Compare commits
3 Commits
7b3e50ff29
...
432f8ce176
| Author | SHA1 | Date | |
|---|---|---|---|
| 432f8ce176 | |||
| 695d4e76d0 | |||
| 7f94f83940 |
144
.cursorrules
@ -1,97 +1,63 @@
|
||||
# Contexte projet — Application Marchand de Biens
|
||||
# Application Marchand de Biens — Contexte Cursor
|
||||
|
||||
## Qui utilise cette app
|
||||
Marchand de biens professionnel en France. L'app est utilisée au quotidien sur mobile (iOS/Android) et navigateur web. Elle remplace un ensemble d'outils épars (notes, tableurs, contacts téléphoniques, etc.).
|
||||
## Infrastructure
|
||||
- Backend : PocketBase dans Docker (docker/docker-compose.dev.yml)
|
||||
- Binaire PocketBase : /usr/local/bin/pocketbase
|
||||
- Données : /pb_data (flag --dir=/pb_data dans docker-compose)
|
||||
- OS dev : Windows Git Bash → toujours utiliser MSYS_NO_PATHCONV=1 pour docker exec
|
||||
- PocketBase version : v0.23+
|
||||
- URL locale : http://localhost:8090
|
||||
- URL prod : https://SOUS_DOMAINE.duckdns.org (NAS Synology)
|
||||
|
||||
## Stack technique
|
||||
- **Frontend** : React Native avec Expo (SDK 51+)
|
||||
- **Navigation** : Expo Router (file-based routing)
|
||||
- **Base de données** : Supabase (PostgreSQL)
|
||||
- **Auth** : Supabase Auth (email/password)
|
||||
- **Stockage fichiers** : Supabase Storage (photos, PDFs)
|
||||
- **IA** : API Anthropic Claude (claude-sonnet-4-20250514)
|
||||
- **UI** : NativeWind (Tailwind pour React Native) + React Native Paper pour les composants complexes
|
||||
- **State** : Zustand pour le state global, React Query (TanStack) pour le cache serveur
|
||||
- **Déploiement mobile** : Expo EAS
|
||||
- **Déploiement web** : Vercel
|
||||
- Frontend : React Native avec Expo SDK 51 + Expo Router
|
||||
- SDK PocketBase : npm package "pocketbase"
|
||||
- UI : NativeWind (Tailwind pour React Native)
|
||||
- State : Zustand + React Query (TanStack)
|
||||
- IA : API Anthropic Claude via PocketBase Hook (jamais côté client)
|
||||
- Déploiement mobile : Expo EAS
|
||||
|
||||
## Conventions de code
|
||||
- TypeScript strict partout, jamais de `any`
|
||||
- Noms de fichiers : kebab-case pour les fichiers, PascalCase pour les composants
|
||||
- Toujours utiliser des hooks personnalisés pour la logique métier (ex: `useBiens`, `useContacts`)
|
||||
- Les appels Supabase se font UNIQUEMENT dans les hooks, jamais dans les composants
|
||||
- Les types TypeScript sont définis dans `/types/database.ts` (généré depuis Supabase)
|
||||
- Les constantes métier sont dans `/constants/metier.ts`
|
||||
- Toujours gérer les états de chargement et d'erreur
|
||||
- Commentaires en français pour la logique métier, anglais pour le code technique
|
||||
## Client PocketBase — pattern obligatoire
|
||||
// /services/pocketbase.ts — singleton, importer partout
|
||||
import PocketBase from 'pocketbase';
|
||||
export const pb = new PocketBase(process.env.EXPO_PUBLIC_PB_URL);
|
||||
|
||||
## Vocabulaire métier (utiliser ces termes précis)
|
||||
- **Bien** : propriété immobilière prospectée ou acquise
|
||||
- **Piste** : bien en cours d'analyse, pas encore d'offre
|
||||
- **Dossier** : bien avec offre en cours ou acte signé
|
||||
- **Fiche bien** : écran de détail d'un bien
|
||||
- **Compromis** : avant-contrat de vente (SPC)
|
||||
- **Acte** : acte authentique de vente chez notaire
|
||||
- **Portage** : période entre achat et revente (coût = intérêts + taxes)
|
||||
- **Marge brute** : prix revente - prix achat - travaux - frais notaire achat
|
||||
- **Marge nette** : marge brute - frais de portage - frais d'agence vente - impôts
|
||||
- **DPE** : Diagnostic de Performance Énergétique
|
||||
- **Surface habitable** : surface loi Carrez pour appartements
|
||||
- **Marchand de biens** = le user, l'utilisateur de cette app
|
||||
## Collections PocketBase (toutes créées via migration)
|
||||
etapes_pipeline, contacts, biens, analyses_financieres,
|
||||
visites, taches, notes_biens, documents_biens, devis_travaux
|
||||
|
||||
## Modules de l'application
|
||||
1. **Prospection** : pipeline Kanban des biens (piste → analyse → offre → compromis → acte → revente)
|
||||
2. **Annuaire** : contacts métier (notaires, artisans, banquiers, agents immo)
|
||||
3. **Fiches biens** : dossier complet par bien (photos, docs, historique)
|
||||
4. **Calculateur** : analyse de rentabilité financière
|
||||
5. **Visites** : compte-rendus de visites avec check-list
|
||||
6. **Travaux** : suivi de chantier et devis
|
||||
7. **Administratif** : documents, délais légaux, alertes
|
||||
8. **Agenda** : tâches et rappels liés aux biens
|
||||
9. **Dashboard** : vue globale et KPIs
|
||||
## Règles de code
|
||||
- TypeScript strict, jamais de any
|
||||
- Appels PocketBase UNIQUEMENT dans les hooks (/hooks/)
|
||||
- Jamais de nouvelle instance PocketBase, toujours importer pb
|
||||
- Commentaires métier en français, code en anglais
|
||||
- Gérer loading + erreur partout
|
||||
|
||||
## Structure des dossiers
|
||||
```
|
||||
/app → écrans (Expo Router)
|
||||
/(tabs) → navigation principale
|
||||
/prospection
|
||||
/annuaire
|
||||
/agenda
|
||||
/dashboard
|
||||
/bien/[id] → fiche bien
|
||||
/visite/[id] → rapport de visite
|
||||
/contact/[id] → fiche contact
|
||||
/components → composants réutilisables
|
||||
/ui → composants génériques (Button, Card, Input...)
|
||||
/biens → composants spécifiques aux biens
|
||||
/visites → composants spécifiques aux visites
|
||||
/hooks → hooks personnalisés
|
||||
/services → appels API (Supabase, Anthropic)
|
||||
/supabase.ts → client Supabase
|
||||
/ai.ts → appels Claude API
|
||||
## Vocabulaire métier
|
||||
- Bien : propriété immobilière prospectée ou acquise
|
||||
- Piste : bien en phase d'analyse
|
||||
- Portage : période entre achat et revente
|
||||
- Marge brute : prix revente - prix achat - travaux - frais notaire
|
||||
- Marge nette : marge brute - portage - frais agence - impôts
|
||||
|
||||
## Formules financières
|
||||
frais_notaire = prix_achat * 0.075 (ancien) ou * 0.02 (neuf)
|
||||
frais_portage_total = (prix_achat * taux_credit/100/12 + taxe_fonciere/12 + charges) * duree_mois
|
||||
travaux_total = budget_travaux * (1 + reserve_pct/100)
|
||||
prix_revient = prix_achat + frais_notaire + frais_agence_achat + travaux_total + frais_portage_total
|
||||
marge_brute = prix_revente - prix_revient
|
||||
marge_nette = marge_brute - (prix_revente * frais_agence_vente_pct/100) - (marge_brute * taux_impot/100)
|
||||
|
||||
## Structure dossiers
|
||||
/docker → docker-compose dev + prod
|
||||
/pocketbase
|
||||
/pb_data → données (dans .gitignore)
|
||||
/pb_hooks → hooks JS côté serveur (IA)
|
||||
/pb_migrations → migrations auto au démarrage
|
||||
/app → code Expo React Native
|
||||
/app → écrans Expo Router
|
||||
/components → composants UI
|
||||
/hooks → hooks métier
|
||||
/services → pocketbase.ts
|
||||
/types → types TypeScript
|
||||
/constants → constantes et configuration
|
||||
/utils → fonctions utilitaires
|
||||
```
|
||||
|
||||
## Variables d'environnement nécessaires
|
||||
```
|
||||
EXPO_PUBLIC_SUPABASE_URL=
|
||||
EXPO_PUBLIC_SUPABASE_ANON_KEY=
|
||||
ANTHROPIC_API_KEY= ← côté serveur uniquement, jamais exposé côté client
|
||||
```
|
||||
|
||||
## Règles de sécurité importantes
|
||||
- La clé API Anthropic ne doit JAMAIS être dans le code client
|
||||
- Créer une Supabase Edge Function pour les appels IA
|
||||
- Row Level Security (RLS) activé sur toutes les tables Supabase
|
||||
- Les photos et docs sont dans des buckets Supabase privés
|
||||
|
||||
## Calculs financiers — formules exactes
|
||||
```
|
||||
frais_notaire_achat = prix_achat * 0.075 (ancien) ou * 0.02 (neuf)
|
||||
prix_revient = prix_achat + frais_notaire_achat + travaux + frais_portage
|
||||
marge_brute = prix_revente_cible - prix_revient
|
||||
frais_portage_mensuel = (prix_achat * taux_credit / 12) + taxe_fonciere_mensuelle
|
||||
taux_marge_brute = marge_brute / prix_revente_cible * 100
|
||||
```
|
||||
/constants → constantes métier
|
||||
|
||||
12
.env.example
Normal file
@ -0,0 +1,12 @@
|
||||
# Dev local
|
||||
EXPO_PUBLIC_PB_URL=http://localhost:8090
|
||||
|
||||
# Production NAS (décommenter)
|
||||
# EXPO_PUBLIC_PB_URL=https://VOTRE_SOUS_DOMAINE.duckdns.org
|
||||
|
||||
# Clé IA (jamais dans Git)
|
||||
ANTHROPIC_API_KEY=sk-ant-VOTRE_CLE
|
||||
|
||||
# DuckDNS (production)
|
||||
DUCKDNS_SUBDOMAINS=VOTRE_SOUS_DOMAINE
|
||||
DUCKDNS_TOKEN=VOTRE_TOKEN
|
||||
43
.gitignore
vendored
@ -1,41 +1,8 @@
|
||||
# Learn more https://docs.github.com/en/get-started/getting-started-with-git/ignoring-files
|
||||
|
||||
# dependencies
|
||||
pocketbase/pb_data/
|
||||
.env.local
|
||||
.env.production
|
||||
*.env
|
||||
docker/ssl/
|
||||
node_modules/
|
||||
|
||||
# Expo
|
||||
.expo/
|
||||
dist/
|
||||
web-build/
|
||||
expo-env.d.ts
|
||||
|
||||
# Native
|
||||
.kotlin/
|
||||
*.orig.*
|
||||
*.jks
|
||||
*.p8
|
||||
*.p12
|
||||
*.key
|
||||
*.mobileprovision
|
||||
|
||||
# Metro
|
||||
.metro-health-check*
|
||||
|
||||
# debug
|
||||
npm-debug.*
|
||||
yarn-debug.*
|
||||
yarn-error.*
|
||||
|
||||
# macOS
|
||||
.DS_Store
|
||||
*.pem
|
||||
|
||||
# local env files
|
||||
.env*.local
|
||||
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
|
||||
# generated native folders
|
||||
/ios
|
||||
/android
|
||||
|
||||
147
AGENTS.md
@ -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.
|
||||
> Mets à jour la section "État" après chaque session.
|
||||
## État actuel
|
||||
- [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)
|
||||
|
||||
---
|
||||
|
||||
## État global du projet
|
||||
|
||||
- [ ] Agent 0 — Setup initial (Expo + Supabase)
|
||||
- [ ] Agent 1 — Schéma base de données + types TypeScript
|
||||
- [ ] Agent 2 — Navigation + écrans vides
|
||||
- [ ] Agent 3 — Module Prospection (pipeline Kanban)
|
||||
- [ ] Agent 4 — Module Fiche Bien
|
||||
- [ ] Agent 5 — Calculateur financier
|
||||
- [ ] Agent 6 — Module Annuaire contacts
|
||||
- [ ] Agent 7 — Module Visites (avec IA)
|
||||
- [ ] Agent 8 — Module Agenda & tâches
|
||||
- [ ] Agent 9 — Dashboard & KPIs
|
||||
- [ ] Agent 10 — Module Travaux
|
||||
- [ ] Agent 11 — Module Administratif (alertes, docs)
|
||||
- [ ] Agent 12 — Polish mobile (offline, notifications push)
|
||||
|
||||
---
|
||||
|
||||
## Agent 0 — Setup initial
|
||||
**Statut** : ⬜ Non démarré
|
||||
**Objectif** : Projet Expo fonctionnel connecté à Supabase avec auth
|
||||
**Livrable** : App qui se lance, login qui fonctionne, navigation de base
|
||||
**Dernière session** : —
|
||||
**Problèmes rencontrés** : —
|
||||
|
||||
---
|
||||
|
||||
## Agent 1 — Base de données
|
||||
**Statut** : ⬜ Non démarré
|
||||
**Objectif** : Toutes les tables SQL créées dans Supabase avec RLS
|
||||
**Livrable** : schema.sql exécuté, types TypeScript générés
|
||||
**Dernière session** : —
|
||||
**Problèmes rencontrés** : —
|
||||
|
||||
---
|
||||
|
||||
## Agent 2 — Navigation
|
||||
**Statut** : ⬜ Non démarré
|
||||
**Objectif** : Structure de navigation complète avec tous les onglets
|
||||
**Livrable** : Tous les écrans existent (même vides), navigation fonctionnelle
|
||||
**Dernière session** : —
|
||||
**Problèmes rencontrés** : —
|
||||
|
||||
---
|
||||
|
||||
## Agent 3 — Prospection
|
||||
**Statut** : ⬜ Non démarré
|
||||
**Objectif** : Pipeline Kanban des biens
|
||||
**Livrable** : Vue Kanban, création d'un bien, déplacement entre étapes
|
||||
**Tables utilisées** : `biens`, `etapes_pipeline`
|
||||
**Dernière session** : —
|
||||
**Problèmes rencontrés** : —
|
||||
|
||||
---
|
||||
|
||||
## Agent 4 — Fiche bien
|
||||
**Statut** : ⬜ Non démarré
|
||||
**Objectif** : Écran détail complet d'un bien
|
||||
**Livrable** : Fiche avec infos, photos, documents, historique
|
||||
**Tables utilisées** : `biens`, `photos_biens`, `documents_biens`, `notes_biens`
|
||||
**Dernière session** : —
|
||||
**Problèmes rencontrés** : —
|
||||
|
||||
---
|
||||
|
||||
## Agent 5 — Calculateur
|
||||
**Statut** : ⬜ Non démarré
|
||||
**Objectif** : Calculateur de rentabilité intégré dans la fiche bien
|
||||
**Livrable** : Formulaire avec calculs en temps réel, sauvegarde dans Supabase
|
||||
**Tables utilisées** : `analyses_financieres`
|
||||
**Dernière session** : —
|
||||
**Problèmes rencontrés** : —
|
||||
|
||||
---
|
||||
|
||||
## Agent 6 — Annuaire
|
||||
**Statut** : ⬜ Non démarré
|
||||
**Objectif** : Annuaire de contacts professionnels
|
||||
**Livrable** : Liste, recherche, fiche contact, appel natif
|
||||
**Tables utilisées** : `contacts`, `categories_contacts`
|
||||
**Dernière session** : —
|
||||
**Problèmes rencontrés** : —
|
||||
|
||||
---
|
||||
|
||||
## Agent 7 — Visites
|
||||
**Statut** : ⬜ Non démarré
|
||||
**Objectif** : Module de visite avec CR généré par IA
|
||||
**Livrable** : Check-list de visite, notes, génération CR via Claude API
|
||||
**Tables utilisées** : `visites`, `items_checklist`, `rapports_visite`
|
||||
**Edge Functions** : `generate-rapport-visite`
|
||||
**Dernière session** : —
|
||||
**Problèmes rencontrés** : —
|
||||
|
||||
---
|
||||
|
||||
## Agent 8 — Agenda
|
||||
**Statut** : ⬜ Non démarré
|
||||
**Objectif** : Tâches et rappels liés aux biens
|
||||
**Livrable** : Vue agenda, création tâches, notifications
|
||||
**Tables utilisées** : `taches`, `rappels`
|
||||
**Dernière session** : —
|
||||
**Problèmes rencontrés** : —
|
||||
|
||||
---
|
||||
|
||||
## Agent 9 — Dashboard
|
||||
**Statut** : ⬜ Non démarré
|
||||
**Objectif** : Vue d'ensemble et KPIs
|
||||
**Livrable** : Chiffres clés, biens en cours, alertes urgentes
|
||||
**Tables utilisées** : Toutes (requêtes agrégées)
|
||||
**Dernière session** : —
|
||||
**Problèmes rencontrés** : —
|
||||
|
||||
---
|
||||
|
||||
## Notes techniques globales
|
||||
_(Ajouter ici les décisions d'architecture prises en cours de projet)_
|
||||
|
||||
-
|
||||
## Infos techniques
|
||||
- PocketBase : http://localhost:8090
|
||||
- Admin : http://localhost:8090/_/ (admin@mdb.fr)
|
||||
- Binaire : /usr/local/bin/pocketbase
|
||||
- Données : /pb_data
|
||||
- Hooks JS : volume `pb_hooks` → `--hooksDir=/pb_hooks` (image muchobien)
|
||||
- Variable IA : `ANTHROPIC_API_KEY` dans `.env.local` (chargée par Docker pour PocketBase)
|
||||
- OS : Windows Git Bash (MSYS_NO_PATHCONV=1)
|
||||
- PocketBase : v0.23+
|
||||
|
||||
193
GUIDE_COMPLET.md
Normal 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
@ -1,116 +0,0 @@
|
||||
# ============================================================
|
||||
# Makefile — Raccourcis pour le projet mb-app
|
||||
# Usage : make <commande>
|
||||
# ============================================================
|
||||
|
||||
.PHONY: help dev dev-stop prod prod-stop logs status setup-nas deploy
|
||||
|
||||
# Affiche l'aide
|
||||
help:
|
||||
@echo ""
|
||||
@echo " Développement local"
|
||||
@echo " ─────────────────────────────────────────"
|
||||
@echo " make dev → Lance PocketBase en local"
|
||||
@echo " make dev-stop → Arrête le conteneur local"
|
||||
@echo " make logs → Logs PocketBase en temps réel"
|
||||
@echo " make status → État des conteneurs"
|
||||
@echo ""
|
||||
@echo " Production NAS"
|
||||
@echo " ─────────────────────────────────────────"
|
||||
@echo " make prod → Lance la stack complète NAS"
|
||||
@echo " make prod-stop → Arrête tout"
|
||||
@echo " make deploy → git pull + restart (sur le NAS)"
|
||||
@echo ""
|
||||
@echo " Setup"
|
||||
@echo " ─────────────────────────────────────────"
|
||||
@echo " make setup → Crée les dossiers manquants"
|
||||
@echo " make ssl → Obtient le certificat SSL (1ère fois)"
|
||||
@echo " make renew-ssl → Renouvelle le certificat SSL"
|
||||
@echo ""
|
||||
|
||||
# ── DÉVELOPPEMENT LOCAL ──────────────────────────────────────
|
||||
|
||||
dev:
|
||||
@echo "🚀 Lancement PocketBase en local..."
|
||||
@[ -f .env.local ] || (echo "❌ Fichier .env.local manquant ! Copier .env.example" && exit 1)
|
||||
docker compose -f docker/docker-compose.dev.yml up -d
|
||||
@echo "✅ PocketBase : http://localhost:8090"
|
||||
@echo "✅ Admin : http://localhost:8090/_/"
|
||||
|
||||
dev-stop:
|
||||
docker compose -f docker/docker-compose.dev.yml down
|
||||
|
||||
dev-reset:
|
||||
@echo "⚠️ Supprime les données locales de dev !"
|
||||
@read -p "Confirmer ? (oui/non) : " c; [ "$$c" = "oui" ] || exit 1
|
||||
docker compose -f docker/docker-compose.dev.yml down
|
||||
rm -rf pocketbase/pb_data
|
||||
@echo "✅ Données supprimées"
|
||||
|
||||
# ── PRODUCTION NAS ───────────────────────────────────────────
|
||||
|
||||
prod:
|
||||
@echo "🚀 Lancement stack production..."
|
||||
@[ -f .env.production ] || (echo "❌ Fichier .env.production manquant !" && exit 1)
|
||||
docker compose -f docker/docker-compose.prod.yml up -d
|
||||
@echo "✅ Stack lancée"
|
||||
|
||||
prod-stop:
|
||||
docker compose -f docker/docker-compose.prod.yml down
|
||||
|
||||
deploy:
|
||||
@echo "📦 Déploiement en cours..."
|
||||
git pull origin main
|
||||
docker compose -f docker/docker-compose.prod.yml restart pocketbase
|
||||
@echo "✅ Déployé"
|
||||
|
||||
# ── LOGS & STATUS ────────────────────────────────────────────
|
||||
|
||||
logs:
|
||||
docker compose -f docker/docker-compose.dev.yml logs -f pocketbase
|
||||
|
||||
logs-prod:
|
||||
docker compose -f docker/docker-compose.prod.yml logs -f pocketbase
|
||||
|
||||
status:
|
||||
docker compose -f docker/docker-compose.dev.yml ps
|
||||
|
||||
# ── SETUP ────────────────────────────────────────────────────
|
||||
|
||||
setup:
|
||||
@echo "📁 Création des dossiers..."
|
||||
mkdir -p pocketbase/pb_data
|
||||
mkdir -p pocketbase/pb_hooks
|
||||
mkdir -p pocketbase/pb_migrations
|
||||
@[ -f .env.local ] || cp .env.example .env.local
|
||||
@echo "✅ Structure prête"
|
||||
@echo "👉 Éditer .env.local avec votre clé Anthropic"
|
||||
|
||||
setup-nas:
|
||||
@echo "📁 Création des dossiers sur le NAS..."
|
||||
mkdir -p /volume1/docker/mb-app/pb_data
|
||||
mkdir -p /volume1/docker/mb-app/ssl
|
||||
mkdir -p /volume1/docker/mb-app/duckdns
|
||||
@[ -f .env.production ] || cp .env.example .env.production
|
||||
@echo "✅ Dossiers NAS créés"
|
||||
@echo "👉 Éditer .env.production"
|
||||
|
||||
ssl:
|
||||
@echo "🔐 Obtention certificat SSL..."
|
||||
@[ -f .env.production ] || (echo "❌ .env.production manquant" && exit 1)
|
||||
@source .env.production && docker run --rm \
|
||||
-v /volume1/docker/mb-app/ssl:/etc/letsencrypt \
|
||||
-v /tmp/certbot-webroot:/var/www/certbot \
|
||||
-p 80:80 \
|
||||
certbot/certbot certonly --standalone \
|
||||
-d $$DUCKDNS_SUBDOMAINS.duckdns.org \
|
||||
--non-interactive --agree-tos \
|
||||
--email admin@example.com
|
||||
@echo "✅ Certificat obtenu"
|
||||
|
||||
renew-ssl:
|
||||
docker run --rm \
|
||||
-v /volume1/docker/mb-app/ssl:/etc/letsencrypt \
|
||||
certbot/certbot renew --quiet
|
||||
docker compose -f docker/docker-compose.prod.yml restart nginx
|
||||
@echo "✅ Certificat renouvelé"
|
||||
@ -1,690 +0,0 @@
|
||||
# PROMPTS CURSOR — Application Marchand de Biens
|
||||
# Copier-coller chaque prompt dans Cursor Chat (Cmd+L ou Ctrl+L)
|
||||
# Lire le fichier .cursorrules avant chaque session
|
||||
|
||||
---
|
||||
|
||||
## ═══════════════════════════════════════════
|
||||
## AGENT 0 — Setup initial du projet
|
||||
## ═══════════════════════════════════════════
|
||||
## Durée estimée : 1-2h
|
||||
|
||||
```
|
||||
Lis attentivement le fichier .cursorrules pour comprendre le contexte du projet.
|
||||
|
||||
Je veux créer une application React Native avec Expo pour un marchand de biens immobiliers.
|
||||
|
||||
Crée le projet de zéro avec cette configuration précise :
|
||||
|
||||
1. INITIALISATION DU PROJET :
|
||||
- Initialise un nouveau projet Expo avec Expo Router : `npx create-expo-app@latest mb-app --template tabs`
|
||||
- TypeScript strict
|
||||
- NativeWind pour le styling (Tailwind CSS pour React Native)
|
||||
|
||||
2. DÉPENDANCES À INSTALLER :
|
||||
- @supabase/supabase-js
|
||||
- @tanstack/react-query
|
||||
- zustand
|
||||
- react-native-paper
|
||||
- expo-image-picker
|
||||
- expo-document-picker
|
||||
- expo-file-system
|
||||
- expo-notifications
|
||||
- react-native-maps
|
||||
- @react-native-async-storage/async-storage
|
||||
- react-native-safe-area-context (si pas déjà inclus)
|
||||
|
||||
3. CONFIGURATION SUPABASE :
|
||||
Crée le fichier `/services/supabase.ts` avec :
|
||||
- Initialisation du client Supabase avec les variables d'env
|
||||
- Export du client
|
||||
- Gestion de la session (AsyncStorage pour mobile)
|
||||
|
||||
4. CONFIGURATION AUTH :
|
||||
Crée un contexte d'authentification `/context/AuthContext.tsx` avec :
|
||||
- Connexion par email/password
|
||||
- Déconnexion
|
||||
- État de l'utilisateur connecté
|
||||
- Redirection automatique si non connecté
|
||||
|
||||
5. ÉCRANS D'AUTH :
|
||||
- `/app/auth/login.tsx` : formulaire email + password, bouton connexion
|
||||
- `/app/auth/register.tsx` : formulaire inscription (email, password, nom, prénom)
|
||||
- Style sobre et professionnel, couleur principale #1D4ED8 (bleu)
|
||||
|
||||
6. FICHIER .env :
|
||||
Crée `.env.local` avec les placeholders :
|
||||
```
|
||||
EXPO_PUBLIC_SUPABASE_URL=https://VOTRE_PROJET.supabase.co
|
||||
EXPO_PUBLIC_SUPABASE_ANON_KEY=VOTRE_ANON_KEY
|
||||
```
|
||||
|
||||
7. VALIDATION :
|
||||
L'app doit se lancer avec `npx expo start`, afficher l'écran de login, et permettre la connexion.
|
||||
|
||||
Ne génère PAS encore les écrans de contenu, seulement l'authentification qui fonctionne.
|
||||
Mets à jour AGENTS.md : Agent 0 = ✅ Terminé.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ═══════════════════════════════════════════
|
||||
## AGENT 1 — Types TypeScript + connexion DB
|
||||
## ═══════════════════════════════════════════
|
||||
## Prérequis : Avoir exécuté schema.sql dans Supabase
|
||||
|
||||
```
|
||||
Lis .cursorrules et AGENTS.md.
|
||||
|
||||
Le schéma SQL a été exécuté dans Supabase. Je dois maintenant créer les types TypeScript et les services de base.
|
||||
|
||||
1. TYPES TYPESCRIPT `/types/database.ts` :
|
||||
Crée des interfaces TypeScript pour TOUTES les tables du schéma :
|
||||
- Profile, Bien, EtapePipeline, AnalyseFinanciere
|
||||
- Contact, BienContact
|
||||
- Visite, ChecklistItem, ChecklistReponse
|
||||
- PhotoBien, DocumentBien
|
||||
- Tache, NoteBien, DevisTravaux
|
||||
|
||||
Pour chaque table, crée aussi un type "Insert" (sans id et dates auto) et "Update" (tout optionnel).
|
||||
Exemple :
|
||||
```typescript
|
||||
export interface Bien { id: string; user_id: string; titre?: string; ... }
|
||||
export type BienInsert = Omit<Bien, 'id' | 'created_at' | 'updated_at'>
|
||||
export type BienUpdate = Partial<BienInsert>
|
||||
```
|
||||
|
||||
2. CONSTANTES MÉTIER `/constants/metier.ts` :
|
||||
- ETAPES_DEFAUT : noms des étapes du pipeline
|
||||
- CATEGORIES_CONTACTS : liste avec labels français
|
||||
- TYPES_BIENS : appartement, maison, immeuble, etc.
|
||||
- CATEGORIES_CHECKLIST : avec emojis et labels
|
||||
- TYPES_DOCUMENTS : compromis, acte, DPE, etc.
|
||||
|
||||
3. HOOK DE BASE `/hooks/useBiens.ts` :
|
||||
- fetchBiens() : récupère tous les biens de l'utilisateur avec l'étape
|
||||
- createBien(data) : crée un bien, retourne l'id
|
||||
- updateBien(id, data) : met à jour
|
||||
- deleteBien(id) : supprime
|
||||
|
||||
4. HOOK `/hooks/useContacts.ts` :
|
||||
- fetchContacts() : avec filtre par catégorie optionnel
|
||||
- createContact(data)
|
||||
- updateContact(id, data)
|
||||
|
||||
5. REACT QUERY CONFIG `/services/queryClient.ts` :
|
||||
- Initialisation de QueryClient
|
||||
- Stale time : 5 minutes
|
||||
- Retry : 1 fois
|
||||
|
||||
Mets à jour AGENTS.md : Agent 1 = ✅ Terminé.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ═══════════════════════════════════════════
|
||||
## AGENT 2 — Navigation complète
|
||||
## ═══════════════════════════════════════════
|
||||
|
||||
```
|
||||
Lis .cursorrules et AGENTS.md.
|
||||
|
||||
Crée la structure de navigation complète de l'application avec Expo Router.
|
||||
|
||||
1. NAVIGATION PAR ONGLETS `/app/(tabs)/` :
|
||||
5 onglets avec icônes Expo Vector Icons :
|
||||
- `index.tsx` → Dashboard (icône: grid, label: "Vue d'ensemble")
|
||||
- `prospection.tsx` → Prospection (icône: search, label: "Biens")
|
||||
- `visites.tsx` → Visites (icône: clipboard, label: "Visites")
|
||||
- `annuaire.tsx` → Annuaire (icône: people, label: "Contacts")
|
||||
- `agenda.tsx` → Agenda (icône: calendar, label: "Agenda")
|
||||
|
||||
Couleur active : #1D4ED8, couleur inactive : #9CA3AF
|
||||
|
||||
2. ÉCRANS DE DÉTAIL (hors onglets) :
|
||||
- `/app/bien/[id].tsx` → Fiche bien (en-tête avec titre du bien)
|
||||
- `/app/bien/nouveau.tsx` → Création d'un bien
|
||||
- `/app/contact/[id].tsx` → Fiche contact
|
||||
- `/app/contact/nouveau.tsx` → Nouveau contact
|
||||
- `/app/visite/[id].tsx` → Rapport de visite
|
||||
- `/app/visite/nouvelle.tsx` → Nouvelle visite (prend un bien_id en param)
|
||||
- `/app/calculateur/[bienId].tsx` → Calculateur financier
|
||||
|
||||
3. CHAQUE ÉCRAN doit avoir pour l'instant :
|
||||
- Un titre visible
|
||||
- Un texte "Module [Nom] — à venir"
|
||||
- Le bouton de navigation retour (automatique avec Expo Router)
|
||||
|
||||
4. HEADER GLOBAL :
|
||||
- Bouton profil en haut à droite sur les onglets
|
||||
- Titre dynamique selon l'onglet actif
|
||||
|
||||
5. BOUTON FLOATING ACTION BUTTON (FAB) :
|
||||
Sur l'onglet Prospection et Annuaire : bouton "+" en bas à droite pour ajouter un bien/contact.
|
||||
|
||||
Mets à jour AGENTS.md : Agent 2 = ✅ Terminé.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ═══════════════════════════════════════════
|
||||
## AGENT 3 — Module Prospection (Kanban)
|
||||
## ═══════════════════════════════════════════
|
||||
|
||||
```
|
||||
Lis .cursorrules et AGENTS.md.
|
||||
|
||||
Crée le module Prospection complet : vue pipeline Kanban des biens.
|
||||
|
||||
1. ÉCRAN PROSPECTION `/app/(tabs)/prospection.tsx` :
|
||||
|
||||
Vue principale avec 2 modes switchables (boutons en haut) :
|
||||
|
||||
MODE KANBAN (défaut) :
|
||||
- Colonnes horizontales scrollables (ScrollView horizontal)
|
||||
- Chaque colonne = une étape du pipeline (depuis Supabase)
|
||||
- Chaque carte de bien affiche : titre, ville, surface, prix achat, badge de priorité
|
||||
- Nombre de biens par colonne dans le header de colonne
|
||||
- Couleur de colonne selon la couleur définie dans etapes_pipeline
|
||||
|
||||
MODE LISTE :
|
||||
- FlatList avec tri par date, ville, ou priorité
|
||||
- Même info que les cartes Kanban + statut en badge
|
||||
|
||||
2. COMPOSANT CARTE BIEN `/components/biens/CarteBien.tsx` :
|
||||
- Titre ou "Bien sans titre" si vide
|
||||
- Ville + code postal
|
||||
- Surface habitable (si renseignée)
|
||||
- Prix d'achat formaté en €
|
||||
- Indicateur priorité (rouge=haute, orange=normale, gris=basse)
|
||||
- Badge source (off-market, agence, notaire...)
|
||||
- onPress → navigation vers `/bien/[id]`
|
||||
- onLongPress → modal rapide (changer étape, appeler contact, supprimer)
|
||||
|
||||
3. FORMULAIRE CRÉATION BIEN `/app/bien/nouveau.tsx` :
|
||||
Formulaire en plusieurs étapes (step 1, step 2, step 3) :
|
||||
|
||||
Étape 1 — Localisation :
|
||||
- Adresse (text input)
|
||||
- Ville + code postal
|
||||
- Type de bien (Select : appartement, maison, immeuble, terrain...)
|
||||
|
||||
Étape 2 — Caractéristiques :
|
||||
- Surface habitable
|
||||
- Nombre de pièces
|
||||
- Prix d'achat estimé
|
||||
- Source (Select)
|
||||
|
||||
Étape 3 — Résumé + validation
|
||||
|
||||
Bouton "Créer le bien" → POST Supabase → redirect vers la fiche bien
|
||||
|
||||
4. FILTRES ET RECHERCHE :
|
||||
- Barre de recherche par ville ou titre
|
||||
- Filtre rapide : Tous / Actifs / Abandonnés
|
||||
|
||||
5. HOOK `/hooks/usePipeline.ts` :
|
||||
- fetchBiensParEtape() : retourne les biens groupés par étape
|
||||
- moveToEtape(bienId, etapeId) : change l'étape d'un bien
|
||||
- fetchEtapes() : récupère les étapes du pipeline
|
||||
|
||||
Mets à jour AGENTS.md : Agent 3 = ✅ Terminé.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ═══════════════════════════════════════════
|
||||
## AGENT 4 — Fiche Bien (détail complet)
|
||||
## ═══════════════════════════════════════════
|
||||
|
||||
```
|
||||
Lis .cursorrules et AGENTS.md.
|
||||
|
||||
Crée l'écran de fiche bien complet : le "dossier" central de l'app.
|
||||
|
||||
ÉCRAN `/app/bien/[id].tsx` :
|
||||
|
||||
Structure avec ScrollView et sections collapsibles :
|
||||
|
||||
1. EN-TÊTE :
|
||||
- Photo principale (ou placeholder avec icône maison)
|
||||
- Adresse complète
|
||||
- Badge étape actuelle (avec bouton pour changer)
|
||||
- Score d'opportunité visuel (étoiles ou barre)
|
||||
- Boutons rapides : Appeler vendeur | Calculateur | Nouvelle visite
|
||||
|
||||
2. SECTION "INFOS" :
|
||||
- Type de bien, surface, pièces, étages, année construction
|
||||
- DPE : badge coloré (A=vert foncé → G=rouge)
|
||||
- Source de la piste
|
||||
|
||||
3. SECTION "FINANCES" (résumé) :
|
||||
- Prix d'achat, budget travaux estimé
|
||||
- Marge cible (si analyse créée)
|
||||
- Bouton → ouvre le calculateur
|
||||
|
||||
4. SECTION "VISITES" :
|
||||
- Liste des visites avec date et avis
|
||||
- Bouton "Nouvelle visite"
|
||||
|
||||
5. SECTION "CONTACTS" :
|
||||
- Contacts liés (notaire, agent, artisans...)
|
||||
- Bouton pour lier un contact existant
|
||||
|
||||
6. SECTION "DOCUMENTS" :
|
||||
- Liste des documents (DPE, compromis, etc.)
|
||||
- Bouton upload (expo-document-picker)
|
||||
|
||||
7. SECTION "NOTES" :
|
||||
- Notes libres avec date
|
||||
- Champ pour ajouter une note rapide
|
||||
|
||||
8. SECTION "TIMELINE" :
|
||||
- Historique chronologique des actions (création, visite, offre, compromis...)
|
||||
|
||||
ÉDITION :
|
||||
- Bouton "Modifier" en haut à droite → mode édition inline
|
||||
- Chaque section modifiable directement
|
||||
- Auto-save avec debounce (500ms)
|
||||
|
||||
HOOK `/hooks/useBienDetail.ts` :
|
||||
- fetchBienComplet(id) : avec toutes les relations (visites, contacts, docs, notes)
|
||||
- addNote(bienId, contenu)
|
||||
- linkContact(bienId, contactId, role)
|
||||
- uploadDocument(bienId, file, type)
|
||||
|
||||
Mets à jour AGENTS.md : Agent 4 = ✅ Terminé.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ═══════════════════════════════════════════
|
||||
## AGENT 5 — Calculateur financier
|
||||
## ═══════════════════════════════════════════
|
||||
|
||||
```
|
||||
Lis .cursorrules et AGENTS.md.
|
||||
|
||||
Crée le calculateur de rentabilité, le cœur financier de l'application.
|
||||
|
||||
ÉCRAN `/app/calculateur/[bienId].tsx` :
|
||||
|
||||
Ce calculateur utilise les formules exactes définies dans .cursorrules.
|
||||
|
||||
1. SECTION ACQUISITION :
|
||||
- Prix d'achat (€) — input numérique
|
||||
- Type de bien fiscal : Ancien (7,5%) / Neuf (2%) — toggle
|
||||
- Frais de notaire : calculés automatiquement + possibilité de saisir manuellement
|
||||
- Frais d'agence achat (€ ou %)
|
||||
|
||||
2. SECTION TRAVAUX :
|
||||
- Budget travaux estimé (€)
|
||||
- Réserve imprévus : slider 5-25% (défaut 10%)
|
||||
- Budget total travaux = estimation + réserve
|
||||
|
||||
3. SECTION PORTAGE :
|
||||
- Durée de portage prévue (slider 6-36 mois)
|
||||
- Taux de crédit (%) — pré-rempli depuis le profil
|
||||
- Taxe foncière annuelle (€)
|
||||
- Charges mensuelles (€)
|
||||
- Coût de portage total = calculé automatiquement
|
||||
|
||||
4. SECTION REVENTE :
|
||||
- Prix de revente cible (€)
|
||||
- Frais d'agence vente (% — défaut 5%)
|
||||
- Taux d'imposition (% — défaut 25%)
|
||||
|
||||
5. RÉSULTATS EN TEMPS RÉEL (mis à jour à chaque keystroke) :
|
||||
Affichés dans des cartes colorées :
|
||||
- Prix de revient total (€) — gris
|
||||
- Marge brute (€ et %) — vert si > 15%, orange si 8-15%, rouge si < 8%
|
||||
- Marge nette après impôts (€ et %) — même code couleur
|
||||
- ROI (%) — retour sur investissement
|
||||
- Point mort : prix de revente minimum pour ne pas perdre
|
||||
|
||||
6. SCÉNARIOS :
|
||||
Bouton "Voir les scénarios" → modal avec 3 colonnes :
|
||||
- Pessimiste (-10% prix revente)
|
||||
- Réaliste (prix saisi)
|
||||
- Optimiste (+10% prix revente)
|
||||
Chaque colonne affiche la marge nette correspondante.
|
||||
|
||||
7. SAUVEGARDE :
|
||||
Bouton "Enregistrer l'analyse" → sauvegarde dans analyses_financieres
|
||||
|
||||
8. EXPORT :
|
||||
Bouton "Partager" → génère un résumé texte formaté à partager par SMS/email
|
||||
|
||||
COMPOSANT RÉUTILISABLE `/components/biens/ResultatFinancier.tsx` :
|
||||
Affiche les 4 KPIs financiers (utilisé aussi dans la fiche bien)
|
||||
|
||||
Mets à jour AGENTS.md : Agent 5 = ✅ Terminé.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ═══════════════════════════════════════════
|
||||
## AGENT 6 — Annuaire contacts
|
||||
## ═══════════════════════════════════════════
|
||||
|
||||
```
|
||||
Lis .cursorrules et AGENTS.md.
|
||||
|
||||
Crée le module Annuaire : carnet d'adresses professionnel du marchand de biens.
|
||||
|
||||
1. ÉCRAN ANNUAIRE `/app/(tabs)/annuaire.tsx` :
|
||||
|
||||
EN-TÊTE :
|
||||
- Barre de recherche (prénom, nom, société, ville)
|
||||
- Filtres par catégorie : tous | notaires | agents | artisans | banques | autres
|
||||
- Sous-filtre artisans : par corps de métier (plomberie, élec, maçonnerie...)
|
||||
|
||||
LISTE :
|
||||
- FlatList avec sections par catégorie (SectionList si filtre = "tous")
|
||||
- Chaque item : avatar initiales coloré, nom, société, ville, note étoiles, bouton appel direct
|
||||
- Swipe gauche pour supprimer, swipe droit pour "Favori"
|
||||
- Section "Favoris" en haut de liste
|
||||
|
||||
2. FICHE CONTACT `/app/contact/[id].tsx` :
|
||||
- Avatar grande taille avec initiales
|
||||
- Badges : catégorie + spécialité
|
||||
- Note (étoiles, modifiable par tap)
|
||||
- Boutons actions : Appeler | SMS | Email | Copier
|
||||
- Section "Biens associés" : liste des biens où ce contact intervient
|
||||
- Section "Historique" : notes d'échanges avec date
|
||||
- Champ "Notes" libre
|
||||
- Infos artisan : taux horaire, remise habituelle
|
||||
|
||||
3. FORMULAIRE NOUVEAU CONTACT `/app/contact/nouveau.tsx` :
|
||||
- Catégorie (obligatoire) — change les champs affichés
|
||||
- Nom, prénom, société
|
||||
- Téléphone (avec format FR automatique)
|
||||
- Email
|
||||
- Ville + zone d'intervention
|
||||
- Spécialité (si artisan)
|
||||
- Note initiale
|
||||
|
||||
4. FONCTIONNALITÉ APPEL :
|
||||
Import `{ Linking } from 'react-native'`
|
||||
`Linking.openURL('tel:' + telephone)` — fonctionne sur iOS et Android
|
||||
|
||||
5. HOOK `/hooks/useContacts.ts` (compléter celui de l'Agent 1) :
|
||||
- searchContacts(query, categorie?)
|
||||
- getFavoris()
|
||||
- getBiensByContact(contactId)
|
||||
- toggleFavori(contactId)
|
||||
|
||||
Mets à jour AGENTS.md : Agent 6 = ✅ Terminé.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ═══════════════════════════════════════════
|
||||
## AGENT 7 — Module Visites (avec IA Claude)
|
||||
## ═══════════════════════════════════════════
|
||||
|
||||
```
|
||||
Lis .cursorrules et AGENTS.md.
|
||||
|
||||
Crée le module Visites avec génération de compte-rendu par IA Claude.
|
||||
|
||||
⚠️ IMPORTANT SÉCURITÉ : L'appel à l'API Anthropic doit se faire via une Supabase Edge Function,
|
||||
jamais directement depuis le client mobile (la clé API ne doit pas être dans le code React Native).
|
||||
|
||||
1. EDGE FUNCTION SUPABASE `/supabase/functions/generate-rapport-visite/index.ts` :
|
||||
```typescript
|
||||
// Reçoit : { notes_brutes, checklist_reponses, bien_info }
|
||||
// Appelle l'API Anthropic Claude
|
||||
// Retourne : { rapport_structure }
|
||||
|
||||
const prompt = `Tu es assistant d'un marchand de biens immobiliers français.
|
||||
|
||||
À partir des notes de visite et de la check-list ci-dessous, génère un compte-rendu
|
||||
de visite professionnel et structuré en français.
|
||||
|
||||
Informations du bien : ${JSON.stringify(bien_info)}
|
||||
|
||||
Notes prises pendant la visite : ${notes_brutes}
|
||||
|
||||
Résultats de la check-list : ${JSON.stringify(checklist_reponses)}
|
||||
|
||||
Génère un compte-rendu avec ces sections :
|
||||
1. Résumé exécutif (3-4 phrases)
|
||||
2. Points positifs (liste)
|
||||
3. Points négatifs / travaux nécessaires (liste)
|
||||
4. Estimation des travaux (si des éléments permettent de l'estimer)
|
||||
5. Recommandation (Coup de cœur / Intéressant / À éviter + justification)
|
||||
6. Prochaines étapes suggérées
|
||||
|
||||
Sois concis, professionnel, et orienté investissement.`;
|
||||
```
|
||||
|
||||
2. ÉCRAN LISTE VISITES `/app/(tabs)/visites.tsx` :
|
||||
- Visites à venir (avec date et bien concerné)
|
||||
- Visites passées avec leur rapport
|
||||
- Bouton "Planifier une visite"
|
||||
|
||||
3. ÉCRAN NOUVELLE VISITE `/app/visite/nouvelle.tsx` :
|
||||
- Sélecteur de bien (liste ou scan QR code si bien a un QR)
|
||||
- Date et heure de la visite
|
||||
- Type : première visite / seconde visite / expert
|
||||
- Redirect vers l'écran de visite en cours
|
||||
|
||||
4. ÉCRAN VISITE EN COURS `/app/visite/[id].tsx` :
|
||||
|
||||
TAB 1 — CHECK-LIST :
|
||||
- Sections par catégorie (Structure, Toiture, Électricité, etc.)
|
||||
- Chaque item avec 4 états : ✅ OK | ⚠️ Attention | ❌ Problème | — Non vérifié
|
||||
- Champ note par item
|
||||
- Progression visuelle (X/Y items vérifiés)
|
||||
|
||||
TAB 2 — NOTES LIBRES :
|
||||
- Grande zone de texte pour notes rapides
|
||||
- Bouton microphone : transcription vocale (expo-av)
|
||||
- Bouton photo : ajouter une photo directement liée à la visite
|
||||
|
||||
TAB 3 — ESTIMATION :
|
||||
- Budget travaux min/max estimé sur place
|
||||
- Durée estimée
|
||||
- Avis global (Coup de cœur / Intéressant / Neutre / À éviter)
|
||||
- Score d'opportunité (slider 1-10)
|
||||
|
||||
BOUTON "GÉNÉRER LE RAPPORT IA" :
|
||||
- Loading spinner avec message "Analyse en cours..."
|
||||
- Appel à la Edge Function Supabase
|
||||
- Affiche le rapport généré
|
||||
- Bouton "Copier" et "Modifier" le rapport
|
||||
- Sauvegarde automatique dans visites.rapport_genere
|
||||
|
||||
5. HOOK `/hooks/useVisite.ts` :
|
||||
- startVisite(bienId, dateVisite)
|
||||
- saveChecklist(visiteId, reponses)
|
||||
- saveNotes(visiteId, notes)
|
||||
- generateRapport(visiteId) → appel Edge Function
|
||||
- fetchVisitesByBien(bienId)
|
||||
|
||||
Mets à jour AGENTS.md : Agent 7 = ✅ Terminé.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ═══════════════════════════════════════════
|
||||
## AGENT 8 — Agenda & tâches
|
||||
## ═══════════════════════════════════════════
|
||||
|
||||
```
|
||||
Lis .cursorrules et AGENTS.md.
|
||||
|
||||
Crée le module Agenda : gestion des tâches quotidiennes du marchand de biens.
|
||||
|
||||
1. ÉCRAN AGENDA `/app/(tabs)/agenda.tsx` :
|
||||
|
||||
VUE "AUJOURD'HUI" (défaut) :
|
||||
- Tâches du jour groupées par bien
|
||||
- Section "En retard" en rouge en haut
|
||||
- Section "Aujourd'hui"
|
||||
- Section "Cette semaine"
|
||||
|
||||
VUE CALENDRIER :
|
||||
- react-native-calendars
|
||||
- Points colorés sur les jours avec tâches
|
||||
- Tap sur un jour → liste des tâches du jour
|
||||
|
||||
2. COMPOSANT TÂCHE `/components/agenda/CarteTache.tsx` :
|
||||
- Checkbox (tap pour cocher = fait)
|
||||
- Titre + description courte
|
||||
- Badge bien associé (avec couleur de l'étape)
|
||||
- Date + heure
|
||||
- Badge priorité
|
||||
- Icône type (téléphone, email, rendez-vous, admin...)
|
||||
- Swipe droit : Snooze +1 jour
|
||||
- Swipe gauche : Supprimer
|
||||
|
||||
3. CRÉATION TÂCHE :
|
||||
Modal bottom sheet avec :
|
||||
- Titre (obligatoire)
|
||||
- Type de tâche (Select avec icônes)
|
||||
- Bien associé (optionnel — searchable Select)
|
||||
- Contact associé (optionnel)
|
||||
- Date et heure d'échéance (DateTimePicker)
|
||||
- Priorité (toggle 3 niveaux)
|
||||
- Rappel (30min avant / 1h / 1 jour / pas de rappel)
|
||||
|
||||
4. TÂCHES AUTO-GÉNÉRÉES :
|
||||
Créer une fonction `genererTachesDelaisLegaux(bienId)` qui génère automatiquement
|
||||
les tâches importantes lors des changements d'étape :
|
||||
- Compromis signé → "SRU 10 jours : fin le [date]"
|
||||
- Compromis signé → "Déblocage financement : échéance [date+45j]"
|
||||
- Acte signé → "Déclaration IS : rappel [date+3mois]"
|
||||
|
||||
5. NOTIFICATIONS :
|
||||
Configurer expo-notifications pour :
|
||||
- Rappel de tâche (heure configurée)
|
||||
- Alerte délai légal (veille)
|
||||
|
||||
6. HOOK `/hooks/useTaches.ts` :
|
||||
- fetchTachesJour(date)
|
||||
- fetchTachesEnRetard()
|
||||
- createTache(data)
|
||||
- toggleTacheStatus(id)
|
||||
- snooze(id, jours)
|
||||
- genererTachesDelaisLegaux(bienId, etapeName)
|
||||
|
||||
Mets à jour AGENTS.md : Agent 8 = ✅ Terminé.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ═══════════════════════════════════════════
|
||||
## AGENT 9 — Dashboard
|
||||
## ═══════════════════════════════════════════
|
||||
|
||||
```
|
||||
Lis .cursorrules et AGENTS.md.
|
||||
|
||||
Crée le tableau de bord : vue d'ensemble de l'activité du marchand de biens.
|
||||
|
||||
ÉCRAN `/app/(tabs)/index.tsx` :
|
||||
|
||||
1. EN-TÊTE :
|
||||
- "Bonjour [Prénom] 👋"
|
||||
- Date du jour
|
||||
- Météo rapide (optionnel, via Open-Meteo API gratuite)
|
||||
|
||||
2. ALERTES URGENTES (si existantes) :
|
||||
Carte rouge en haut : tâches en retard + délais légaux proches (< 3 jours)
|
||||
|
||||
3. BARRE KPIs (scroll horizontal, 4 métriques) :
|
||||
- Biens actifs : nombre total
|
||||
- En compromis : nombre
|
||||
- Marge prévue : somme des marges nettes des analyses
|
||||
- Tâches du jour : nombre (avec badge rouge si en retard)
|
||||
|
||||
4. PIPELINE RÉSUMÉ :
|
||||
Barre horizontale proportionnelle avec chaque étape et son nombre de biens
|
||||
Tap sur une étape → filtre la liste en dessous
|
||||
|
||||
5. BIENS EN COURS :
|
||||
FlatList horizontale des 5 derniers biens actifs
|
||||
Chaque carte : titre, étape, date de la dernière action
|
||||
Tap → fiche bien
|
||||
|
||||
6. TÂCHES DU JOUR :
|
||||
3-5 tâches prioritaires
|
||||
Bouton "Voir tout" → onglet Agenda
|
||||
|
||||
7. DERNIÈRES VISITES :
|
||||
2-3 dernières visites avec score d'opportunité
|
||||
Tap → rapport de visite
|
||||
|
||||
8. STATISTIQUES MENSUELLES (bottom) :
|
||||
- Biens analysés ce mois
|
||||
- Offres faites
|
||||
- Taux de conversion piste → offre
|
||||
- Biens vendus (si applicable)
|
||||
|
||||
HOOK `/hooks/useDashboard.ts` :
|
||||
Utilise React Query pour fetcher en parallèle :
|
||||
- getNombreBiensParEtape()
|
||||
- getKPIsFinanciers()
|
||||
- getTachesUrgentes()
|
||||
- getDernieresVisites()
|
||||
Toutes les données mises en cache 5 minutes, refresh au focus de l'écran.
|
||||
|
||||
Mets à jour AGENTS.md : Agent 9 = ✅ Terminé.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ═══════════════════════════════════════════
|
||||
## PROMPT DE DEBUG UNIVERSEL
|
||||
## (À utiliser quand quelque chose ne marche pas)
|
||||
## ═══════════════════════════════════════════
|
||||
|
||||
```
|
||||
Lis .cursorrules et AGENTS.md pour le contexte.
|
||||
|
||||
J'ai une erreur dans le module [NOM DU MODULE] :
|
||||
|
||||
ERREUR : [coller le message d'erreur exact]
|
||||
|
||||
FICHIER CONCERNÉ : [nom du fichier]
|
||||
|
||||
CE QUI DEVRAIT SE PASSER : [description du comportement attendu]
|
||||
|
||||
CE QUI SE PASSE : [description du bug]
|
||||
|
||||
Diagnostique le problème et propose un fix. Tiens compte de la stack technique
|
||||
définie dans .cursorrules (Expo + Supabase + TypeScript strict).
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ═══════════════════════════════════════════
|
||||
## PROMPT D'AMÉLIORATION UI
|
||||
## (Quand un écran fonctionne mais est laid)
|
||||
## ═══════════════════════════════════════════
|
||||
|
||||
```
|
||||
Lis .cursorrules.
|
||||
|
||||
L'écran [NOM] fonctionne mais l'interface n'est pas assez soignée pour une utilisation
|
||||
professionnelle quotidienne sur mobile.
|
||||
|
||||
Améliore le design avec ces critères :
|
||||
- Style sobre et professionnel (pas de couleurs vives inutiles)
|
||||
- Lisible en extérieur (fort contraste)
|
||||
- Actions principales facilement accessibles en une main (zone du pouce)
|
||||
- Chargement : skeletons pendant le fetch (pas de spinner seul)
|
||||
- États vides : message explicatif + bouton d'action (ex: "Aucune visite — Planifier une visite")
|
||||
- Feedback tactile sur les boutons (Haptics.impactAsync)
|
||||
- Gestion d'erreur visible (toast ou banner rouge en haut)
|
||||
|
||||
Couleurs de l'app :
|
||||
- Primaire : #1D4ED8 (bleu professionnel)
|
||||
- Succès : #16A34A
|
||||
- Attention : #D97706
|
||||
- Danger : #DC2626
|
||||
- Fond : #F9FAFB
|
||||
- Texte : #111827
|
||||
```
|
||||
163
README.md
@ -1,163 +0,0 @@
|
||||
# mb-app — Application Marchand de Biens
|
||||
|
||||
Application mobile et web pour la gestion quotidienne d'une activité de marchand de biens immobiliers.
|
||||
|
||||
**Stack :** React Native (Expo) + PocketBase (self-hosted sur NAS Synology) + IA Claude
|
||||
|
||||
---
|
||||
|
||||
## Démarrage rapide
|
||||
|
||||
### Prérequis
|
||||
- Docker Desktop (Mac/Windows) ou Docker Engine (Linux/NAS)
|
||||
- Node.js 18+
|
||||
- Un compte DuckDNS (gratuit) pour l'accès distant
|
||||
|
||||
### 1. Cloner le projet
|
||||
|
||||
```bash
|
||||
git clone https://github.com/VOUS/mb-app.git
|
||||
cd mb-app
|
||||
```
|
||||
|
||||
### 2. Setup initial
|
||||
|
||||
```bash
|
||||
make setup
|
||||
# → Crée les dossiers nécessaires
|
||||
# → Copie .env.example en .env.local
|
||||
```
|
||||
|
||||
Éditer `.env.local` :
|
||||
```
|
||||
EXPO_PUBLIC_PB_URL=http://localhost:8090
|
||||
ANTHROPIC_API_KEY=sk-ant-VOTRE_CLE
|
||||
```
|
||||
|
||||
### 3. Lancer en développement
|
||||
|
||||
```bash
|
||||
# Terminal 1 — Backend PocketBase
|
||||
make dev
|
||||
# → PocketBase sur http://localhost:8090
|
||||
# → Admin sur http://localhost:8090/_/
|
||||
|
||||
# Terminal 2 — App Expo
|
||||
cd app
|
||||
npm install
|
||||
npx expo start
|
||||
```
|
||||
|
||||
**Première fois :** Aller sur http://localhost:8090/_/ → créer le compte admin
|
||||
→ Settings → Import collections → coller le contenu de `pocketbase/pb_collections.json`
|
||||
|
||||
---
|
||||
|
||||
## Déploiement sur le NAS Synology
|
||||
|
||||
### 1. Cloner sur le NAS
|
||||
|
||||
```bash
|
||||
# Se connecter en SSH au NAS
|
||||
ssh admin@IP_DU_NAS
|
||||
|
||||
# Cloner le projet
|
||||
git clone https://github.com/VOUS/mb-app.git /volume1/docker/mb-app
|
||||
cd /volume1/docker/mb-app
|
||||
```
|
||||
|
||||
### 2. Configurer l'environnement production
|
||||
|
||||
```bash
|
||||
make setup-nas
|
||||
# Puis éditer .env.production :
|
||||
nano .env.production
|
||||
```
|
||||
|
||||
```
|
||||
EXPO_PUBLIC_PB_URL=https://mon-sous-domaine.duckdns.org
|
||||
ANTHROPIC_API_KEY=sk-ant-VOTRE_CLE
|
||||
DUCKDNS_SUBDOMAINS=mon-sous-domaine
|
||||
DUCKDNS_TOKEN=VOTRE_TOKEN_DUCKDNS
|
||||
```
|
||||
|
||||
### 3. Ouvrir les ports sur votre box internet
|
||||
- Port 80 → IP du NAS, port 80
|
||||
- Port 443 → IP du NAS, port 443
|
||||
|
||||
### 4. Obtenir le certificat SSL
|
||||
|
||||
```bash
|
||||
make ssl
|
||||
```
|
||||
|
||||
### 5. Lancer la stack
|
||||
|
||||
```bash
|
||||
make prod
|
||||
```
|
||||
|
||||
### Mettre à jour après un git push
|
||||
|
||||
```bash
|
||||
# Sur le NAS :
|
||||
make deploy
|
||||
# → git pull + restart PocketBase (les hooks sont rechargés)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Workflow de développement
|
||||
|
||||
```
|
||||
[Local] Coder + tester
|
||||
↓
|
||||
git add . && git commit -m "feat: module visites"
|
||||
↓
|
||||
git push origin main
|
||||
↓
|
||||
[NAS] make deploy
|
||||
```
|
||||
|
||||
Les **données** (`pb_data/`) restent sur chaque machine et ne sont jamais dans Git.
|
||||
Le **code** (hooks, migrations, app) est versionné et déployé via Git.
|
||||
|
||||
---
|
||||
|
||||
## Structure du projet
|
||||
|
||||
```
|
||||
mb-app/
|
||||
├── app/ ← Code React Native (Expo Router)
|
||||
├── pocketbase/
|
||||
│ ├── pb_hooks/ ← Hooks JS côté serveur (IA, etc.)
|
||||
│ ├── pb_migrations/ ← Migrations auto PocketBase
|
||||
│ └── pb_collections.json ← Schéma des collections
|
||||
├── docker/
|
||||
│ ├── docker-compose.dev.yml
|
||||
│ ├── docker-compose.prod.yml
|
||||
│ └── nginx.prod.conf
|
||||
├── .cursorrules ← Contexte pour Cursor AI
|
||||
├── AGENTS.md ← Suivi des sessions de développement
|
||||
└── Makefile ← Raccourcis commandes
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Commandes utiles
|
||||
|
||||
| Commande | Description |
|
||||
|---|---|
|
||||
| `make dev` | Lance PocketBase en local |
|
||||
| `make dev-stop` | Arrête le dev |
|
||||
| `make logs` | Logs en temps réel |
|
||||
| `make prod` | Lance la stack NAS |
|
||||
| `make deploy` | git pull + redémarre (sur NAS) |
|
||||
| `make renew-ssl` | Renouvelle le certificat SSL |
|
||||
|
||||
---
|
||||
|
||||
## Backup des données
|
||||
|
||||
Les données PocketBase sont dans `pb_data/` (exclu du Git).
|
||||
Configurer une tâche Synology Hyper Backup sur `/volume1/docker/mb-app/pb_data/`.
|
||||
42
app.json
@ -1,42 +0,0 @@
|
||||
{
|
||||
"expo": {
|
||||
"name": "MDB-Turbo",
|
||||
"slug": "mdb-turbo",
|
||||
"scheme": "mdb-turbo",
|
||||
"version": "1.0.0",
|
||||
"orientation": "portrait",
|
||||
"icon": "./assets/icon.png",
|
||||
"userInterfaceStyle": "light",
|
||||
"newArchEnabled": true,
|
||||
"splash": {
|
||||
"image": "./assets/splash-icon.png",
|
||||
"resizeMode": "contain",
|
||||
"backgroundColor": "#ffffff"
|
||||
},
|
||||
"ios": {
|
||||
"supportsTablet": true
|
||||
},
|
||||
"android": {
|
||||
"adaptiveIcon": {
|
||||
"foregroundImage": "./assets/adaptive-icon.png",
|
||||
"backgroundColor": "#ffffff"
|
||||
},
|
||||
"edgeToEdgeEnabled": true,
|
||||
"predictiveBackGestureEnabled": false
|
||||
},
|
||||
"web": {
|
||||
"favicon": "./assets/favicon.png"
|
||||
},
|
||||
"plugins": [
|
||||
"expo-router",
|
||||
"expo-font",
|
||||
[
|
||||
"expo-notifications",
|
||||
{
|
||||
"icon": "./assets/icon.png",
|
||||
"color": "#3d8bfd"
|
||||
}
|
||||
]
|
||||
]
|
||||
}
|
||||
}
|
||||
@ -1,48 +0,0 @@
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { Tabs } from 'expo-router';
|
||||
import { colors } from '../../src/theme/colors';
|
||||
|
||||
export default function TabsLayout() {
|
||||
return (
|
||||
<Tabs
|
||||
screenOptions={{
|
||||
headerStyle: { backgroundColor: colors.bgCard },
|
||||
headerTintColor: colors.text,
|
||||
tabBarStyle: {
|
||||
backgroundColor: colors.bgCard,
|
||||
borderTopColor: colors.border,
|
||||
},
|
||||
tabBarActiveTintColor: colors.accent,
|
||||
tabBarInactiveTintColor: colors.textMuted,
|
||||
}}
|
||||
>
|
||||
<Tabs.Screen
|
||||
name="index"
|
||||
options={{
|
||||
title: 'Dossiers',
|
||||
tabBarIcon: ({ color, size }) => (
|
||||
<Ionicons name="folder-open-outline" size={size} color={color} />
|
||||
),
|
||||
}}
|
||||
/>
|
||||
<Tabs.Screen
|
||||
name="investisseurs"
|
||||
options={{
|
||||
title: 'Investisseurs',
|
||||
tabBarIcon: ({ color, size }) => (
|
||||
<Ionicons name="people-outline" size={size} color={color} />
|
||||
),
|
||||
}}
|
||||
/>
|
||||
<Tabs.Screen
|
||||
name="reglages"
|
||||
options={{
|
||||
title: 'Réglages',
|
||||
tabBarIcon: ({ color, size }) => (
|
||||
<Ionicons name="settings-outline" size={size} color={color} />
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</Tabs>
|
||||
);
|
||||
}
|
||||
@ -1,297 +0,0 @@
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { router } from 'expo-router';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import {
|
||||
Alert,
|
||||
Pressable,
|
||||
SectionList,
|
||||
StyleSheet,
|
||||
Text,
|
||||
View,
|
||||
} from 'react-native';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
import { PrimaryButton } from '../../src/components/PrimaryButton';
|
||||
import { useApp } from '../../src/context/AppContext';
|
||||
import type { DealSourceRow, DossierRow } from '../../src/data/types';
|
||||
import {
|
||||
ensureNotificationPermission,
|
||||
notifyGradeADealLocal,
|
||||
useDealsSourcesGradeAAlerts,
|
||||
} from '../../src/hooks/useDealsSourcesGradeAAlerts';
|
||||
import { colors } from '../../src/theme/colors';
|
||||
|
||||
type SectionRow =
|
||||
| { kind: 'deal'; deal: DealSourceRow }
|
||||
| { kind: 'dossier'; dossier: DossierRow };
|
||||
|
||||
export default function DossiersListScreen() {
|
||||
const insets = useSafeAreaInsets();
|
||||
const app = useApp();
|
||||
const [scoutBusy, setScoutBusy] = useState(false);
|
||||
|
||||
const cloudNeedsAuth =
|
||||
app.runtimeMode === 'cloud' && !app.user && app.supabase;
|
||||
const needsSetup = app.runtimeMode === 'none';
|
||||
|
||||
const sortedDeals = useMemo(
|
||||
() =>
|
||||
[...app.dealSources].sort(
|
||||
(a, b) => b.opportunity_score - a.opportunity_score,
|
||||
),
|
||||
[app.dealSources],
|
||||
);
|
||||
|
||||
const sections = useMemo(() => {
|
||||
const dealRows: SectionRow[] = sortedDeals.map((deal) => ({
|
||||
kind: 'deal' as const,
|
||||
deal,
|
||||
}));
|
||||
const dossierRows: SectionRow[] = app.dossiers.map((dossier) => ({
|
||||
kind: 'dossier' as const,
|
||||
dossier,
|
||||
}));
|
||||
return [
|
||||
{ title: 'Flux opportunités (Scout)', data: dealRows },
|
||||
{ title: 'Mes dossiers', data: dossierRows },
|
||||
];
|
||||
}, [sortedDeals, app.dossiers]);
|
||||
|
||||
useDealsSourcesGradeAAlerts(
|
||||
app.supabase,
|
||||
app.user?.id,
|
||||
app.runtimeMode === 'cloud' && !!app.user && !!app.supabase,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (app.runtimeMode === 'cloud' && app.user) {
|
||||
void ensureNotificationPermission();
|
||||
}
|
||||
}, [app.runtimeMode, app.user]);
|
||||
|
||||
return (
|
||||
<View style={[styles.root, { paddingTop: 8 }]}>
|
||||
{needsSetup ? (
|
||||
<View style={styles.banner}>
|
||||
<Text style={styles.bannerText}>
|
||||
Choisissez le mode hors-ligne sur l’accueil, ou configurez Supabase
|
||||
dans Réglages.
|
||||
</Text>
|
||||
<PrimaryButton
|
||||
title="Retour accueil"
|
||||
onPress={() => router.replace('/')}
|
||||
/>
|
||||
</View>
|
||||
) : null}
|
||||
{cloudNeedsAuth ? (
|
||||
<View style={styles.banner}>
|
||||
<Text style={styles.bannerText}>
|
||||
Connectez-vous pour charger vos dossiers Supabase.
|
||||
</Text>
|
||||
<PrimaryButton title="Connexion" onPress={() => router.push('/auth/login')} />
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
<SectionList
|
||||
sections={sections}
|
||||
keyExtractor={(item, index) =>
|
||||
item.kind === 'deal' ? `deal-${item.deal.id}` : `dossier-${item.dossier.id}-${index}`
|
||||
}
|
||||
stickySectionHeadersEnabled={false}
|
||||
contentContainerStyle={{
|
||||
paddingHorizontal: 16,
|
||||
paddingBottom: insets.bottom + 100,
|
||||
}}
|
||||
ListHeaderComponent={
|
||||
<View style={{ marginBottom: 12 }}>
|
||||
<PrimaryButton
|
||||
title={scoutBusy ? 'Scout…' : 'Simuler ingest Scout (JSON)'}
|
||||
loading={scoutBusy}
|
||||
onPress={async () => {
|
||||
if (!app.user) {
|
||||
router.push('/auth/login');
|
||||
return;
|
||||
}
|
||||
setScoutBusy(true);
|
||||
const r = await app.runScoutSampleBatch();
|
||||
setScoutBusy(false);
|
||||
if ('error' in r) {
|
||||
Alert.alert('Scout', r.error);
|
||||
return;
|
||||
}
|
||||
if (app.runtimeMode === 'local' && r.gradeA > 0) {
|
||||
notifyGradeADealLocal(
|
||||
`${r.gradeA} opportunité(s) Grade A (Scout simulé)`,
|
||||
);
|
||||
}
|
||||
Alert.alert(
|
||||
'Scout',
|
||||
`Insérés : ${r.inserted} — Grade A : ${r.gradeA}.`,
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<Text style={styles.hint}>
|
||||
Filtre : mots-clés succession / urgent / travaux important + prix/m²
|
||||
sous moyenne simulée (3500 €/m²). Cloud : RPC `scout_process_batch`
|
||||
après migration SQL.
|
||||
</Text>
|
||||
</View>
|
||||
}
|
||||
renderSectionHeader={({ section: { title, data } }) => (
|
||||
<Text style={styles.sectionTitle}>
|
||||
{title}
|
||||
{title.startsWith('Flux') ? ` (${data.length})` : ` (${data.length})`}
|
||||
</Text>
|
||||
)}
|
||||
renderItem={({ item }) =>
|
||||
item.kind === 'deal' ? (
|
||||
<DealSourceCard row={item.deal} />
|
||||
) : (
|
||||
<DossierRowCard row={item.dossier} />
|
||||
)
|
||||
}
|
||||
ListEmptyComponent={null}
|
||||
/>
|
||||
|
||||
<View style={[styles.fabWrap, { bottom: insets.bottom + 20 }]}>
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
style={styles.fab}
|
||||
onPress={async () => {
|
||||
if (app.runtimeMode === 'none') {
|
||||
router.replace('/');
|
||||
return;
|
||||
}
|
||||
if (!app.user && app.runtimeMode === 'cloud') {
|
||||
router.push('/auth/login');
|
||||
return;
|
||||
}
|
||||
const id = await app.createDossier();
|
||||
if (id) router.push(`/dossier/${id}`);
|
||||
}}
|
||||
>
|
||||
<Ionicons name="add" size={32} color="#fff" />
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function DealSourceCard({ row }: { row: DealSourceRow }) {
|
||||
const pm = Math.round(row.price_per_m2_eur);
|
||||
const dotStyle =
|
||||
row.grade === 'A' ? styles.badgeA : row.grade === 'B' ? styles.badgeB : styles.badgeC;
|
||||
return (
|
||||
<View style={styles.dealCard}>
|
||||
<View style={styles.dealHead}>
|
||||
<Text style={styles.badgeText}>Grade {row.grade}</Text>
|
||||
<View style={[styles.badgeDot, dotStyle]} />
|
||||
<Text style={styles.score}>{row.opportunity_score.toFixed(0)} pts</Text>
|
||||
</View>
|
||||
<Text style={styles.cardTitle}>{row.title}</Text>
|
||||
<Text style={styles.cardSub}>
|
||||
{row.price_eur != null
|
||||
? `${row.price_eur.toLocaleString('fr-FR')} € · ${row.surface_m2} m² · ${pm} €/m²`
|
||||
: `${row.surface_m2} m²`}
|
||||
</Text>
|
||||
{row.distress_keywords?.length ? (
|
||||
<Text style={[styles.kw, { color: colors.flash }]}>
|
||||
Mots-clés : {row.distress_keywords.join(', ')}
|
||||
</Text>
|
||||
) : null}
|
||||
{row.source_name ? (
|
||||
<Text style={styles.cardMeta}>Source : {row.source_name}</Text>
|
||||
) : null}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function DossierRowCard({ row }: { row: DossierRow }) {
|
||||
const city = [row.postal_code, row.city].filter(Boolean).join(' ');
|
||||
return (
|
||||
<Pressable
|
||||
style={styles.card}
|
||||
onPress={() => router.push(`/dossier/${row.id}`)}
|
||||
>
|
||||
<Text style={styles.cardTitle}>{row.title}</Text>
|
||||
{city ? <Text style={styles.cardSub}>{city}</Text> : null}
|
||||
<Text style={styles.cardMeta}>Statut : {row.status}</Text>
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
root: { flex: 1, backgroundColor: colors.bg },
|
||||
banner: {
|
||||
marginHorizontal: 16,
|
||||
marginBottom: 12,
|
||||
padding: 14,
|
||||
borderRadius: 12,
|
||||
backgroundColor: colors.bgCard,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.border,
|
||||
gap: 10,
|
||||
},
|
||||
bannerText: { color: colors.text, lineHeight: 20 },
|
||||
hint: {
|
||||
color: colors.textMuted,
|
||||
fontSize: 12,
|
||||
lineHeight: 17,
|
||||
marginTop: 10,
|
||||
},
|
||||
sectionTitle: {
|
||||
color: colors.text,
|
||||
fontSize: 15,
|
||||
fontWeight: '800',
|
||||
marginTop: 8,
|
||||
marginBottom: 8,
|
||||
},
|
||||
dealCard: {
|
||||
backgroundColor: '#121a24',
|
||||
borderRadius: 14,
|
||||
padding: 16,
|
||||
marginBottom: 12,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.border,
|
||||
},
|
||||
dealHead: { flexDirection: 'row', alignItems: 'center', marginBottom: 8, gap: 8 },
|
||||
badgeDot: { width: 8, height: 8, borderRadius: 4 },
|
||||
badgeA: { backgroundColor: '#3fb950' },
|
||||
badgeB: { backgroundColor: '#d29922' },
|
||||
badgeC: { backgroundColor: colors.textMuted },
|
||||
badgeText: {
|
||||
color: colors.text,
|
||||
fontWeight: '900',
|
||||
fontSize: 13,
|
||||
marginRight: 4,
|
||||
},
|
||||
score: { color: colors.textMuted, fontSize: 12, marginLeft: 'auto' },
|
||||
kw: { color: colors.flash ?? '#7ee787', marginTop: 6, fontSize: 12 },
|
||||
card: {
|
||||
backgroundColor: colors.bgCard,
|
||||
borderRadius: 14,
|
||||
padding: 16,
|
||||
marginBottom: 12,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.border,
|
||||
},
|
||||
cardTitle: { color: colors.text, fontSize: 17, fontWeight: '700' },
|
||||
cardSub: { color: colors.textMuted, marginTop: 4 },
|
||||
cardMeta: { color: colors.textMuted, marginTop: 8, fontSize: 12 },
|
||||
fabWrap: {
|
||||
position: 'absolute',
|
||||
right: 20,
|
||||
},
|
||||
fab: {
|
||||
width: 58,
|
||||
height: 58,
|
||||
borderRadius: 29,
|
||||
backgroundColor: colors.accent,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
elevation: 4,
|
||||
shadowColor: '#000',
|
||||
shadowOpacity: 0.25,
|
||||
shadowRadius: 6,
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
},
|
||||
});
|
||||
@ -1,234 +0,0 @@
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
Alert,
|
||||
FlatList,
|
||||
Modal,
|
||||
Pressable,
|
||||
StyleSheet,
|
||||
Text,
|
||||
View,
|
||||
} from 'react-native';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
import { LabeledField } from '../../src/components/LabeledField';
|
||||
import { PrimaryButton } from '../../src/components/PrimaryButton';
|
||||
import { useApp } from '../../src/context/AppContext';
|
||||
import { colors } from '../../src/theme/colors';
|
||||
import type { InvestisseurRow } from '../../src/data/types';
|
||||
import { router } from 'expo-router';
|
||||
|
||||
function parseNum(s: string): number | null {
|
||||
const v = Number(s.replace(',', '.').replace(/\s/g, ''));
|
||||
return Number.isFinite(v) ? v : null;
|
||||
}
|
||||
|
||||
export default function InvestisseursScreen() {
|
||||
const insets = useSafeAreaInsets();
|
||||
const app = useApp();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [editing, setEditing] = useState<InvestisseurRow | null>(null);
|
||||
const [name, setName] = useState('');
|
||||
const [email, setEmail] = useState('');
|
||||
const [phone, setPhone] = useState('');
|
||||
const [minMargin, setMinMargin] = useState('12');
|
||||
const [maxTicket, setMaxTicket] = useState('');
|
||||
const [zones, setZones] = useState('');
|
||||
|
||||
const cloudNeedsAuth = app.runtimeMode === 'cloud' && !app.user;
|
||||
|
||||
const openNew = () => {
|
||||
setEditing(null);
|
||||
setName('');
|
||||
setEmail('');
|
||||
setPhone('');
|
||||
setMinMargin('12');
|
||||
setMaxTicket('');
|
||||
setZones('');
|
||||
setOpen(true);
|
||||
};
|
||||
|
||||
const openEdit = (row: InvestisseurRow) => {
|
||||
setEditing(row);
|
||||
setName(row.display_name);
|
||||
setEmail(row.email ?? '');
|
||||
setPhone(row.phone ?? '');
|
||||
setMinMargin(String(row.min_margin_pct));
|
||||
setMaxTicket(row.max_ticket_eur != null ? String(row.max_ticket_eur) : '');
|
||||
setZones((row.zones ?? []).join(', '));
|
||||
setOpen(true);
|
||||
};
|
||||
|
||||
const save = async () => {
|
||||
if (!app.user) {
|
||||
router.push('/auth/login');
|
||||
return;
|
||||
}
|
||||
const uid = app.user.id;
|
||||
const mm = parseNum(minMargin) ?? 12;
|
||||
const mt = maxTicket.trim() ? parseNum(maxTicket) : null;
|
||||
const z = zones
|
||||
.split(',')
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
await app.upsertInvestisseur({
|
||||
id: editing?.id,
|
||||
user_id: uid,
|
||||
display_name: name.trim() || 'Investisseur',
|
||||
email: email.trim() || null,
|
||||
phone: phone.trim() || null,
|
||||
min_margin_pct: mm,
|
||||
max_ticket_eur: mt,
|
||||
zones: z.length ? z : null,
|
||||
strategies: null,
|
||||
notes: null,
|
||||
});
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
if (cloudNeedsAuth) {
|
||||
return (
|
||||
<View style={[styles.center, { paddingTop: insets.top }]}>
|
||||
<Text style={styles.muted}>Connectez-vous pour gérer vos investisseurs.</Text>
|
||||
<PrimaryButton title="Connexion" onPress={() => router.push('/auth/login')} />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={styles.root}>
|
||||
<FlatList
|
||||
data={app.investisseurs}
|
||||
keyExtractor={(i) => i.id}
|
||||
contentContainerStyle={{
|
||||
padding: 16,
|
||||
paddingBottom: insets.bottom + 80,
|
||||
}}
|
||||
ListEmptyComponent={
|
||||
<Text style={styles.muted}>
|
||||
Ajoutez des profils pour le module « Investisseur flash » (matching
|
||||
marge / ticket / zones).
|
||||
</Text>
|
||||
}
|
||||
renderItem={({ item }) => (
|
||||
<Pressable style={styles.card} onPress={() => openEdit(item)}>
|
||||
<Text style={styles.name}>{item.display_name}</Text>
|
||||
<Text style={styles.meta}>
|
||||
Marge mini {item.min_margin_pct}% — ticket max{' '}
|
||||
{item.max_ticket_eur != null
|
||||
? `${item.max_ticket_eur.toLocaleString('fr-FR')} €`
|
||||
: '—'}
|
||||
</Text>
|
||||
{item.zones?.length ? (
|
||||
<Text style={styles.meta}>Zones : {item.zones.join(', ')}</Text>
|
||||
) : null}
|
||||
</Pressable>
|
||||
)}
|
||||
/>
|
||||
<View style={[styles.fabRow, { bottom: insets.bottom + 16 }]}>
|
||||
<PrimaryButton title="Nouvel investisseur" onPress={openNew} />
|
||||
</View>
|
||||
|
||||
<Modal visible={open} animationType="slide" transparent>
|
||||
<View style={styles.modalBackdrop}>
|
||||
<View style={[styles.modalCard, { paddingBottom: insets.bottom + 16 }]}>
|
||||
<Text style={styles.modalTitle}>
|
||||
{editing ? 'Modifier investisseur' : 'Nouvel investisseur'}
|
||||
</Text>
|
||||
<LabeledField label="Nom" value={name} onChangeText={setName} />
|
||||
<LabeledField
|
||||
label="E-mail"
|
||||
autoCapitalize="none"
|
||||
value={email}
|
||||
onChangeText={setEmail}
|
||||
/>
|
||||
<LabeledField label="Téléphone" value={phone} onChangeText={setPhone} />
|
||||
<LabeledField
|
||||
label="Marge nette minimum (%)"
|
||||
keyboardType="decimal-pad"
|
||||
value={minMargin}
|
||||
onChangeText={setMinMargin}
|
||||
/>
|
||||
<LabeledField
|
||||
label="Ticket max (€) — optionnel"
|
||||
keyboardType="number-pad"
|
||||
value={maxTicket}
|
||||
onChangeText={setMaxTicket}
|
||||
/>
|
||||
<LabeledField
|
||||
label="Zones (ville ou CP, séparés par des virgules)"
|
||||
value={zones}
|
||||
onChangeText={setZones}
|
||||
/>
|
||||
<PrimaryButton title="Enregistrer" onPress={() => void save()} />
|
||||
{editing ? (
|
||||
<PrimaryButton
|
||||
title="Supprimer"
|
||||
variant="danger"
|
||||
containerStyle={{ marginTop: 10 }}
|
||||
onPress={() => {
|
||||
Alert.alert(
|
||||
'Supprimer',
|
||||
'Confirmer la suppression ?',
|
||||
[
|
||||
{ text: 'Annuler', style: 'cancel' },
|
||||
{
|
||||
text: 'Supprimer',
|
||||
style: 'destructive',
|
||||
onPress: () => {
|
||||
void app.deleteInvestisseur(editing.id).then(() =>
|
||||
setOpen(false),
|
||||
);
|
||||
},
|
||||
},
|
||||
],
|
||||
);
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
<PrimaryButton
|
||||
title="Fermer"
|
||||
variant="ghost"
|
||||
containerStyle={{ marginTop: 12 }}
|
||||
onPress={() => setOpen(false)}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
</Modal>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
root: { flex: 1, backgroundColor: colors.bg },
|
||||
center: { flex: 1, padding: 20, backgroundColor: colors.bg, gap: 12 },
|
||||
muted: { color: colors.textMuted, textAlign: 'center', lineHeight: 20 },
|
||||
card: {
|
||||
backgroundColor: colors.bgCard,
|
||||
borderRadius: 14,
|
||||
padding: 16,
|
||||
marginBottom: 12,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.border,
|
||||
},
|
||||
name: { color: colors.text, fontSize: 17, fontWeight: '700' },
|
||||
meta: { color: colors.textMuted, marginTop: 6, fontSize: 13 },
|
||||
fabRow: { position: 'absolute', left: 16, right: 16 },
|
||||
modalBackdrop: {
|
||||
flex: 1,
|
||||
backgroundColor: 'rgba(0,0,0,0.55)',
|
||||
justifyContent: 'flex-end',
|
||||
},
|
||||
modalCard: {
|
||||
backgroundColor: colors.bgCard,
|
||||
borderTopLeftRadius: 18,
|
||||
borderTopRightRadius: 18,
|
||||
padding: 20,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.border,
|
||||
},
|
||||
modalTitle: {
|
||||
color: colors.text,
|
||||
fontSize: 20,
|
||||
fontWeight: '700',
|
||||
marginBottom: 16,
|
||||
},
|
||||
});
|
||||
@ -1,112 +0,0 @@
|
||||
import { router } from 'expo-router';
|
||||
import { useState } from 'react';
|
||||
import { ScrollView, StyleSheet, Text, View } from 'react-native';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
import { LabeledField } from '../../src/components/LabeledField';
|
||||
import { PrimaryButton } from '../../src/components/PrimaryButton';
|
||||
import { useApp } from '../../src/context/AppContext';
|
||||
import { colors } from '../../src/theme/colors';
|
||||
|
||||
export default function ReglagesScreen() {
|
||||
const insets = useSafeAreaInsets();
|
||||
const app = useApp();
|
||||
const [url, setUrl] = useState('');
|
||||
const [key, setKey] = useState('');
|
||||
const [msg, setMsg] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
return (
|
||||
<ScrollView
|
||||
contentContainerStyle={{
|
||||
padding: 20,
|
||||
paddingTop: 12,
|
||||
paddingBottom: insets.bottom + 32,
|
||||
backgroundColor: colors.bg,
|
||||
}}
|
||||
>
|
||||
<Text style={styles.h2}>Mode actuel</Text>
|
||||
<Text style={styles.p}>
|
||||
{app.runtimeMode === 'local' && 'Hors-ligne — données stockées sur l’appareil.'}
|
||||
{app.runtimeMode === 'cloud' && 'Supabase — synchronisation cloud.'}
|
||||
{app.runtimeMode === 'none' && 'Non initialisé.'}
|
||||
</Text>
|
||||
{app.user ? (
|
||||
<Text style={styles.p}>
|
||||
Compte : {app.user.email ?? app.user.id}
|
||||
</Text>
|
||||
) : null}
|
||||
|
||||
<Text style={[styles.h2, { marginTop: 24 }]}>Projet Supabase</Text>
|
||||
<Text style={styles.p}>
|
||||
URL et clé « anon » (Settings → API). Exécutez aussi la migration SQL du
|
||||
dépôt sur votre projet.
|
||||
</Text>
|
||||
<LabeledField
|
||||
label="URL du projet"
|
||||
autoCapitalize="none"
|
||||
value={url}
|
||||
onChangeText={setUrl}
|
||||
placeholder="https://xxxx.supabase.co"
|
||||
/>
|
||||
<LabeledField
|
||||
label="Clé anon (public)"
|
||||
autoCapitalize="none"
|
||||
value={key}
|
||||
onChangeText={setKey}
|
||||
placeholder="eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
|
||||
/>
|
||||
{msg ? <Text style={styles.msg}>{msg}</Text> : null}
|
||||
<PrimaryButton
|
||||
title="Enregistrer et passer en mode cloud"
|
||||
loading={loading}
|
||||
onPress={async () => {
|
||||
setMsg(null);
|
||||
if (!url.trim() || !key.trim()) {
|
||||
setMsg('Renseignez URL et clé.');
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
try {
|
||||
await app.saveCloudConfig({
|
||||
supabaseUrl: url.trim(),
|
||||
supabaseAnonKey: key.trim(),
|
||||
});
|
||||
setMsg('Configuration enregistrée. Connectez-vous ou créez un compte.');
|
||||
router.push('/auth/login');
|
||||
} catch {
|
||||
setMsg('Erreur lors de l’enregistrement.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
<PrimaryButton
|
||||
title="Activer le mode hors-ligne"
|
||||
variant="ghost"
|
||||
containerStyle={{ marginTop: 12 }}
|
||||
onPress={async () => {
|
||||
await app.enterLocalMode();
|
||||
setMsg('Mode hors-ligne activé.');
|
||||
router.replace('/(tabs)');
|
||||
}}
|
||||
/>
|
||||
|
||||
<PrimaryButton
|
||||
title="Déconnexion / quitter la session"
|
||||
variant="ghost"
|
||||
containerStyle={{ marginTop: 24 }}
|
||||
onPress={async () => {
|
||||
await app.signOut();
|
||||
router.replace('/');
|
||||
}}
|
||||
/>
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
h2: { color: colors.text, fontSize: 18, fontWeight: '700', marginBottom: 8 },
|
||||
p: { color: colors.textMuted, lineHeight: 20, marginBottom: 8 },
|
||||
msg: { color: colors.flash, marginBottom: 12, lineHeight: 20 },
|
||||
});
|
||||
6
app/.gitignore
vendored
Normal 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
|
||||
@ -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
@ -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');
|
||||
@ -1,45 +1,31 @@
|
||||
{
|
||||
"expo": {
|
||||
"name": "mb-app",
|
||||
"slug": "mb-app",
|
||||
"name": "mdb",
|
||||
"slug": "mdb",
|
||||
"version": "1.0.0",
|
||||
"orientation": "portrait",
|
||||
"icon": "./assets/images/icon.png",
|
||||
"scheme": "mbapp",
|
||||
"scheme": "mdb",
|
||||
"userInterfaceStyle": "automatic",
|
||||
"newArchEnabled": true,
|
||||
"splash": {
|
||||
"image": "./assets/images/splash-icon.png",
|
||||
"resizeMode": "contain",
|
||||
"backgroundColor": "#ffffff"
|
||||
},
|
||||
"ios": {
|
||||
"supportsTablet": true
|
||||
"backgroundColor": "#f8fafc"
|
||||
},
|
||||
"ios": { "supportsTablet": true },
|
||||
"android": {
|
||||
"adaptiveIcon": {
|
||||
"foregroundImage": "./assets/images/adaptive-icon.png",
|
||||
"backgroundColor": "#ffffff"
|
||||
},
|
||||
"edgeToEdgeEnabled": true,
|
||||
"predictiveBackGestureEnabled": false
|
||||
"backgroundColor": "#f8fafc"
|
||||
}
|
||||
},
|
||||
"web": {
|
||||
"bundler": "metro",
|
||||
"output": "static",
|
||||
"favicon": "./assets/images/favicon.png"
|
||||
},
|
||||
"plugins": [
|
||||
"expo-router",
|
||||
[
|
||||
"expo-notifications",
|
||||
{
|
||||
"color": "#1D4ED8"
|
||||
}
|
||||
]
|
||||
],
|
||||
"experiments": {
|
||||
"typedRoutes": true
|
||||
}
|
||||
"plugins": ["expo-router"],
|
||||
"experiments": { "typedRoutes": true }
|
||||
}
|
||||
}
|
||||
50
app/app/(tabs)/_layout.tsx
Normal 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
@ -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
@ -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
@ -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
@ -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 aujourd’hui.</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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
45
app/app/(tabs)/visites.tsx
Normal 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
@ -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
@ -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
@ -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
@ -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'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
@ -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} m²` : '—'}
|
||||
/>
|
||||
<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
@ -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 d’achat 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 l’assigner 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é (1–5)" 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} m²` : '—'} />
|
||||
<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>
|
||||
);
|
||||
}
|
||||
164
app/app/calculateur/[bienId].tsx
Normal 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'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
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
87
app/app/contact/nouveau.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@ -1,11 +1,12 @@
|
||||
import { Redirect } from 'expo-router';
|
||||
import { ActivityIndicator, View } from 'react-native';
|
||||
|
||||
import { useAuth } from '@/context/AuthContext';
|
||||
|
||||
export default function Index() {
|
||||
const { initialized, session } = useAuth();
|
||||
const { loading, user } = useAuth();
|
||||
|
||||
if (!initialized) {
|
||||
if (loading) {
|
||||
return (
|
||||
<View className="flex-1 items-center justify-center bg-slate-50">
|
||||
<ActivityIndicator size="large" color="#1D4ED8" />
|
||||
@ -13,9 +14,5 @@ export default function Index() {
|
||||
);
|
||||
}
|
||||
|
||||
if (session?.user) {
|
||||
return <Redirect href="/(tabs)" />;
|
||||
}
|
||||
|
||||
return <Redirect href="/auth/login" />;
|
||||
return user ? <Redirect href="/(tabs)" /> : <Redirect href="/auth/login" />;
|
||||
}
|
||||
387
app/app/visite/[id].tsx
Normal 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é (1–10)</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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
BIN
app/assets/images/adaptive-icon.png
Normal file
|
After Width: | Height: | Size: 70 B |
BIN
app/assets/images/favicon.png
Normal file
|
After Width: | Height: | Size: 70 B |
BIN
app/assets/images/icon.png
Normal file
|
After Width: | Height: | Size: 70 B |
BIN
app/assets/images/splash-icon.png
Normal file
|
After Width: | Height: | Size: 70 B |
@ -1,95 +0,0 @@
|
||||
import { router } from 'expo-router';
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
KeyboardAvoidingView,
|
||||
Platform,
|
||||
ScrollView,
|
||||
StyleSheet,
|
||||
Text,
|
||||
View,
|
||||
} from 'react-native';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
import { LabeledField } from '../../src/components/LabeledField';
|
||||
import { PrimaryButton } from '../../src/components/PrimaryButton';
|
||||
import { useApp } from '../../src/context/AppContext';
|
||||
import { colors } from '../../src/theme/colors';
|
||||
|
||||
export default function LoginScreen() {
|
||||
const insets = useSafeAreaInsets();
|
||||
const app = useApp();
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [err, setErr] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
if (app.runtimeMode !== 'cloud' || !app.supabase) {
|
||||
return (
|
||||
<View style={[styles.box, { paddingTop: insets.top }]}>
|
||||
<Text style={styles.err}>
|
||||
Configurez d’abord Supabase dans Réglages, puis revenez ici.
|
||||
</Text>
|
||||
<PrimaryButton
|
||||
title="Ouvrir Réglages"
|
||||
onPress={() => router.replace('/(tabs)/reglages')}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<KeyboardAvoidingView
|
||||
style={{ flex: 1, backgroundColor: colors.bg }}
|
||||
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
|
||||
>
|
||||
<ScrollView
|
||||
keyboardShouldPersistTaps="handled"
|
||||
contentContainerStyle={{
|
||||
padding: 20,
|
||||
paddingTop: insets.top + 12,
|
||||
paddingBottom: insets.bottom + 24,
|
||||
}}
|
||||
>
|
||||
<LabeledField
|
||||
label="E-mail"
|
||||
autoCapitalize="none"
|
||||
keyboardType="email-address"
|
||||
value={email}
|
||||
onChangeText={setEmail}
|
||||
/>
|
||||
<LabeledField
|
||||
label="Mot de passe"
|
||||
secureTextEntry
|
||||
value={password}
|
||||
onChangeText={setPassword}
|
||||
/>
|
||||
{err ? <Text style={styles.err}>{err}</Text> : null}
|
||||
<PrimaryButton
|
||||
title="Connexion"
|
||||
loading={loading}
|
||||
onPress={async () => {
|
||||
setErr(null);
|
||||
setLoading(true);
|
||||
const r = await app.signIn(email.trim(), password);
|
||||
setLoading(false);
|
||||
if (r.error) {
|
||||
setErr(r.error);
|
||||
return;
|
||||
}
|
||||
router.replace('/(tabs)');
|
||||
}}
|
||||
/>
|
||||
<PrimaryButton
|
||||
title="Créer un compte"
|
||||
variant="ghost"
|
||||
onPress={() => router.push('/auth/register')}
|
||||
containerStyle={{ marginTop: 12 }}
|
||||
/>
|
||||
</ScrollView>
|
||||
</KeyboardAvoidingView>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
box: { flex: 1, padding: 20, backgroundColor: colors.bg, gap: 12 },
|
||||
err: { color: colors.danger, marginBottom: 12 },
|
||||
});
|
||||
@ -1,103 +0,0 @@
|
||||
import { router } from 'expo-router';
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
KeyboardAvoidingView,
|
||||
Platform,
|
||||
ScrollView,
|
||||
StyleSheet,
|
||||
Text,
|
||||
View,
|
||||
} from 'react-native';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
import { LabeledField } from '../../src/components/LabeledField';
|
||||
import { PrimaryButton } from '../../src/components/PrimaryButton';
|
||||
import { useApp } from '../../src/context/AppContext';
|
||||
import { colors } from '../../src/theme/colors';
|
||||
|
||||
export default function RegisterScreen() {
|
||||
const insets = useSafeAreaInsets();
|
||||
const app = useApp();
|
||||
const [name, setName] = useState('');
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [err, setErr] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [info, setInfo] = useState<string | null>(null);
|
||||
|
||||
if (app.runtimeMode !== 'cloud' || !app.supabase) {
|
||||
return (
|
||||
<View style={[styles.box, { paddingTop: insets.top }]}>
|
||||
<Text style={styles.err}>
|
||||
Configurez d’abord Supabase dans Réglages.
|
||||
</Text>
|
||||
<PrimaryButton
|
||||
title="Ouvrir Réglages"
|
||||
onPress={() => router.replace('/(tabs)/reglages')}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<KeyboardAvoidingView
|
||||
style={{ flex: 1, backgroundColor: colors.bg }}
|
||||
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
|
||||
>
|
||||
<ScrollView
|
||||
keyboardShouldPersistTaps="handled"
|
||||
contentContainerStyle={{
|
||||
padding: 20,
|
||||
paddingTop: insets.top + 12,
|
||||
paddingBottom: insets.bottom + 24,
|
||||
}}
|
||||
>
|
||||
<LabeledField label="Nom affiché" value={name} onChangeText={setName} />
|
||||
<LabeledField
|
||||
label="E-mail"
|
||||
autoCapitalize="none"
|
||||
keyboardType="email-address"
|
||||
value={email}
|
||||
onChangeText={setEmail}
|
||||
/>
|
||||
<LabeledField
|
||||
label="Mot de passe"
|
||||
secureTextEntry
|
||||
value={password}
|
||||
onChangeText={setPassword}
|
||||
/>
|
||||
{err ? <Text style={styles.err}>{err}</Text> : null}
|
||||
{info ? <Text style={styles.info}>{info}</Text> : null}
|
||||
<PrimaryButton
|
||||
title="S’inscrire"
|
||||
loading={loading}
|
||||
onPress={async () => {
|
||||
setErr(null);
|
||||
setInfo(null);
|
||||
setLoading(true);
|
||||
const r = await app.signUp(email.trim(), password, name.trim());
|
||||
setLoading(false);
|
||||
if (r.error) {
|
||||
setErr(r.error);
|
||||
return;
|
||||
}
|
||||
setInfo(
|
||||
'Si la confirmation e-mail est activée sur votre projet, vérifiez votre boîte avant de vous connecter.',
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<PrimaryButton
|
||||
title="J’ai déjà un compte"
|
||||
variant="ghost"
|
||||
onPress={() => router.back()}
|
||||
containerStyle={{ marginTop: 12 }}
|
||||
/>
|
||||
</ScrollView>
|
||||
</KeyboardAvoidingView>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
box: { flex: 1, padding: 20, backgroundColor: colors.bg, gap: 12 },
|
||||
err: { color: colors.danger, marginBottom: 12 },
|
||||
info: { color: colors.flash, marginBottom: 12, lineHeight: 20 },
|
||||
});
|
||||
20
app/constants/contactCategories.ts
Normal 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
@ -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' },
|
||||
};
|
||||
23
app/constants/visiteChecklist.ts
Normal 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';
|
||||
82
app/context/AuthContext.tsx
Normal 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 };
|
||||
@ -1,567 +0,0 @@
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { router, useLocalSearchParams } from 'expo-router';
|
||||
import { useLayoutEffect, useMemo, useState, useEffect } from 'react';
|
||||
import {
|
||||
Alert,
|
||||
Pressable,
|
||||
ScrollView,
|
||||
StyleSheet,
|
||||
Switch,
|
||||
Text,
|
||||
View,
|
||||
} from 'react-native';
|
||||
import { useNavigation } from '@react-navigation/native';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
import { LabeledField } from '../../src/components/LabeledField';
|
||||
import { PrimaryButton } from '../../src/components/PrimaryButton';
|
||||
import { useApp, useVisitFindings } from '../../src/context/AppContext';
|
||||
import { colors } from '../../src/theme/colors';
|
||||
import type {
|
||||
DossierRow,
|
||||
DossierVisitFindingRow,
|
||||
VisitFindingDefinitionRow,
|
||||
} from '../../src/data/types';
|
||||
import { useDossierJuge } from '../../src/hooks/useDossierJuge';
|
||||
import { matchInvestisseurs } from '../../src/services/matchInvestors';
|
||||
import { shareTeaserPdf } from '../../src/services/teaserPdf';
|
||||
import { MIN_NET_MARGIN_PCT } from '../../src/core/juge';
|
||||
|
||||
type TabKey = 'dash' | 'money' | 'visit' | 'flash';
|
||||
|
||||
const TABS: { key: TabKey; label: string }[] = [
|
||||
{ key: 'dash', label: 'Feu' },
|
||||
{ key: 'money', label: 'Finances' },
|
||||
{ key: 'visit', label: 'Visite' },
|
||||
{ key: 'flash', label: 'Flash' },
|
||||
];
|
||||
|
||||
export default function DossierDetailScreen() {
|
||||
const { id } = useLocalSearchParams<{ id: string }>();
|
||||
const dossierId = typeof id === 'string' ? id : id?.[0];
|
||||
const navigation = useNavigation();
|
||||
const insets = useSafeAreaInsets();
|
||||
const app = useApp();
|
||||
const [tab, setTab] = useState<TabKey>('dash');
|
||||
|
||||
const dossier = useMemo(
|
||||
() => app.dossiers.find((d) => d.id === dossierId),
|
||||
[app.dossiers, dossierId],
|
||||
);
|
||||
|
||||
const findings = useVisitFindings(dossierId);
|
||||
const juge = useDossierJuge(dossier, findings, app.definitions);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
navigation.setOptions({
|
||||
title: dossier?.title ?? 'Dossier',
|
||||
headerRight: () => (
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
hitSlop={12}
|
||||
onPress={() => {
|
||||
if (!dossierId) return;
|
||||
Alert.alert(
|
||||
'Supprimer le dossier',
|
||||
'Cette action est irréversible.',
|
||||
[
|
||||
{ text: 'Annuler', style: 'cancel' },
|
||||
{
|
||||
text: 'Supprimer',
|
||||
style: 'destructive',
|
||||
onPress: () => {
|
||||
void app.deleteDossier(dossierId).then(() => router.back());
|
||||
},
|
||||
},
|
||||
],
|
||||
);
|
||||
}}
|
||||
>
|
||||
<Ionicons name="trash-outline" size={22} color={colors.danger} />
|
||||
</Pressable>
|
||||
),
|
||||
});
|
||||
}, [navigation, dossier?.title, dossierId, app.deleteDossier]);
|
||||
|
||||
if (!dossierId || !dossier || !juge) {
|
||||
return (
|
||||
<View style={[styles.center, { paddingTop: insets.top }]}>
|
||||
<Text style={styles.muted}>Dossier introuvable.</Text>
|
||||
<PrimaryButton title="Retour" onPress={() => router.back()} />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const matches = matchInvestisseurs(dossier, juge.result, app.investisseurs);
|
||||
|
||||
const dashBg =
|
||||
juge.result.trafficLight === 'red'
|
||||
? '#2d1418'
|
||||
: juge.result.trafficLight === 'orange'
|
||||
? '#2a2310'
|
||||
: juge.result.trafficLight === 'green_flash_dvf'
|
||||
? '#102a18'
|
||||
: '#10221c';
|
||||
|
||||
return (
|
||||
<View style={styles.root}>
|
||||
<ScrollView
|
||||
horizontal
|
||||
showsHorizontalScrollIndicator={false}
|
||||
style={styles.tabBar}
|
||||
contentContainerStyle={styles.tabBarInner}
|
||||
>
|
||||
{TABS.map((t) => (
|
||||
<Pressable
|
||||
key={t.key}
|
||||
onPress={() => setTab(t.key)}
|
||||
style={[styles.tabChip, tab === t.key && styles.tabChipOn]}
|
||||
>
|
||||
<Text style={[styles.tabText, tab === t.key && styles.tabTextOn]}>
|
||||
{t.label}
|
||||
</Text>
|
||||
</Pressable>
|
||||
))}
|
||||
</ScrollView>
|
||||
|
||||
{tab === 'dash' ? (
|
||||
<ScrollView
|
||||
contentContainerStyle={{
|
||||
padding: 16,
|
||||
paddingBottom: insets.bottom + 24,
|
||||
}}
|
||||
>
|
||||
<View style={[styles.hero, { backgroundColor: dashBg }]}>
|
||||
<Text style={styles.heroLabel}>Score deal</Text>
|
||||
<Text style={styles.heroScore}>{juge.result.scoreDeal}</Text>
|
||||
<Text style={styles.heroSub}>
|
||||
Marge nette : {(juge.result.netMarginPct * 100).toFixed(1)} % (seuil
|
||||
achat : {(MIN_NET_MARGIN_PCT * 100).toFixed(0)} %)
|
||||
</Text>
|
||||
<Text style={styles.heroSub}>
|
||||
Feu : {juge.result.trafficLight} — DVF flash :{' '}
|
||||
{juge.result.dvfUnderMarketFlash ? 'oui' : 'non'}
|
||||
</Text>
|
||||
</View>
|
||||
<View style={styles.card}>
|
||||
<Text style={styles.cardTitle}>Synthèse</Text>
|
||||
<Row label="Investi (est.)" value={`${Math.round(juge.result.totalInvested).toLocaleString('fr-FR')} €`} />
|
||||
<Row label="Produit net revente" value={`${Math.round(juge.result.netResaleProceeds).toLocaleString('fr-FR')} €`} />
|
||||
<Row label="TVA sur marge (est.)" value={`${Math.round(juge.result.vatOnMargin).toLocaleString('fr-FR')} €`} />
|
||||
<Row label="Marge nette" value={`${Math.round(juge.result.netMarginAfterVat).toLocaleString('fr-FR')} €`} />
|
||||
<Row
|
||||
label="Break-even revente"
|
||||
value={
|
||||
Number.isFinite(juge.result.breakEvenResalePrice)
|
||||
? `${Math.round(juge.result.breakEvenResalePrice).toLocaleString('fr-FR')} €`
|
||||
: '—'
|
||||
}
|
||||
/>
|
||||
</View>
|
||||
<PrimaryButton
|
||||
title="Marquer « sous promesse »"
|
||||
variant="ghost"
|
||||
onPress={() => void app.setDossierStatus(dossier.id, 'under_promise')}
|
||||
containerStyle={{ marginTop: 8 }}
|
||||
/>
|
||||
</ScrollView>
|
||||
) : null}
|
||||
|
||||
{tab === 'money' ? (
|
||||
<FinancesEditor dossier={dossier} onSave={(patch) => void app.updateDossier(dossier.id, patch)} />
|
||||
) : null}
|
||||
|
||||
{tab === 'visit' ? (
|
||||
<VisiteTab
|
||||
definitions={app.definitions}
|
||||
findings={findings}
|
||||
onToggle={(code, checked) =>
|
||||
void app.toggleFinding(dossier.id, code, checked)
|
||||
}
|
||||
checklistEUR={juge.checklistWorks}
|
||||
maxPurchase={juge.maxPurchase}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{tab === 'flash' ? (
|
||||
<ScrollView
|
||||
contentContainerStyle={{
|
||||
padding: 16,
|
||||
paddingBottom: insets.bottom + 24,
|
||||
}}
|
||||
>
|
||||
{dossier.status !== 'under_promise' ? (
|
||||
<Text style={styles.muted}>
|
||||
Verrouillez le dossier (« sous promesse ») depuis l’onglet Feu pour
|
||||
activer le teaser investisseur.
|
||||
</Text>
|
||||
) : (
|
||||
<>
|
||||
<Text style={styles.cardTitle}>Investisseurs (top 5 match)</Text>
|
||||
{matches.length === 0 ? (
|
||||
<Text style={styles.muted}>Aucun match — ajustez critères ou dossier.</Text>
|
||||
) : (
|
||||
matches.map((m) => (
|
||||
<View key={m.id} style={styles.matchRow}>
|
||||
<Text style={styles.matchName}>{m.display_name}</Text>
|
||||
{m.email ? (
|
||||
<Text style={styles.muted}>{m.email}</Text>
|
||||
) : null}
|
||||
</View>
|
||||
))
|
||||
)}
|
||||
<PrimaryButton
|
||||
title="Générer & partager le teaser PDF"
|
||||
containerStyle={{ marginTop: 16 }}
|
||||
onPress={() =>
|
||||
void shareTeaserPdf(
|
||||
dossier,
|
||||
juge.result,
|
||||
matches.map((m) => m.display_name),
|
||||
)
|
||||
}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</ScrollView>
|
||||
) : null}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function Row({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<View style={styles.row}>
|
||||
<Text style={styles.rowLabel}>{label}</Text>
|
||||
<Text style={styles.rowValue}>{value}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function FinancesEditor({
|
||||
dossier,
|
||||
onSave,
|
||||
}: {
|
||||
dossier: DossierRow;
|
||||
onSave: (patch: Partial<DossierRow>) => void;
|
||||
}) {
|
||||
const insets = useSafeAreaInsets();
|
||||
const [title, setTitle] = useState(dossier.title);
|
||||
const [address, setAddress] = useState(dossier.address_line ?? '');
|
||||
const [city, setCity] = useState(dossier.city ?? '');
|
||||
const [postal, setPostal] = useState(dossier.postal_code ?? '');
|
||||
const [surface, setSurface] = useState(String(dossier.surface_m2 ?? ''));
|
||||
const [purchase, setPurchase] = useState(String(dossier.purchase_price_target ?? ''));
|
||||
const [resale, setResale] = useState(String(dossier.resale_price_estimate ?? ''));
|
||||
const [dvf, setDvf] = useState(String(dossier.dvf_reference_price_m2 ?? ''));
|
||||
const [works, setWorks] = useState(String(dossier.works_estimate_total ?? ''));
|
||||
const [miscA, setMiscA] = useState(String(dossier.misc_acquisition_cost ?? ''));
|
||||
const [miscS, setMiscS] = useState(String(dossier.misc_sale_cost ?? ''));
|
||||
const [carryM, setCarryM] = useState(String(dossier.carrying_months ?? 6));
|
||||
const [carryR, setCarryR] = useState(String(dossier.carrying_annual_rate ?? 0.05));
|
||||
const [dpe, setDpe] = useState(dossier.dpe_class ?? '');
|
||||
const [pluZone, setPluZone] = useState(dossier.plu_zone_code ?? '');
|
||||
const [pluNotes, setPluNotes] = useState(dossier.plu_notes ?? '');
|
||||
const [parcelDiv, setParcelDiv] = useState(dossier.parcel_subdivision_candidate);
|
||||
const [deficitFoncier, setDeficitFoncier] = useState(
|
||||
dossier.deficit_foncier_candidate,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setTitle(dossier.title);
|
||||
setAddress(dossier.address_line ?? '');
|
||||
setCity(dossier.city ?? '');
|
||||
setPostal(dossier.postal_code ?? '');
|
||||
setSurface(String(dossier.surface_m2 ?? ''));
|
||||
setPurchase(String(dossier.purchase_price_target ?? ''));
|
||||
setResale(String(dossier.resale_price_estimate ?? ''));
|
||||
setDvf(String(dossier.dvf_reference_price_m2 ?? ''));
|
||||
setWorks(String(dossier.works_estimate_total ?? ''));
|
||||
setMiscA(String(dossier.misc_acquisition_cost ?? ''));
|
||||
setMiscS(String(dossier.misc_sale_cost ?? ''));
|
||||
setCarryM(String(dossier.carrying_months ?? 6));
|
||||
setCarryR(String(dossier.carrying_annual_rate ?? 0.05));
|
||||
setDpe(dossier.dpe_class ?? '');
|
||||
setPluZone(dossier.plu_zone_code ?? '');
|
||||
setPluNotes(dossier.plu_notes ?? '');
|
||||
setParcelDiv(dossier.parcel_subdivision_candidate);
|
||||
setDeficitFoncier(dossier.deficit_foncier_candidate);
|
||||
}, [
|
||||
dossier.id,
|
||||
dossier.updated_at,
|
||||
dossier.title,
|
||||
dossier.address_line,
|
||||
dossier.city,
|
||||
dossier.postal_code,
|
||||
dossier.surface_m2,
|
||||
dossier.purchase_price_target,
|
||||
dossier.resale_price_estimate,
|
||||
dossier.dvf_reference_price_m2,
|
||||
dossier.works_estimate_total,
|
||||
dossier.misc_acquisition_cost,
|
||||
dossier.misc_sale_cost,
|
||||
dossier.carrying_months,
|
||||
dossier.carrying_annual_rate,
|
||||
dossier.dpe_class,
|
||||
dossier.plu_zone_code,
|
||||
dossier.plu_notes,
|
||||
dossier.parcel_subdivision_candidate,
|
||||
dossier.deficit_foncier_candidate,
|
||||
]);
|
||||
|
||||
const parseNum = (s: string) => Number(s.replace(',', '.').replace(/\s/g, ''));
|
||||
|
||||
return (
|
||||
<ScrollView
|
||||
contentContainerStyle={{
|
||||
padding: 16,
|
||||
paddingBottom: insets.bottom + 24,
|
||||
}}
|
||||
>
|
||||
<LabeledField label="Titre du dossier" value={title} onChangeText={setTitle} />
|
||||
<LabeledField label="Adresse" value={address} onChangeText={setAddress} />
|
||||
<LabeledField label="Ville" value={city} onChangeText={setCity} />
|
||||
<LabeledField label="Code postal" value={postal} onChangeText={setPostal} />
|
||||
<LabeledField
|
||||
label="Surface (m²)"
|
||||
keyboardType="decimal-pad"
|
||||
value={surface}
|
||||
onChangeText={setSurface}
|
||||
/>
|
||||
<LabeledField
|
||||
label="Prix d'achat cible (€)"
|
||||
keyboardType="number-pad"
|
||||
value={purchase}
|
||||
onChangeText={setPurchase}
|
||||
/>
|
||||
<LabeledField
|
||||
label="Prix de revente estimé (€)"
|
||||
keyboardType="number-pad"
|
||||
value={resale}
|
||||
onChangeText={setResale}
|
||||
/>
|
||||
<LabeledField
|
||||
label="DVF réf. (€/m²)"
|
||||
keyboardType="decimal-pad"
|
||||
value={dvf}
|
||||
onChangeText={setDvf}
|
||||
/>
|
||||
<LabeledField
|
||||
label="Travaux estimés hors checklist (€)"
|
||||
keyboardType="number-pad"
|
||||
value={works}
|
||||
onChangeText={setWorks}
|
||||
/>
|
||||
<LabeledField
|
||||
label="Frais d'achat divers (€)"
|
||||
keyboardType="number-pad"
|
||||
value={miscA}
|
||||
onChangeText={setMiscA}
|
||||
/>
|
||||
<LabeledField
|
||||
label="Frais de vente divers (€)"
|
||||
keyboardType="number-pad"
|
||||
value={miscS}
|
||||
onChangeText={setMiscS}
|
||||
/>
|
||||
<LabeledField
|
||||
label="Portage (mois)"
|
||||
keyboardType="number-pad"
|
||||
value={carryM}
|
||||
onChangeText={setCarryM}
|
||||
/>
|
||||
<LabeledField
|
||||
label="Taux portage annuel (ex: 0.055)"
|
||||
keyboardType="decimal-pad"
|
||||
value={carryR}
|
||||
onChangeText={setCarryR}
|
||||
/>
|
||||
<LabeledField
|
||||
label="DPE (A–G)"
|
||||
autoCapitalize="characters"
|
||||
maxLength={1}
|
||||
value={dpe}
|
||||
onChangeText={(t) => setDpe(t.toUpperCase())}
|
||||
/>
|
||||
<Text style={styles.sectionLabel}>Urbanisme & stratégie</Text>
|
||||
<LabeledField
|
||||
label="Zone PLU (libellé ou code)"
|
||||
value={pluZone}
|
||||
onChangeText={setPluZone}
|
||||
/>
|
||||
<LabeledField
|
||||
label="Notes urbanisme (servitude, COS…)"
|
||||
value={pluNotes}
|
||||
onChangeText={setPluNotes}
|
||||
multiline
|
||||
/>
|
||||
<View style={styles.switchRow}>
|
||||
<Text style={styles.switchLabel}>Piste division parcellaire</Text>
|
||||
<Switch value={parcelDiv} onValueChange={setParcelDiv} />
|
||||
</View>
|
||||
<View style={styles.switchRow}>
|
||||
<Text style={styles.switchLabel}>Piste déficit foncier (passoire)</Text>
|
||||
<Switch value={deficitFoncier} onValueChange={setDeficitFoncier} />
|
||||
</View>
|
||||
<PrimaryButton
|
||||
title="Enregistrer les finances"
|
||||
onPress={() => {
|
||||
onSave({
|
||||
title: title.trim(),
|
||||
address_line: address.trim() || null,
|
||||
city: city.trim() || null,
|
||||
postal_code: postal.trim() || null,
|
||||
surface_m2: parseNum(surface) || null,
|
||||
purchase_price_target: parseNum(purchase) || null,
|
||||
resale_price_estimate: parseNum(resale) || null,
|
||||
dvf_reference_price_m2: parseNum(dvf) || null,
|
||||
works_estimate_total: parseNum(works) || null,
|
||||
misc_acquisition_cost: parseNum(miscA) || null,
|
||||
misc_sale_cost: parseNum(miscS) || null,
|
||||
carrying_months: Math.round(parseNum(carryM) || 6),
|
||||
carrying_annual_rate: parseNum(carryR) || 0.05,
|
||||
dpe_class: dpe || null,
|
||||
plu_zone_code: pluZone.trim() || null,
|
||||
plu_notes: pluNotes.trim() || null,
|
||||
parcel_subdivision_candidate: parcelDiv,
|
||||
deficit_foncier_candidate: deficitFoncier,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
||||
|
||||
function VisiteTab({
|
||||
definitions,
|
||||
findings,
|
||||
onToggle,
|
||||
checklistEUR,
|
||||
maxPurchase,
|
||||
}: {
|
||||
definitions: VisitFindingDefinitionRow[];
|
||||
findings: DossierVisitFindingRow[];
|
||||
onToggle: (code: string, checked: boolean) => void;
|
||||
checklistEUR: number;
|
||||
maxPurchase: number;
|
||||
}) {
|
||||
const insets = useSafeAreaInsets();
|
||||
const rows = definitions.map((def) => {
|
||||
const f = findings.find((x) => x.finding_code === def.code);
|
||||
return { def, f };
|
||||
});
|
||||
|
||||
return (
|
||||
<ScrollView
|
||||
contentContainerStyle={{
|
||||
padding: 16,
|
||||
paddingBottom: insets.bottom + 24,
|
||||
}}
|
||||
>
|
||||
<View style={styles.card}>
|
||||
<Text style={styles.cardTitle}>Anti-erreur visite</Text>
|
||||
<Text style={styles.muted}>
|
||||
Cochez les points noirs : l’app ajoute les travaux associés et recalcule
|
||||
le prix d’achat max pour rester à {(MIN_NET_MARGIN_PCT * 100).toFixed(0)}{' '}
|
||||
% de marge nette.
|
||||
</Text>
|
||||
<Text style={styles.highlight}>
|
||||
Travaux checklist : {checklistEUR.toLocaleString('fr-FR')} €
|
||||
</Text>
|
||||
<Text style={styles.highlight}>
|
||||
Prix d’achat max (cible marge) :{' '}
|
||||
{maxPurchase.toLocaleString('fr-FR')} €
|
||||
</Text>
|
||||
</View>
|
||||
{rows.map(({ def, f }) => {
|
||||
const checked = f?.checked ?? false;
|
||||
return (
|
||||
<View key={def.code} style={styles.visitRow}>
|
||||
<View style={{ flex: 1, paddingRight: 12 }}>
|
||||
<Text style={styles.visitLabel}>{def.label}</Text>
|
||||
<Text style={styles.muted}>
|
||||
+{(f?.works_delta_override_eur ?? def.default_works_delta_eur).toLocaleString('fr-FR')}{' '}
|
||||
€ si coché
|
||||
</Text>
|
||||
</View>
|
||||
<Switch
|
||||
value={checked}
|
||||
onValueChange={(v) => onToggle(def.code, v)}
|
||||
trackColor={{ true: colors.accent, false: colors.border }}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
})}
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
root: { flex: 1, backgroundColor: colors.bg },
|
||||
center: { flex: 1, justifyContent: 'center', alignItems: 'center', padding: 24 },
|
||||
muted: { color: colors.textMuted, lineHeight: 20 },
|
||||
tabBar: { maxHeight: 52, borderBottomWidth: 1, borderBottomColor: colors.border },
|
||||
tabBarInner: { paddingHorizontal: 12, paddingVertical: 10, gap: 8, alignItems: 'center' },
|
||||
tabChip: {
|
||||
paddingHorizontal: 14,
|
||||
paddingVertical: 8,
|
||||
borderRadius: 999,
|
||||
backgroundColor: colors.bgCard,
|
||||
marginRight: 8,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.border,
|
||||
},
|
||||
tabChipOn: { borderColor: colors.accent, backgroundColor: '#15233d' },
|
||||
tabText: { color: colors.textMuted, fontWeight: '600' },
|
||||
tabTextOn: { color: colors.text },
|
||||
hero: { borderRadius: 16, padding: 20, marginBottom: 16 },
|
||||
heroLabel: { color: colors.textMuted, fontSize: 12, textTransform: 'uppercase' },
|
||||
heroScore: { fontSize: 44, fontWeight: '800', color: colors.text, marginVertical: 8 },
|
||||
heroSub: { color: colors.textMuted, marginTop: 4 },
|
||||
card: {
|
||||
backgroundColor: colors.bgCard,
|
||||
borderRadius: 14,
|
||||
padding: 16,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.border,
|
||||
},
|
||||
cardTitle: { color: colors.text, fontSize: 18, fontWeight: '700', marginBottom: 12 },
|
||||
row: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
marginBottom: 10,
|
||||
gap: 12,
|
||||
},
|
||||
rowLabel: { color: colors.textMuted, flex: 1 },
|
||||
rowValue: { color: colors.text, fontWeight: '600' },
|
||||
highlight: { color: colors.flash, marginTop: 8, fontWeight: '600' },
|
||||
visitRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingVertical: 12,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: colors.border,
|
||||
},
|
||||
visitLabel: { color: colors.text, fontWeight: '600' },
|
||||
matchRow: {
|
||||
paddingVertical: 10,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: colors.border,
|
||||
},
|
||||
matchName: { color: colors.text, fontWeight: '700', fontSize: 16 },
|
||||
sectionLabel: {
|
||||
color: colors.textMuted,
|
||||
fontSize: 12,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 0.06,
|
||||
marginTop: 8,
|
||||
marginBottom: 6,
|
||||
},
|
||||
switchRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
marginBottom: 14,
|
||||
paddingVertical: 4,
|
||||
},
|
||||
switchLabel: { color: colors.text, flex: 1, paddingRight: 12 },
|
||||
});
|
||||
166
app/hooks/useAnalyse.ts
Normal 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
@ -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
@ -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
@ -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
@ -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
@ -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
@ -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);
|
||||
}
|
||||
112
app/index.tsx
@ -1,112 +0,0 @@
|
||||
import { router } from 'expo-router';
|
||||
import { useEffect } from 'react';
|
||||
import {
|
||||
ActivityIndicator,
|
||||
ScrollView,
|
||||
StyleSheet,
|
||||
Text,
|
||||
View,
|
||||
} from 'react-native';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
import { PrimaryButton } from '../src/components/PrimaryButton';
|
||||
import { useApp } from '../src/context/AppContext';
|
||||
import { colors } from '../src/theme/colors';
|
||||
|
||||
export default function WelcomeScreen() {
|
||||
const insets = useSafeAreaInsets();
|
||||
const app = useApp();
|
||||
|
||||
useEffect(() => {
|
||||
if (!app.ready) return;
|
||||
if (app.user) {
|
||||
router.replace('/(tabs)');
|
||||
}
|
||||
}, [app.ready, app.user]);
|
||||
|
||||
if (!app.ready) {
|
||||
return (
|
||||
<View style={[styles.center, { paddingTop: insets.top }]}>
|
||||
<ActivityIndicator size="large" color={colors.accent} />
|
||||
<Text style={styles.muted}>Chargement…</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
if (app.user) {
|
||||
return (
|
||||
<View style={[styles.center, { paddingTop: insets.top }]}>
|
||||
<ActivityIndicator size="large" color={colors.accent} />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ScrollView
|
||||
contentContainerStyle={[
|
||||
styles.scroll,
|
||||
{ paddingTop: insets.top + 24, paddingBottom: insets.bottom + 24 },
|
||||
]}
|
||||
>
|
||||
<Text style={styles.brand}>MDB-Turbo</Text>
|
||||
<Text style={styles.tagline}>
|
||||
Prospection marchand de biens : marge, visite, investisseurs — sur le
|
||||
terrain.
|
||||
</Text>
|
||||
<PrimaryButton
|
||||
title="Continuer hors-ligne (données sur l’appareil)"
|
||||
onPress={() => {
|
||||
void app.enterLocalMode().then(() => router.replace('/(tabs)'));
|
||||
}}
|
||||
containerStyle={styles.btn}
|
||||
/>
|
||||
<PrimaryButton
|
||||
title="Se connecter (Supabase)"
|
||||
variant="ghost"
|
||||
onPress={() => router.push('/auth/login')}
|
||||
containerStyle={styles.btn}
|
||||
/>
|
||||
<PrimaryButton
|
||||
title="Configurer Supabase"
|
||||
variant="ghost"
|
||||
onPress={() => router.push('/(tabs)/reglages')}
|
||||
containerStyle={styles.btn}
|
||||
/>
|
||||
<Text style={styles.hint}>
|
||||
Le mode hors-ligne fonctionne sans compte. Pour synchroniser plusieurs
|
||||
appareils, renseignez votre projet Supabase dans Réglages puis
|
||||
connectez-vous.
|
||||
</Text>
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
center: {
|
||||
flex: 1,
|
||||
backgroundColor: colors.bg,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: 12,
|
||||
},
|
||||
muted: { color: colors.textMuted },
|
||||
scroll: { paddingHorizontal: 22, backgroundColor: colors.bg },
|
||||
brand: {
|
||||
fontSize: 34,
|
||||
fontWeight: '800',
|
||||
color: colors.text,
|
||||
marginBottom: 8,
|
||||
},
|
||||
tagline: {
|
||||
fontSize: 16,
|
||||
color: colors.textMuted,
|
||||
lineHeight: 24,
|
||||
marginBottom: 28,
|
||||
},
|
||||
btn: { marginBottom: 12, width: '100%' },
|
||||
hint: {
|
||||
marginTop: 20,
|
||||
fontSize: 13,
|
||||
color: colors.textMuted,
|
||||
lineHeight: 20,
|
||||
},
|
||||
});
|
||||
2909
mb-app/package-lock.json → app/package-lock.json
generated
@ -1,8 +1,10 @@
|
||||
{
|
||||
"name": "mb-app",
|
||||
"main": "expo-router/entry",
|
||||
"name": "app",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"main": "expo-router/entry",
|
||||
"scripts": {
|
||||
"postinstall": "node scripts/ensure-assets.js",
|
||||
"start": "expo start",
|
||||
"android": "expo start --android",
|
||||
"ios": "expo start --ios",
|
||||
@ -12,41 +14,36 @@
|
||||
"@expo/vector-icons": "^15.0.3",
|
||||
"@react-native-async-storage/async-storage": "2.2.0",
|
||||
"@react-navigation/native": "^7.1.8",
|
||||
"@supabase/supabase-js": "^2.105.1",
|
||||
"@tanstack/react-query": "^5.100.9",
|
||||
"expo": "~54.0.33",
|
||||
"@tanstack/react-query": "^5.90.0",
|
||||
"expo": "~54.0.0",
|
||||
"expo-constants": "~18.0.13",
|
||||
"expo-document-picker": "~14.0.8",
|
||||
"expo-file-system": "~19.0.22",
|
||||
"expo-font": "~14.0.11",
|
||||
"expo-image-picker": "~17.0.11",
|
||||
"expo-haptics": "~15.0.7",
|
||||
"expo-image-picker": "~17.0.8",
|
||||
"expo-linking": "~8.0.11",
|
||||
"expo-notifications": "~0.32.17",
|
||||
"expo-router": "~6.0.23",
|
||||
"expo-router": "~6.0.0",
|
||||
"expo-splash-screen": "~31.0.13",
|
||||
"expo-status-bar": "~3.0.9",
|
||||
"expo-status-bar": "~3.0.8",
|
||||
"expo-web-browser": "~15.0.10",
|
||||
"nativewind": "^4.2.3",
|
||||
"nativewind": "^4.1.23",
|
||||
"pocketbase": "^0.26.0",
|
||||
"react": "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-maps": "1.20.1",
|
||||
"react-native-paper": "^5.15.1",
|
||||
"react-native-reanimated": "~4.1.1",
|
||||
"react-native-safe-area-context": "~5.6.0",
|
||||
"react-native-screens": "~4.16.0",
|
||||
"react-native-url-polyfill": "^3.0.0",
|
||||
"react-native-web": "~0.21.0",
|
||||
"react-native-worklets": "0.5.1",
|
||||
"tailwindcss": "^3.4.19",
|
||||
"zustand": "^5.0.12"
|
||||
"tailwindcss": "^3.4.17",
|
||||
"zustand": "^5.0.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.26.0",
|
||||
"@types/react": "~19.1.0",
|
||||
"prettier-plugin-tailwindcss": "^0.8.0",
|
||||
"react-test-renderer": "19.1.0",
|
||||
"babel-preset-expo": "~54.0.0",
|
||||
"typescript": "~5.9.2"
|
||||
},
|
||||
"private": true
|
||||
}
|
||||
}
|
||||
26
app/scripts/ensure-assets.js
Normal 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);
|
||||
86
app/services/pocketbase.ts
Normal 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
@ -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
@ -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
@ -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
@ -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, aujourd’hui, dans les 7 jours (excl. aujourd’hui), 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
@ -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;
|
||||
}
|
||||
12
app/utils/pocketbaseErrors.ts
Normal 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.';
|
||||
}
|
||||
|
Before Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 1.4 KiB |
BIN
assets/icon.png
|
Before Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 17 KiB |
@ -1,7 +0,0 @@
|
||||
module.exports = function (api) {
|
||||
api.cache(true);
|
||||
return {
|
||||
presets: ['babel-preset-expo'],
|
||||
plugins: ['expo-router/babel'],
|
||||
};
|
||||
};
|
||||
@ -1,32 +0,0 @@
|
||||
version: '3.8'
|
||||
|
||||
# ============================================================
|
||||
# DÉVELOPPEMENT LOCAL — docker compose -f docker/docker-compose.dev.yml up
|
||||
# PocketBase accessible sur http://localhost:8090
|
||||
# Admin PocketBase : http://localhost:8090/_/
|
||||
# ============================================================
|
||||
|
||||
services:
|
||||
|
||||
pocketbase:
|
||||
image: ghcr.io/muchobien/pocketbase:latest
|
||||
container_name: mb-pocketbase-dev
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "8090:8090" # Accès direct sans Nginx en dev
|
||||
volumes:
|
||||
# Données locales (dans .gitignore)
|
||||
- ../pocketbase/pb_data:/pb/pb_data
|
||||
# Hooks JS versionnés dans Git ✅
|
||||
- ../pocketbase/pb_hooks:/pb/pb_hooks
|
||||
# Migrations versionnées dans Git ✅
|
||||
- ../pocketbase/pb_migrations:/pb/pb_migrations
|
||||
env_file:
|
||||
- ../.env.local # Clé Anthropic en local
|
||||
environment:
|
||||
- TZ=Europe/Paris
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://localhost:8090/api/health"]
|
||||
interval: 15s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
@ -1,59 +0,0 @@
|
||||
version: '3.8'
|
||||
|
||||
# ============================================================
|
||||
# PRODUCTION NAS — docker compose -f docker/docker-compose.prod.yml up -d
|
||||
# Accessible sur https://VOTRE_SOUS_DOMAINE.duckdns.org
|
||||
# ============================================================
|
||||
|
||||
services:
|
||||
|
||||
pocketbase:
|
||||
image: ghcr.io/muchobien/pocketbase:latest
|
||||
container_name: mb-pocketbase
|
||||
restart: unless-stopped
|
||||
# Pas de port exposé directement : Nginx fait le proxy
|
||||
expose:
|
||||
- "8090"
|
||||
volumes:
|
||||
# Données persistantes NAS (dans .gitignore)
|
||||
- /volume1/docker/mb-app/pb_data:/pb/pb_data
|
||||
# Hooks et migrations versionnés dans Git ✅
|
||||
- ../pocketbase/pb_hooks:/pb/pb_hooks
|
||||
- ../pocketbase/pb_migrations:/pb/pb_migrations
|
||||
env_file:
|
||||
- ../.env.production
|
||||
environment:
|
||||
- TZ=Europe/Paris
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://localhost:8090/api/health"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
|
||||
nginx:
|
||||
image: nginx:alpine
|
||||
container_name: mb-nginx
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "80:80"
|
||||
- "443:443"
|
||||
volumes:
|
||||
- ./nginx.prod.conf:/etc/nginx/nginx.conf:ro
|
||||
- /volume1/docker/mb-app/ssl:/etc/nginx/ssl:ro
|
||||
depends_on:
|
||||
pocketbase:
|
||||
condition: service_healthy
|
||||
|
||||
duckdns:
|
||||
image: lscr.io/linuxserver/duckdns:latest
|
||||
container_name: mb-duckdns
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
- TZ=Europe/Paris
|
||||
- SUBDOMAINS=${DUCKDNS_SUBDOMAINS}
|
||||
- TOKEN=${DUCKDNS_TOKEN}
|
||||
- LOG_FILE=true
|
||||
env_file:
|
||||
- ../.env.production
|
||||
volumes:
|
||||
- /volume1/docker/mb-app/duckdns:/config
|
||||
18
docker/docker-compose.dev.yml
Normal file
@ -0,0 +1,18 @@
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
pocketbase:
|
||||
image: ghcr.io/muchobien/pocketbase:latest
|
||||
container_name: mdb-pocketbase-dev
|
||||
restart: unless-stopped
|
||||
command: --dir=/pb_data
|
||||
ports:
|
||||
- "8090:8090"
|
||||
volumes:
|
||||
- ../pocketbase/pb_data:/pb_data
|
||||
- ../pocketbase/pb_hooks:/pb_hooks
|
||||
- ../pocketbase/pb_migrations:/pb_migrations
|
||||
env_file:
|
||||
- ../.env.local
|
||||
environment:
|
||||
- TZ=Europe/Paris
|
||||
43
docker/docker-compose.prod.yml
Normal file
@ -0,0 +1,43 @@
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
pocketbase:
|
||||
image: ghcr.io/muchobien/pocketbase:latest
|
||||
container_name: mdb-pocketbase
|
||||
restart: unless-stopped
|
||||
expose:
|
||||
- "8090"
|
||||
volumes:
|
||||
- /volume1/docker/mdb/pocketbase/pb_data:/pb/pb_data
|
||||
- ../pocketbase/pb_hooks:/pb/pb_hooks
|
||||
- ../pocketbase/pb_migrations:/pb/pb_migrations
|
||||
env_file:
|
||||
- ../.env.production
|
||||
environment:
|
||||
- TZ=Europe/Paris
|
||||
|
||||
nginx:
|
||||
image: nginx:alpine
|
||||
container_name: mdb-nginx
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "80:80"
|
||||
- "443:443"
|
||||
volumes:
|
||||
- ./nginx.prod.conf:/etc/nginx/nginx.conf:ro
|
||||
- /volume1/docker/mdb/ssl:/etc/nginx/ssl:ro
|
||||
depends_on:
|
||||
- pocketbase
|
||||
|
||||
duckdns:
|
||||
image: lscr.io/linuxserver/duckdns:latest
|
||||
container_name: mdb-duckdns
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
- TZ=Europe/Paris
|
||||
- SUBDOMAINS=${DUCKDNS_SUBDOMAINS}
|
||||
- TOKEN=${DUCKDNS_TOKEN}
|
||||
env_file:
|
||||
- ../.env.production
|
||||
volumes:
|
||||
- /volume1/docker/mdb/duckdns:/config
|
||||
@ -1,20 +0,0 @@
|
||||
# ============================================================
|
||||
# .env.example — Copier en .env.local (dev) ou .env.production
|
||||
# NE PAS mettre de vraies valeurs dans ce fichier
|
||||
# ============================================================
|
||||
|
||||
# ── URL PocketBase ───────────────────────────────────────────
|
||||
# Dev local :
|
||||
EXPO_PUBLIC_PB_URL=http://localhost:8090
|
||||
# Production NAS :
|
||||
# EXPO_PUBLIC_PB_URL=https://VOTRE_SOUS_DOMAINE.duckdns.org
|
||||
|
||||
# ── PocketBase (côté serveur Docker) ─────────────────────────
|
||||
ANTHROPIC_API_KEY=sk-ant-VOTRE_CLE_ICI
|
||||
|
||||
# ── DuckDNS (production uniquement) ──────────────────────────
|
||||
DUCKDNS_SUBDOMAINS=VOTRE_SOUS_DOMAINE
|
||||
DUCKDNS_TOKEN=VOTRE_TOKEN_DUCKDNS
|
||||
|
||||
# ── Expo (optionnel, pour EAS Build) ─────────────────────────
|
||||
# EXPO_TOKEN=
|
||||
41
mb-app/.gitignore
vendored
@ -1,41 +0,0 @@
|
||||
# Learn more https://docs.github.com/en/get-started/getting-started-with-git/ignoring-files
|
||||
|
||||
# dependencies
|
||||
node_modules/
|
||||
|
||||
# Expo
|
||||
.expo/
|
||||
dist/
|
||||
web-build/
|
||||
expo-env.d.ts
|
||||
|
||||
# Native
|
||||
.kotlin/
|
||||
*.orig.*
|
||||
*.jks
|
||||
*.p8
|
||||
*.p12
|
||||
*.key
|
||||
*.mobileprovision
|
||||
|
||||
# Metro
|
||||
.metro-health-check*
|
||||
|
||||
# debug
|
||||
npm-debug.*
|
||||
yarn-debug.*
|
||||
yarn-error.*
|
||||
|
||||
# macOS
|
||||
.DS_Store
|
||||
*.pem
|
||||
|
||||
# local env files
|
||||
.env*.local
|
||||
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
|
||||
# generated native folders
|
||||
/ios
|
||||
/android
|
||||
1
mb-app/.vscode/extensions.json
vendored
@ -1 +0,0 @@
|
||||
{ "recommendations": ["expo.vscode-expo-tools"] }
|
||||
7
mb-app/.vscode/settings.json
vendored
@ -1,7 +0,0 @@
|
||||
{
|
||||
"editor.codeActionsOnSave": {
|
||||
"source.fixAll": "explicit",
|
||||
"source.organizeImports": "explicit",
|
||||
"source.sortMembers": "explicit"
|
||||
}
|
||||
}
|
||||
@ -1,59 +0,0 @@
|
||||
import React from 'react';
|
||||
import FontAwesome from '@expo/vector-icons/FontAwesome';
|
||||
import { Link, Tabs } from 'expo-router';
|
||||
import { Pressable } from 'react-native';
|
||||
|
||||
import Colors from '@/constants/Colors';
|
||||
import { useColorScheme } from '@/components/useColorScheme';
|
||||
import { useClientOnlyValue } from '@/components/useClientOnlyValue';
|
||||
|
||||
// You can explore the built-in icon families and icons on the web at https://icons.expo.fyi/
|
||||
function TabBarIcon(props: {
|
||||
name: React.ComponentProps<typeof FontAwesome>['name'];
|
||||
color: string;
|
||||
}) {
|
||||
return <FontAwesome size={28} style={{ marginBottom: -3 }} {...props} />;
|
||||
}
|
||||
|
||||
export default function TabLayout() {
|
||||
const colorScheme = useColorScheme();
|
||||
|
||||
return (
|
||||
<Tabs
|
||||
screenOptions={{
|
||||
tabBarActiveTintColor: Colors[colorScheme ?? 'light'].tint,
|
||||
// Disable the static render of the header on web
|
||||
// to prevent a hydration error in React Navigation v6.
|
||||
headerShown: useClientOnlyValue(false, true),
|
||||
}}>
|
||||
<Tabs.Screen
|
||||
name="index"
|
||||
options={{
|
||||
title: 'Tab One',
|
||||
tabBarIcon: ({ color }) => <TabBarIcon name="code" color={color} />,
|
||||
headerRight: () => (
|
||||
<Link href="/modal" asChild>
|
||||
<Pressable>
|
||||
{({ pressed }) => (
|
||||
<FontAwesome
|
||||
name="info-circle"
|
||||
size={25}
|
||||
color={Colors[colorScheme ?? 'light'].text}
|
||||
style={{ marginRight: 15, opacity: pressed ? 0.5 : 1 }}
|
||||
/>
|
||||
)}
|
||||
</Pressable>
|
||||
</Link>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
<Tabs.Screen
|
||||
name="two"
|
||||
options={{
|
||||
title: 'Tab Two',
|
||||
tabBarIcon: ({ color }) => <TabBarIcon name="code" color={color} />,
|
||||
}}
|
||||
/>
|
||||
</Tabs>
|
||||
);
|
||||
}
|
||||
@ -1,31 +0,0 @@
|
||||
import { StyleSheet } from 'react-native';
|
||||
|
||||
import EditScreenInfo from '@/components/EditScreenInfo';
|
||||
import { Text, View } from '@/components/Themed';
|
||||
|
||||
export default function TabOneScreen() {
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<Text style={styles.title}>Tab One</Text>
|
||||
<View style={styles.separator} lightColor="#eee" darkColor="rgba(255,255,255,0.1)" />
|
||||
<EditScreenInfo path="app/(tabs)/index.tsx" />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
title: {
|
||||
fontSize: 20,
|
||||
fontWeight: 'bold',
|
||||
},
|
||||
separator: {
|
||||
marginVertical: 30,
|
||||
height: 1,
|
||||
width: '80%',
|
||||
},
|
||||
});
|
||||
@ -1,31 +0,0 @@
|
||||
import { StyleSheet } from 'react-native';
|
||||
|
||||
import EditScreenInfo from '@/components/EditScreenInfo';
|
||||
import { Text, View } from '@/components/Themed';
|
||||
|
||||
export default function TabTwoScreen() {
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<Text style={styles.title}>Tab Two</Text>
|
||||
<View style={styles.separator} lightColor="#eee" darkColor="rgba(255,255,255,0.1)" />
|
||||
<EditScreenInfo path="app/(tabs)/two.tsx" />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
title: {
|
||||
fontSize: 20,
|
||||
fontWeight: 'bold',
|
||||
},
|
||||
separator: {
|
||||
marginVertical: 30,
|
||||
height: 1,
|
||||
width: '80%',
|
||||
},
|
||||
});
|
||||
@ -1,38 +0,0 @@
|
||||
import { ScrollViewStyleReset } from 'expo-router/html';
|
||||
|
||||
// This file is web-only and used to configure the root HTML for every
|
||||
// web page during static rendering.
|
||||
// The contents of this function only run in Node.js environments and
|
||||
// do not have access to the DOM or browser APIs.
|
||||
export default function Root({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charSet="utf-8" />
|
||||
<meta httpEquiv="X-UA-Compatible" content="IE=edge" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no" />
|
||||
|
||||
{/*
|
||||
Disable body scrolling on web. This makes ScrollView components work closer to how they do on native.
|
||||
However, body scrolling is often nice to have for mobile web. If you want to enable it, remove this line.
|
||||
*/}
|
||||
<ScrollViewStyleReset />
|
||||
|
||||
{/* Using raw CSS styles as an escape-hatch to ensure the background color never flickers in dark-mode. */}
|
||||
<style dangerouslySetInnerHTML={{ __html: responsiveBackground }} />
|
||||
{/* Add any additional <head> elements that you want globally available on web... */}
|
||||
</head>
|
||||
<body>{children}</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
|
||||
const responsiveBackground = `
|
||||
body {
|
||||
background-color: #fff;
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
body {
|
||||
background-color: #000;
|
||||
}
|
||||
}`;
|
||||
@ -1,40 +0,0 @@
|
||||
import { Link, Stack } from 'expo-router';
|
||||
import { StyleSheet } from 'react-native';
|
||||
|
||||
import { Text, View } from '@/components/Themed';
|
||||
|
||||
export default function NotFoundScreen() {
|
||||
return (
|
||||
<>
|
||||
<Stack.Screen options={{ title: 'Oops!' }} />
|
||||
<View style={styles.container}>
|
||||
<Text style={styles.title}>This screen doesn't exist.</Text>
|
||||
|
||||
<Link href="/" style={styles.link}>
|
||||
<Text style={styles.linkText}>Go to home screen!</Text>
|
||||
</Link>
|
||||
</View>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: 20,
|
||||
},
|
||||
title: {
|
||||
fontSize: 20,
|
||||
fontWeight: 'bold',
|
||||
},
|
||||
link: {
|
||||
marginTop: 15,
|
||||
paddingVertical: 15,
|
||||
},
|
||||
linkText: {
|
||||
fontSize: 14,
|
||||
color: '#2e78b7',
|
||||
},
|
||||
});
|
||||
@ -1,88 +0,0 @@
|
||||
import 'react-native-gesture-handler';
|
||||
import '../global.css';
|
||||
import FontAwesome from '@expo/vector-icons/FontAwesome';
|
||||
import { DarkTheme, DefaultTheme, ThemeProvider } from '@react-navigation/native';
|
||||
import { QueryClientProvider } from '@tanstack/react-query';
|
||||
import { useFonts } from 'expo-font';
|
||||
import { Stack } from 'expo-router';
|
||||
import * as SplashScreen from 'expo-splash-screen';
|
||||
import { useEffect } from 'react';
|
||||
import { GestureHandlerRootView } from 'react-native-gesture-handler';
|
||||
import { MD3DarkTheme, MD3LightTheme, PaperProvider } from 'react-native-paper';
|
||||
import 'react-native-reanimated';
|
||||
|
||||
import { useColorScheme } from '@/components/useColorScheme';
|
||||
import { AuthProvider } from '@/context/AuthContext';
|
||||
import { queryClient } from '@/lib/query-client';
|
||||
|
||||
export { ErrorBoundary } from 'expo-router';
|
||||
|
||||
SplashScreen.preventAutoHideAsync();
|
||||
|
||||
export default function RootLayout() {
|
||||
const [loaded, error] = useFonts({
|
||||
SpaceMono: require('../assets/fonts/SpaceMono-Regular.ttf'),
|
||||
...FontAwesome.font,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (error) throw error;
|
||||
}, [error]);
|
||||
|
||||
useEffect(() => {
|
||||
if (loaded) {
|
||||
void SplashScreen.hideAsync();
|
||||
}
|
||||
}, [loaded]);
|
||||
|
||||
if (!loaded) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <RootLayoutNav />;
|
||||
}
|
||||
|
||||
function RootLayoutNav() {
|
||||
const colorScheme = useColorScheme();
|
||||
const navigationTheme = colorScheme === 'dark' ? DarkTheme : DefaultTheme;
|
||||
const paperTheme =
|
||||
colorScheme === 'dark'
|
||||
? {
|
||||
...MD3DarkTheme,
|
||||
colors: {
|
||||
...MD3DarkTheme.colors,
|
||||
primary: '#3B82F6',
|
||||
primaryContainer: '#1E3A8A',
|
||||
},
|
||||
}
|
||||
: {
|
||||
...MD3LightTheme,
|
||||
colors: {
|
||||
...MD3LightTheme.colors,
|
||||
primary: '#1D4ED8',
|
||||
primaryContainer: '#DBEAFE',
|
||||
},
|
||||
};
|
||||
|
||||
return (
|
||||
<GestureHandlerRootView style={{ flex: 1 }}>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<PaperProvider theme={paperTheme}>
|
||||
<AuthProvider>
|
||||
<ThemeProvider value={navigationTheme}>
|
||||
<Stack screenOptions={{ headerShown: false }}>
|
||||
<Stack.Screen name="index" />
|
||||
<Stack.Screen name="(tabs)" />
|
||||
<Stack.Screen name="auth" />
|
||||
<Stack.Screen
|
||||
name="modal"
|
||||
options={{ presentation: 'modal', headerShown: true, title: 'Info' }}
|
||||
/>
|
||||
</Stack>
|
||||
</ThemeProvider>
|
||||
</AuthProvider>
|
||||
</PaperProvider>
|
||||
</QueryClientProvider>
|
||||
</GestureHandlerRootView>
|
||||
);
|
||||
}
|
||||
@ -1,12 +0,0 @@
|
||||
import { Stack } from 'expo-router';
|
||||
|
||||
export default function AuthLayout() {
|
||||
return (
|
||||
<Stack
|
||||
screenOptions={{
|
||||
headerShown: false,
|
||||
animation: 'fade',
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@ -1,96 +0,0 @@
|
||||
import { Link } from 'expo-router';
|
||||
import { useState } from 'react';
|
||||
import { KeyboardAvoidingView, Platform, Pressable, ScrollView, Text, View } from 'react-native';
|
||||
import { Button, TextInput } from 'react-native-paper';
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
import { useAuth } from '@/context/AuthContext';
|
||||
import { useUiStore } from '@/stores/ui-store';
|
||||
|
||||
export default function LoginScreen() {
|
||||
const { signIn } = useAuth();
|
||||
const lastEmail = useUiStore((s) => s.lastAuthEmail);
|
||||
const [email, setEmail] = useState(lastEmail);
|
||||
const [password, setPassword] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
return (
|
||||
<SafeAreaView className="flex-1 bg-slate-50">
|
||||
<KeyboardAvoidingView
|
||||
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
|
||||
className="flex-1"
|
||||
>
|
||||
<ScrollView
|
||||
keyboardShouldPersistTaps="handled"
|
||||
contentContainerClassName="flex-grow justify-center px-6 py-10"
|
||||
>
|
||||
<View className="mb-10">
|
||||
<Text className="text-3xl font-bold text-slate-900">Connexion</Text>
|
||||
<Text className="mt-2 text-base text-slate-600">
|
||||
Espace marchand de biens — accès sécurisé
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{error ? (
|
||||
<Text className="mb-4 rounded-lg bg-red-50 px-3 py-2 text-sm text-red-700">
|
||||
{error}
|
||||
</Text>
|
||||
) : null}
|
||||
|
||||
<TextInput
|
||||
label="E-mail"
|
||||
mode="outlined"
|
||||
value={email}
|
||||
onChangeText={setEmail}
|
||||
autoCapitalize="none"
|
||||
keyboardType="email-address"
|
||||
autoComplete="email"
|
||||
className="mb-3 bg-white"
|
||||
style={{ backgroundColor: '#fff' }}
|
||||
/>
|
||||
<TextInput
|
||||
label="Mot de passe"
|
||||
mode="outlined"
|
||||
value={password}
|
||||
onChangeText={setPassword}
|
||||
secureTextEntry
|
||||
autoComplete="password"
|
||||
className="mb-6 bg-white"
|
||||
style={{ backgroundColor: '#fff' }}
|
||||
/>
|
||||
|
||||
<Button
|
||||
mode="contained"
|
||||
onPress={async () => {
|
||||
setError(null);
|
||||
setLoading(true);
|
||||
const r = await signIn(email.trim(), password);
|
||||
setLoading(false);
|
||||
if (r.error) {
|
||||
setError(r.error);
|
||||
return;
|
||||
}
|
||||
useUiStore.getState().setLastAuthEmail(email.trim());
|
||||
}}
|
||||
loading={loading}
|
||||
disabled={loading}
|
||||
buttonColor="#1D4ED8"
|
||||
textColor="#ffffff"
|
||||
style={{ borderRadius: 10, paddingVertical: 4 }}
|
||||
>
|
||||
Se connecter
|
||||
</Button>
|
||||
|
||||
<View className="mt-8 items-center">
|
||||
<Text className="text-slate-600">Pas encore de compte ?</Text>
|
||||
<Link href="/auth/register" asChild>
|
||||
<Pressable className="mt-2 py-2">
|
||||
<Text className="font-semibold text-primary">Créer un compte</Text>
|
||||
</Pressable>
|
||||
</Link>
|
||||
</View>
|
||||
</ScrollView>
|
||||
</KeyboardAvoidingView>
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
@ -1,127 +0,0 @@
|
||||
import { Link } from 'expo-router';
|
||||
import { useState } from 'react';
|
||||
import { KeyboardAvoidingView, Platform, Pressable, ScrollView, Text, View } from 'react-native';
|
||||
import { Button, TextInput } from 'react-native-paper';
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
import { useAuth } from '@/context/AuthContext';
|
||||
|
||||
export default function RegisterScreen() {
|
||||
const { signUp } = useAuth();
|
||||
const [firstName, setFirstName] = useState('');
|
||||
const [lastName, setLastName] = useState('');
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [info, setInfo] = useState<string | null>(null);
|
||||
|
||||
return (
|
||||
<SafeAreaView className="flex-1 bg-slate-50">
|
||||
<KeyboardAvoidingView
|
||||
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
|
||||
className="flex-1"
|
||||
>
|
||||
<ScrollView
|
||||
keyboardShouldPersistTaps="handled"
|
||||
contentContainerClassName="flex-grow px-6 py-8"
|
||||
>
|
||||
<View className="mb-8">
|
||||
<Text className="text-3xl font-bold text-slate-900">Inscription</Text>
|
||||
<Text className="mt-2 text-base text-slate-600">
|
||||
Créez votre accès professionnel
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{error ? (
|
||||
<Text className="mb-3 rounded-lg bg-red-50 px-3 py-2 text-sm text-red-700">
|
||||
{error}
|
||||
</Text>
|
||||
) : null}
|
||||
{info ? (
|
||||
<Text className="mb-3 rounded-lg bg-emerald-50 px-3 py-2 text-sm text-emerald-800">
|
||||
{info}
|
||||
</Text>
|
||||
) : null}
|
||||
|
||||
<TextInput
|
||||
label="Prénom"
|
||||
mode="outlined"
|
||||
value={firstName}
|
||||
onChangeText={setFirstName}
|
||||
autoComplete="given-name"
|
||||
className="mb-3"
|
||||
style={{ backgroundColor: '#fff' }}
|
||||
/>
|
||||
<TextInput
|
||||
label="Nom"
|
||||
mode="outlined"
|
||||
value={lastName}
|
||||
onChangeText={setLastName}
|
||||
autoComplete="family-name"
|
||||
className="mb-3"
|
||||
style={{ backgroundColor: '#fff' }}
|
||||
/>
|
||||
<TextInput
|
||||
label="E-mail"
|
||||
mode="outlined"
|
||||
value={email}
|
||||
onChangeText={setEmail}
|
||||
autoCapitalize="none"
|
||||
keyboardType="email-address"
|
||||
autoComplete="email"
|
||||
className="mb-3"
|
||||
style={{ backgroundColor: '#fff' }}
|
||||
/>
|
||||
<TextInput
|
||||
label="Mot de passe"
|
||||
mode="outlined"
|
||||
value={password}
|
||||
onChangeText={setPassword}
|
||||
secureTextEntry
|
||||
autoComplete="new-password"
|
||||
className="mb-6"
|
||||
style={{ backgroundColor: '#fff' }}
|
||||
/>
|
||||
|
||||
<Button
|
||||
mode="contained"
|
||||
onPress={async () => {
|
||||
setError(null);
|
||||
setInfo(null);
|
||||
setLoading(true);
|
||||
const r = await signUp({
|
||||
email: email.trim(),
|
||||
password,
|
||||
firstName: firstName.trim(),
|
||||
lastName: lastName.trim(),
|
||||
});
|
||||
setLoading(false);
|
||||
if (r.error) {
|
||||
setError(r.error);
|
||||
return;
|
||||
}
|
||||
setInfo(
|
||||
'Si la confirmation e-mail est activée sur votre projet Supabase, vérifiez votre boîte avant de vous connecter.',
|
||||
);
|
||||
}}
|
||||
loading={loading}
|
||||
disabled={loading}
|
||||
buttonColor="#1D4ED8"
|
||||
textColor="#ffffff"
|
||||
style={{ borderRadius: 10, paddingVertical: 4 }}
|
||||
>
|
||||
S'inscrire
|
||||
</Button>
|
||||
|
||||
<View className="mt-8 items-center">
|
||||
<Link href="/auth/login" asChild>
|
||||
<Pressable className="py-2">
|
||||
<Text className="font-semibold text-primary">Retour à la connexion</Text>
|
||||
</Pressable>
|
||||
</Link>
|
||||
</View>
|
||||
</ScrollView>
|
||||
</KeyboardAvoidingView>
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
@ -1,35 +0,0 @@
|
||||
import { StatusBar } from 'expo-status-bar';
|
||||
import { Platform, StyleSheet } from 'react-native';
|
||||
|
||||
import EditScreenInfo from '@/components/EditScreenInfo';
|
||||
import { Text, View } from '@/components/Themed';
|
||||
|
||||
export default function ModalScreen() {
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<Text style={styles.title}>Modal</Text>
|
||||
<View style={styles.separator} lightColor="#eee" darkColor="rgba(255,255,255,0.1)" />
|
||||
<EditScreenInfo path="app/modal.tsx" />
|
||||
|
||||
{/* Use a light status bar on iOS to account for the black space above the modal */}
|
||||
<StatusBar style={Platform.OS === 'ios' ? 'light' : 'auto'} />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
title: {
|
||||
fontSize: 20,
|
||||
fontWeight: 'bold',
|
||||
},
|
||||
separator: {
|
||||
marginVertical: 30,
|
||||
height: 1,
|
||||
width: '80%',
|
||||
},
|
||||
});
|
||||
|
Before Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 1.4 KiB |
|
Before Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 17 KiB |
@ -1,77 +0,0 @@
|
||||
import React from 'react';
|
||||
import { StyleSheet } from 'react-native';
|
||||
|
||||
import { ExternalLink } from './ExternalLink';
|
||||
import { MonoText } from './StyledText';
|
||||
import { Text, View } from './Themed';
|
||||
|
||||
import Colors from '@/constants/Colors';
|
||||
|
||||
export default function EditScreenInfo({ path }: { path: string }) {
|
||||
return (
|
||||
<View>
|
||||
<View style={styles.getStartedContainer}>
|
||||
<Text
|
||||
style={styles.getStartedText}
|
||||
lightColor="rgba(0,0,0,0.8)"
|
||||
darkColor="rgba(255,255,255,0.8)">
|
||||
Open up the code for this screen:
|
||||
</Text>
|
||||
|
||||
<View
|
||||
style={[styles.codeHighlightContainer, styles.homeScreenFilename]}
|
||||
darkColor="rgba(255,255,255,0.05)"
|
||||
lightColor="rgba(0,0,0,0.05)">
|
||||
<MonoText>{path}</MonoText>
|
||||
</View>
|
||||
|
||||
<Text
|
||||
style={styles.getStartedText}
|
||||
lightColor="rgba(0,0,0,0.8)"
|
||||
darkColor="rgba(255,255,255,0.8)">
|
||||
Change any of the text, save the file, and your app will automatically update.
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<View style={styles.helpContainer}>
|
||||
<ExternalLink
|
||||
style={styles.helpLink}
|
||||
href="https://docs.expo.io/get-started/create-a-new-app/#opening-the-app-on-your-phonetablet">
|
||||
<Text style={styles.helpLinkText} lightColor={Colors.light.tint}>
|
||||
Tap here if your app doesn't automatically update after making changes
|
||||
</Text>
|
||||
</ExternalLink>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
getStartedContainer: {
|
||||
alignItems: 'center',
|
||||
marginHorizontal: 50,
|
||||
},
|
||||
homeScreenFilename: {
|
||||
marginVertical: 7,
|
||||
},
|
||||
codeHighlightContainer: {
|
||||
borderRadius: 3,
|
||||
paddingHorizontal: 4,
|
||||
},
|
||||
getStartedText: {
|
||||
fontSize: 17,
|
||||
lineHeight: 24,
|
||||
textAlign: 'center',
|
||||
},
|
||||
helpContainer: {
|
||||
marginTop: 15,
|
||||
marginHorizontal: 20,
|
||||
alignItems: 'center',
|
||||
},
|
||||
helpLink: {
|
||||
paddingVertical: 15,
|
||||
},
|
||||
helpLinkText: {
|
||||
textAlign: 'center',
|
||||
},
|
||||
});
|
||||
@ -1,24 +0,0 @@
|
||||
import { Link } from 'expo-router';
|
||||
import * as WebBrowser from 'expo-web-browser';
|
||||
import React from 'react';
|
||||
import { Platform } from 'react-native';
|
||||
|
||||
export function ExternalLink(
|
||||
props: Omit<React.ComponentProps<typeof Link>, 'href'> & { href: string }
|
||||
) {
|
||||
return (
|
||||
<Link
|
||||
target="_blank"
|
||||
{...props}
|
||||
href={props.href}
|
||||
onPress={(e) => {
|
||||
if (Platform.OS !== 'web') {
|
||||
// Prevent the default behavior of linking to the default browser on native.
|
||||
e.preventDefault();
|
||||
// Open the link in an in-app browser.
|
||||
WebBrowser.openBrowserAsync(props.href as string);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@ -1,5 +0,0 @@
|
||||
import { Text, TextProps } from './Themed';
|
||||
|
||||
export function MonoText(props: TextProps) {
|
||||
return <Text {...props} style={[props.style, { fontFamily: 'SpaceMono' }]} />;
|
||||
}
|
||||
@ -1,45 +0,0 @@
|
||||
/**
|
||||
* Learn more about Light and Dark modes:
|
||||
* https://docs.expo.io/guides/color-schemes/
|
||||
*/
|
||||
|
||||
import { Text as DefaultText, View as DefaultView } from 'react-native';
|
||||
|
||||
import Colors from '@/constants/Colors';
|
||||
import { useColorScheme } from './useColorScheme';
|
||||
|
||||
type ThemeProps = {
|
||||
lightColor?: string;
|
||||
darkColor?: string;
|
||||
};
|
||||
|
||||
export type TextProps = ThemeProps & DefaultText['props'];
|
||||
export type ViewProps = ThemeProps & DefaultView['props'];
|
||||
|
||||
export function useThemeColor(
|
||||
props: { light?: string; dark?: string },
|
||||
colorName: keyof typeof Colors.light & keyof typeof Colors.dark
|
||||
) {
|
||||
const theme = useColorScheme() ?? 'light';
|
||||
const colorFromProps = props[theme];
|
||||
|
||||
if (colorFromProps) {
|
||||
return colorFromProps;
|
||||
} else {
|
||||
return Colors[theme][colorName];
|
||||
}
|
||||
}
|
||||
|
||||
export function Text(props: TextProps) {
|
||||
const { style, lightColor, darkColor, ...otherProps } = props;
|
||||
const color = useThemeColor({ light: lightColor, dark: darkColor }, 'text');
|
||||
|
||||
return <DefaultText style={[{ color }, style]} {...otherProps} />;
|
||||
}
|
||||
|
||||
export function View(props: ViewProps) {
|
||||
const { style, lightColor, darkColor, ...otherProps } = props;
|
||||
const backgroundColor = useThemeColor({ light: lightColor, dark: darkColor }, 'background');
|
||||
|
||||
return <DefaultView style={[{ backgroundColor }, style]} {...otherProps} />;
|
||||
}
|
||||