Compare commits
5 Commits
7b3e50ff29
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 360522f30a | |||
| 2b8741de08 | |||
| 432f8ce176 | |||
| 695d4e76d0 | |||
| 7f94f83940 |
147
.cursorrules
@ -1,97 +1,66 @@
|
|||||||
# Contexte projet — Application Marchand de Biens
|
# Application Marchand de Biens — Contexte Cursor
|
||||||
|
|
||||||
## Qui utilise cette app
|
## Infrastructure
|
||||||
Marchand de biens professionnel en France. L'app est utilisée au quotidien sur mobile (iOS/Android) et navigateur web. Elle remplace un ensemble d'outils épars (notes, tableurs, contacts téléphoniques, etc.).
|
- Backend : PocketBase dans Docker (docker/docker-compose.dev.yml)
|
||||||
|
- Binaire PocketBase : /usr/local/bin/pocketbase
|
||||||
|
- Données : /pb_data (flag --dir=/pb_data dans docker-compose)
|
||||||
|
- OS dev : Windows Git Bash → toujours utiliser MSYS_NO_PATHCONV=1 pour docker exec
|
||||||
|
- PocketBase version : v0.23+
|
||||||
|
- URL locale : http://localhost:8090
|
||||||
|
- URL prod : https://SOUS_DOMAINE.duckdns.org (NAS Synology)
|
||||||
|
|
||||||
## Stack technique
|
## Stack technique
|
||||||
- **Frontend** : React Native avec Expo (SDK 51+)
|
- Frontend : React Native avec Expo SDK 51 + Expo Router
|
||||||
- **Navigation** : Expo Router (file-based routing)
|
- SDK PocketBase : npm package "pocketbase"
|
||||||
- **Base de données** : Supabase (PostgreSQL)
|
- UI : NativeWind (Tailwind pour React Native)
|
||||||
- **Auth** : Supabase Auth (email/password)
|
- State : Zustand + React Query (TanStack)
|
||||||
- **Stockage fichiers** : Supabase Storage (photos, PDFs)
|
- IA : API Anthropic Claude via PocketBase Hook (jamais côté client)
|
||||||
- **IA** : API Anthropic Claude (claude-sonnet-4-20250514)
|
- Déploiement mobile : Expo EAS
|
||||||
- **UI** : NativeWind (Tailwind pour React Native) + React Native Paper pour les composants complexes
|
|
||||||
- **State** : Zustand pour le state global, React Query (TanStack) pour le cache serveur
|
|
||||||
- **Déploiement mobile** : Expo EAS
|
|
||||||
- **Déploiement web** : Vercel
|
|
||||||
|
|
||||||
## Conventions de code
|
## Client PocketBase — pattern obligatoire
|
||||||
- TypeScript strict partout, jamais de `any`
|
// /services/pocketbase.ts — singleton, importer partout
|
||||||
- Noms de fichiers : kebab-case pour les fichiers, PascalCase pour les composants
|
import PocketBase from 'pocketbase';
|
||||||
- Toujours utiliser des hooks personnalisés pour la logique métier (ex: `useBiens`, `useContacts`)
|
export const pb = new PocketBase(process.env.EXPO_PUBLIC_PB_URL);
|
||||||
- Les appels Supabase se font UNIQUEMENT dans les hooks, jamais dans les composants
|
|
||||||
- Les types TypeScript sont définis dans `/types/database.ts` (généré depuis Supabase)
|
|
||||||
- Les constantes métier sont dans `/constants/metier.ts`
|
|
||||||
- Toujours gérer les états de chargement et d'erreur
|
|
||||||
- Commentaires en français pour la logique métier, anglais pour le code technique
|
|
||||||
|
|
||||||
## Vocabulaire métier (utiliser ces termes précis)
|
## Collections PocketBase (toutes créées via migration)
|
||||||
- **Bien** : propriété immobilière prospectée ou acquise
|
etapes_pipeline, contacts, biens, analyses_financieres,
|
||||||
- **Piste** : bien en cours d'analyse, pas encore d'offre
|
visites, taches, notes_biens, documents_biens, devis_travaux,
|
||||||
- **Dossier** : bien avec offre en cours ou acte signé
|
analyses_secteur, notes_prospection, grille_prix,
|
||||||
- **Fiche bien** : écran de détail d'un bien
|
recherches_sauvegardees, alertes_recherche, annonces_veille, flux_sources,
|
||||||
- **Compromis** : avant-contrat de vente (SPC)
|
transactions_secteur, courriers_immobilier
|
||||||
- **Acte** : acte authentique de vente chez notaire
|
|
||||||
- **Portage** : période entre achat et revente (coût = intérêts + taxes)
|
|
||||||
- **Marge brute** : prix revente - prix achat - travaux - frais notaire achat
|
|
||||||
- **Marge nette** : marge brute - frais de portage - frais d'agence vente - impôts
|
|
||||||
- **DPE** : Diagnostic de Performance Énergétique
|
|
||||||
- **Surface habitable** : surface loi Carrez pour appartements
|
|
||||||
- **Marchand de biens** = le user, l'utilisateur de cette app
|
|
||||||
|
|
||||||
## Modules de l'application
|
## Règles de code
|
||||||
1. **Prospection** : pipeline Kanban des biens (piste → analyse → offre → compromis → acte → revente)
|
- TypeScript strict, jamais de any
|
||||||
2. **Annuaire** : contacts métier (notaires, artisans, banquiers, agents immo)
|
- Appels PocketBase UNIQUEMENT dans les hooks (/hooks/)
|
||||||
3. **Fiches biens** : dossier complet par bien (photos, docs, historique)
|
- Jamais de nouvelle instance PocketBase, toujours importer pb
|
||||||
4. **Calculateur** : analyse de rentabilité financière
|
- Commentaires métier en français, code en anglais
|
||||||
5. **Visites** : compte-rendus de visites avec check-list
|
- Gérer loading + erreur partout
|
||||||
6. **Travaux** : suivi de chantier et devis
|
|
||||||
7. **Administratif** : documents, délais légaux, alertes
|
|
||||||
8. **Agenda** : tâches et rappels liés aux biens
|
|
||||||
9. **Dashboard** : vue globale et KPIs
|
|
||||||
|
|
||||||
## Structure des dossiers
|
## Vocabulaire métier
|
||||||
```
|
- Bien : propriété immobilière prospectée ou acquise
|
||||||
/app → écrans (Expo Router)
|
- Piste : bien en phase d'analyse
|
||||||
/(tabs) → navigation principale
|
- Portage : période entre achat et revente
|
||||||
/prospection
|
- Marge brute : prix revente - prix achat - travaux - frais notaire
|
||||||
/annuaire
|
- Marge nette : marge brute - portage - frais agence - impôts
|
||||||
/agenda
|
|
||||||
/dashboard
|
## Formules financières
|
||||||
/bien/[id] → fiche bien
|
frais_notaire = prix_achat * 0.075 (ancien) ou * 0.02 (neuf)
|
||||||
/visite/[id] → rapport de visite
|
frais_portage_total = (prix_achat * taux_credit/100/12 + taxe_fonciere/12 + charges) * duree_mois
|
||||||
/contact/[id] → fiche contact
|
travaux_total = budget_travaux * (1 + reserve_pct/100)
|
||||||
/components → composants réutilisables
|
prix_revient = prix_achat + frais_notaire + frais_agence_achat + travaux_total + frais_portage_total
|
||||||
/ui → composants génériques (Button, Card, Input...)
|
marge_brute = prix_revente - prix_revient
|
||||||
/biens → composants spécifiques aux biens
|
marge_nette = marge_brute - (prix_revente * frais_agence_vente_pct/100) - (marge_brute * taux_impot/100)
|
||||||
/visites → composants spécifiques aux visites
|
|
||||||
/hooks → hooks personnalisés
|
## Structure dossiers
|
||||||
/services → appels API (Supabase, Anthropic)
|
/docker → docker-compose dev + prod
|
||||||
/supabase.ts → client Supabase
|
/pocketbase
|
||||||
/ai.ts → appels Claude API
|
/pb_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
|
/types → types TypeScript
|
||||||
/constants → constantes et configuration
|
/constants → constantes métier
|
||||||
/utils → fonctions utilitaires
|
|
||||||
```
|
|
||||||
|
|
||||||
## Variables d'environnement nécessaires
|
|
||||||
```
|
|
||||||
EXPO_PUBLIC_SUPABASE_URL=
|
|
||||||
EXPO_PUBLIC_SUPABASE_ANON_KEY=
|
|
||||||
ANTHROPIC_API_KEY= ← côté serveur uniquement, jamais exposé côté client
|
|
||||||
```
|
|
||||||
|
|
||||||
## Règles de sécurité importantes
|
|
||||||
- La clé API Anthropic ne doit JAMAIS être dans le code client
|
|
||||||
- Créer une Supabase Edge Function pour les appels IA
|
|
||||||
- Row Level Security (RLS) activé sur toutes les tables Supabase
|
|
||||||
- Les photos et docs sont dans des buckets Supabase privés
|
|
||||||
|
|
||||||
## Calculs financiers — formules exactes
|
|
||||||
```
|
|
||||||
frais_notaire_achat = prix_achat * 0.075 (ancien) ou * 0.02 (neuf)
|
|
||||||
prix_revient = prix_achat + frais_notaire_achat + travaux + frais_portage
|
|
||||||
marge_brute = prix_revente_cible - prix_revient
|
|
||||||
frais_portage_mensuel = (prix_achat * taux_credit / 12) + taxe_fonciere_mensuelle
|
|
||||||
taux_marge_brute = marge_brute / prix_revente_cible * 100
|
|
||||||
```
|
|
||||||
|
|||||||
12
.env.example
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
# Dev local
|
||||||
|
EXPO_PUBLIC_PB_URL=http://localhost:8090
|
||||||
|
|
||||||
|
# Production NAS (décommenter)
|
||||||
|
# EXPO_PUBLIC_PB_URL=https://VOTRE_SOUS_DOMAINE.duckdns.org
|
||||||
|
|
||||||
|
# Clé IA (jamais dans Git)
|
||||||
|
ANTHROPIC_API_KEY=sk-ant-VOTRE_CLE
|
||||||
|
|
||||||
|
# DuckDNS (production)
|
||||||
|
DUCKDNS_SUBDOMAINS=VOTRE_SOUS_DOMAINE
|
||||||
|
DUCKDNS_TOKEN=VOTRE_TOKEN
|
||||||
43
.gitignore
vendored
@ -1,41 +1,8 @@
|
|||||||
# Learn more https://docs.github.com/en/get-started/getting-started-with-git/ignoring-files
|
pocketbase/pb_data/
|
||||||
|
.env.local
|
||||||
# dependencies
|
.env.production
|
||||||
|
*.env
|
||||||
|
docker/ssl/
|
||||||
node_modules/
|
node_modules/
|
||||||
|
|
||||||
# Expo
|
|
||||||
.expo/
|
.expo/
|
||||||
dist/
|
|
||||||
web-build/
|
|
||||||
expo-env.d.ts
|
|
||||||
|
|
||||||
# Native
|
|
||||||
.kotlin/
|
|
||||||
*.orig.*
|
|
||||||
*.jks
|
|
||||||
*.p8
|
|
||||||
*.p12
|
|
||||||
*.key
|
|
||||||
*.mobileprovision
|
|
||||||
|
|
||||||
# Metro
|
|
||||||
.metro-health-check*
|
|
||||||
|
|
||||||
# debug
|
|
||||||
npm-debug.*
|
|
||||||
yarn-debug.*
|
|
||||||
yarn-error.*
|
|
||||||
|
|
||||||
# macOS
|
|
||||||
.DS_Store
|
.DS_Store
|
||||||
*.pem
|
|
||||||
|
|
||||||
# local env files
|
|
||||||
.env*.local
|
|
||||||
|
|
||||||
# typescript
|
|
||||||
*.tsbuildinfo
|
|
||||||
|
|
||||||
# generated native folders
|
|
||||||
/ios
|
|
||||||
/android
|
|
||||||
|
|||||||
159
AGENTS.md
@ -1,127 +1,50 @@
|
|||||||
# AGENTS — Suivi des sessions Cursor
|
# AGENTS — Suivi du projet
|
||||||
|
|
||||||
> Ce fichier est lu au début de chaque session Cursor pour reprendre le contexte.
|
## État actuel
|
||||||
> Mets à jour la section "État" après chaque session.
|
- [x] Docker + PocketBase configuré et lancé
|
||||||
|
- [x] Migration collections (fichiers `pb_migrations`) — à appliquer au démarrage serveur si besoin
|
||||||
|
- [x] App Expo initialisée
|
||||||
|
- [x] Auth fonctionnelle
|
||||||
|
- [x] Navigation complète
|
||||||
|
- [x] Module Prospection (pipeline / biens)
|
||||||
|
- [x] Module Fiche bien + Calculateur
|
||||||
|
- [x] Module Contacts (liste par catégorie, recherche, fiche + biens liés)
|
||||||
|
- [x] Module Visites + IA (`pb_hooks/generate_rapport.pb.js`, route `POST /api/mdb/generate-rapport`)
|
||||||
|
- [x] Module Agenda (tâches, snooze, création modal)
|
||||||
|
- [x] Dashboard (alertes, KPIs, pipeline, derniers biens, tâches du jour)
|
||||||
|
- [x] Module Recherche & Analyse marché (onglet Recherche : Secteur / Opportunités / Grille de prix + fiche bien)
|
||||||
|
- [x] Multi-agents MVP (migration `1760000000`, hooks `agents_veille.pb.js`, onglet **Veille & agents** : recherches, alertes, annonces veille, transactions secteur, courriers + routes `/api/mdb/agent-*`)
|
||||||
|
|
||||||
---
|
## Roadmap — Agrégation type MoteurImmo & agents IA
|
||||||
|
|
||||||
## État global du projet
|
Référence produit : [moteurimmo.fr](https://moteurimmo.fr/) (agrégation multi-portails, alertes, DVF/transactions, API pro).
|
||||||
|
|
||||||
- [ ] Agent 0 — Setup initial (Expo + Supabase)
|
**Écart actuel** : pas d’ingestion de flux externes ni de moteur d’alertes ; la grille / secteur restent des données **saisies ou locales** (PocketBase), pas une veille marché temps réel.
|
||||||
- [ ] 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)
|
|
||||||
|
|
||||||
---
|
### Personas agents (rôles métier + briques techniques)
|
||||||
|
|
||||||
## Agent 0 — Setup initial
|
| Agent | Mission | Briques typiques |
|
||||||
**Statut** : ⬜ Non démarré
|
|-------|---------|------------------|
|
||||||
**Objectif** : Projet Expo fonctionnel connecté à Supabase avec auth
|
| **Immobilier** | Off-market, diffusion agence (priorité site maison), prospection pour alimenter le pipe | Collections opportunités / contacts / tâches ; hooks ou jobs pour brouillons de contenu ; pas de scraping illégal — privilégier saisie, imports CSV, API partenaires. |
|
||||||
**Livrable** : App qui se lance, login qui fonctionne, navigation de base
|
| **Marchand de biens** | Prix secteur, €/m², repérage bonnes offres | Grille perso + DVF / transactions (open data) ; scoring simple ; alertes sur critères (prix/m², surface, zone). |
|
||||||
**Dernière session** : —
|
| **Data / DVF** | Normaliser transactions publiques, relier zone ↔ bien | Import DVF (fichiers ou API tiers), tables dérivées, carto plus tard. |
|
||||||
**Problèmes rencontrés** : —
|
| **Veille annonces** | Agréger sources autorisées (API, flux partenaires, [API MoteurImmo](https://moteurimmo.fr/) si abonnement) | Collections `sources_flux`, `annonces_brutes`, `alertes_recherche` ; cron PocketBase ou worker externe ; dédoublonnage. |
|
||||||
|
| **Alertes & notif** | Push / email quand une annonce ou une transac matche une recherche sauvegardée | Règles métier + Expo notifications ; file d’événements côté PB. |
|
||||||
|
| **Rédaction / CRM** | Textes vitrine, relances, synthèses pour prospection | Réutiliser le pattern hook IA (`generate_rapport`) par type de prompt. |
|
||||||
|
|
||||||
---
|
### Phases suggérées
|
||||||
|
|
||||||
## Agent 1 — Base de données
|
1. **Modèle de données** : recherches sauvegardées, alertes, log d’ingestion (sans agrégateur massif au début).
|
||||||
**Statut** : ⬜ Non démarré
|
2. **Données publiques** : DVF ou extrait local par zone (preuve de valeur pour €/m² réel).
|
||||||
**Objectif** : Toutes les tables SQL créées dans Supabase avec RLS
|
3. **Une source API fiable** (partenaire ou open data) avant tout volume type MoteurImmo.
|
||||||
**Livrable** : schema.sql exécuté, types TypeScript générés
|
4. **UI** : liste annonces unifiée + filtres + onglet alertes dans Recherche.
|
||||||
**Dernière session** : —
|
|
||||||
**Problèmes rencontrés** : —
|
|
||||||
|
|
||||||
---
|
## Infos techniques
|
||||||
|
- PocketBase : http://localhost:8090
|
||||||
## Agent 2 — Navigation
|
- Admin : http://localhost:8090/_/ (admin@mdb.fr)
|
||||||
**Statut** : ⬜ Non démarré
|
- Binaire : /usr/local/bin/pocketbase
|
||||||
**Objectif** : Structure de navigation complète avec tous les onglets
|
- Données : /pb_data
|
||||||
**Livrable** : Tous les écrans existent (même vides), navigation fonctionnelle
|
- Hooks JS : volume `pb_hooks` monté sur `/pb_hooks` ; **docker-compose.dev** : `command: --dir=/pb_data --hooksDir=/pb_hooks` pour charger les routes `/api/mdb/*`
|
||||||
**Dernière session** : —
|
- Variable IA : `ANTHROPIC_API_KEY` dans `.env.local` (chargée par Docker pour PocketBase)
|
||||||
**Problèmes rencontrés** : —
|
- OS : Windows Git Bash (MSYS_NO_PATHCONV=1)
|
||||||
|
- PocketBase : v0.23+
|
||||||
---
|
|
||||||
|
|
||||||
## Agent 3 — Prospection
|
|
||||||
**Statut** : ⬜ Non démarré
|
|
||||||
**Objectif** : Pipeline Kanban des biens
|
|
||||||
**Livrable** : Vue Kanban, création d'un bien, déplacement entre étapes
|
|
||||||
**Tables utilisées** : `biens`, `etapes_pipeline`
|
|
||||||
**Dernière session** : —
|
|
||||||
**Problèmes rencontrés** : —
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Agent 4 — Fiche bien
|
|
||||||
**Statut** : ⬜ Non démarré
|
|
||||||
**Objectif** : Écran détail complet d'un bien
|
|
||||||
**Livrable** : Fiche avec infos, photos, documents, historique
|
|
||||||
**Tables utilisées** : `biens`, `photos_biens`, `documents_biens`, `notes_biens`
|
|
||||||
**Dernière session** : —
|
|
||||||
**Problèmes rencontrés** : —
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Agent 5 — Calculateur
|
|
||||||
**Statut** : ⬜ Non démarré
|
|
||||||
**Objectif** : Calculateur de rentabilité intégré dans la fiche bien
|
|
||||||
**Livrable** : Formulaire avec calculs en temps réel, sauvegarde dans Supabase
|
|
||||||
**Tables utilisées** : `analyses_financieres`
|
|
||||||
**Dernière session** : —
|
|
||||||
**Problèmes rencontrés** : —
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Agent 6 — Annuaire
|
|
||||||
**Statut** : ⬜ Non démarré
|
|
||||||
**Objectif** : Annuaire de contacts professionnels
|
|
||||||
**Livrable** : Liste, recherche, fiche contact, appel natif
|
|
||||||
**Tables utilisées** : `contacts`, `categories_contacts`
|
|
||||||
**Dernière session** : —
|
|
||||||
**Problèmes rencontrés** : —
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Agent 7 — Visites
|
|
||||||
**Statut** : ⬜ Non démarré
|
|
||||||
**Objectif** : Module de visite avec CR généré par IA
|
|
||||||
**Livrable** : Check-list de visite, notes, génération CR via Claude API
|
|
||||||
**Tables utilisées** : `visites`, `items_checklist`, `rapports_visite`
|
|
||||||
**Edge Functions** : `generate-rapport-visite`
|
|
||||||
**Dernière session** : —
|
|
||||||
**Problèmes rencontrés** : —
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Agent 8 — Agenda
|
|
||||||
**Statut** : ⬜ Non démarré
|
|
||||||
**Objectif** : Tâches et rappels liés aux biens
|
|
||||||
**Livrable** : Vue agenda, création tâches, notifications
|
|
||||||
**Tables utilisées** : `taches`, `rappels`
|
|
||||||
**Dernière session** : —
|
|
||||||
**Problèmes rencontrés** : —
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Agent 9 — Dashboard
|
|
||||||
**Statut** : ⬜ Non démarré
|
|
||||||
**Objectif** : Vue d'ensemble et KPIs
|
|
||||||
**Livrable** : Chiffres clés, biens en cours, alertes urgentes
|
|
||||||
**Tables utilisées** : Toutes (requêtes agrégées)
|
|
||||||
**Dernière session** : —
|
|
||||||
**Problèmes rencontrés** : —
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Notes techniques globales
|
|
||||||
_(Ajouter ici les décisions d'architecture prises en cours de projet)_
|
|
||||||
|
|
||||||
-
|
|
||||||
|
|||||||
193
GUIDE_COMPLET.md
Normal file
@ -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": {
|
"expo": {
|
||||||
"name": "mb-app",
|
"name": "mdb",
|
||||||
"slug": "mb-app",
|
"slug": "mdb",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"orientation": "portrait",
|
"orientation": "portrait",
|
||||||
"icon": "./assets/images/icon.png",
|
"icon": "./assets/images/icon.png",
|
||||||
"scheme": "mbapp",
|
"scheme": "mdb",
|
||||||
"userInterfaceStyle": "automatic",
|
"userInterfaceStyle": "automatic",
|
||||||
"newArchEnabled": true,
|
"newArchEnabled": true,
|
||||||
"splash": {
|
"splash": {
|
||||||
"image": "./assets/images/splash-icon.png",
|
"image": "./assets/images/splash-icon.png",
|
||||||
"resizeMode": "contain",
|
"resizeMode": "contain",
|
||||||
"backgroundColor": "#ffffff"
|
"backgroundColor": "#f8fafc"
|
||||||
},
|
|
||||||
"ios": {
|
|
||||||
"supportsTablet": true
|
|
||||||
},
|
},
|
||||||
|
"ios": { "supportsTablet": true },
|
||||||
"android": {
|
"android": {
|
||||||
"adaptiveIcon": {
|
"adaptiveIcon": {
|
||||||
"foregroundImage": "./assets/images/adaptive-icon.png",
|
"foregroundImage": "./assets/images/adaptive-icon.png",
|
||||||
"backgroundColor": "#ffffff"
|
"backgroundColor": "#f8fafc"
|
||||||
},
|
}
|
||||||
"edgeToEdgeEnabled": true,
|
|
||||||
"predictiveBackGestureEnabled": false
|
|
||||||
},
|
},
|
||||||
"web": {
|
"web": {
|
||||||
"bundler": "metro",
|
"bundler": "metro",
|
||||||
"output": "static",
|
"output": "static",
|
||||||
"favicon": "./assets/images/favicon.png"
|
"favicon": "./assets/images/favicon.png"
|
||||||
},
|
},
|
||||||
"plugins": [
|
"plugins": ["expo-router"],
|
||||||
"expo-router",
|
"experiments": { "typedRoutes": true }
|
||||||
[
|
|
||||||
"expo-notifications",
|
|
||||||
{
|
|
||||||
"color": "#1D4ED8"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
],
|
|
||||||
"experiments": {
|
|
||||||
"typedRoutes": true
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
57
app/app/(tabs)/_layout.tsx
Normal file
@ -0,0 +1,57 @@
|
|||||||
|
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.Screen
|
||||||
|
name="recherche"
|
||||||
|
options={{
|
||||||
|
title: 'Recherche',
|
||||||
|
tabBarIcon: ({ color, size }) => <Ionicons name="search-outline" size={size} color={color} />,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Tabs>
|
||||||
|
);
|
||||||
|
}
|
||||||
322
app/app/(tabs)/agenda.tsx
Normal file
@ -0,0 +1,322 @@
|
|||||||
|
import { useMemo, useState } from 'react';
|
||||||
|
import { Stack } from 'expo-router';
|
||||||
|
import {
|
||||||
|
ActivityIndicator,
|
||||||
|
Alert,
|
||||||
|
Modal,
|
||||||
|
Pressable,
|
||||||
|
ScrollView,
|
||||||
|
SectionList,
|
||||||
|
Text,
|
||||||
|
TextInput,
|
||||||
|
View,
|
||||||
|
} from 'react-native';
|
||||||
|
|
||||||
|
import { EmptyState } from '@/components/ui/EmptyState';
|
||||||
|
import { ListSkeleton } from '@/components/ui/ListSkeleton';
|
||||||
|
import { UI } from '@/constants/uiTheme';
|
||||||
|
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),
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const emptyList =
|
||||||
|
!isLoading && sections.length === 0 ? (
|
||||||
|
<EmptyState
|
||||||
|
title="Agenda vide"
|
||||||
|
description="Ajoute des rappels (relances, visites, banque) pour piloter ta semaine."
|
||||||
|
actionLabel="+ Nouvelle tâche"
|
||||||
|
onAction={openCreate}
|
||||||
|
/>
|
||||||
|
) : null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Stack.Screen options={{ title: 'Agenda', headerShown: true }} />
|
||||||
|
<View className="flex-1" style={{ backgroundColor: UI.screen }}>
|
||||||
|
{error ? (
|
||||||
|
<View
|
||||||
|
className="mx-3 mt-3 rounded-2xl border-2 px-4 py-3"
|
||||||
|
style={{ borderColor: UI.danger, backgroundColor: '#FEE2E2' }}
|
||||||
|
>
|
||||||
|
<Text className="text-base font-medium" style={{ color: '#991B1B' }}>
|
||||||
|
{formatPocketBaseError(error)}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
) : null}
|
||||||
|
{isLoading ? (
|
||||||
|
<ListSkeleton rows={6} />
|
||||||
|
) : (
|
||||||
|
<SectionList
|
||||||
|
sections={sections}
|
||||||
|
keyExtractor={(item) => item.id}
|
||||||
|
contentContainerStyle={
|
||||||
|
sections.length === 0 ? { flexGrow: 1, padding: 12, paddingBottom: 112 } : { padding: 12, paddingBottom: 112 }
|
||||||
|
}
|
||||||
|
renderSectionHeader={({ section }) => (
|
||||||
|
<Text
|
||||||
|
className="pb-2 pt-4 text-sm font-bold uppercase tracking-wide"
|
||||||
|
style={{ color: section.tint === 'red' ? UI.danger : UI.textMuted }}
|
||||||
|
>
|
||||||
|
{section.title}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
renderItem={({ item: t }) => {
|
||||||
|
const done = t.statut === 'fait';
|
||||||
|
const badge = bienLabel(t);
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
className="mb-3 rounded-2xl border-2 bg-white p-4"
|
||||||
|
style={{ borderColor: UI.border }}
|
||||||
|
>
|
||||||
|
<View className="flex-row items-start gap-4">
|
||||||
|
<Pressable
|
||||||
|
accessibilityRole="checkbox"
|
||||||
|
accessibilityState={{ checked: done }}
|
||||||
|
onPress={() => void toggleDone(t)}
|
||||||
|
className="mt-1 h-12 w-12 items-center justify-center rounded-xl border-2 active:opacity-90"
|
||||||
|
style={{
|
||||||
|
borderColor: done ? UI.success : UI.border,
|
||||||
|
backgroundColor: done ? UI.success : UI.card,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{done ? (
|
||||||
|
<Text className="text-xl font-bold text-white">✓</Text>
|
||||||
|
) : null}
|
||||||
|
</Pressable>
|
||||||
|
<View className="min-w-0 flex-1">
|
||||||
|
<Text
|
||||||
|
className={`text-lg font-bold leading-6 ${done ? 'text-slate-400 line-through' : ''}`}
|
||||||
|
style={!done ? { color: UI.text } : undefined}
|
||||||
|
>
|
||||||
|
{t.titre}
|
||||||
|
</Text>
|
||||||
|
{badge ? (
|
||||||
|
<Text className="mt-2 text-base font-semibold" style={{ color: UI.primary }}>
|
||||||
|
{badge}
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
{t.date_echeance ? (
|
||||||
|
<Text className="mt-1 text-base" style={{ color: UI.textMuted }}>
|
||||||
|
{t.date_echeance}
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
<View className="mt-4 flex-row flex-wrap gap-3">
|
||||||
|
<Pressable
|
||||||
|
accessibilityRole="button"
|
||||||
|
onPress={() => void snoozeOneDay(t)}
|
||||||
|
className="min-h-[52px] min-w-[140px] flex-1 items-center justify-center rounded-2xl border-2 px-4 active:opacity-90"
|
||||||
|
style={{ borderColor: UI.warning, backgroundColor: '#FFFBEB' }}
|
||||||
|
>
|
||||||
|
<Text className="text-lg font-bold" style={{ color: '#B45309' }}>
|
||||||
|
+1 jour
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
<Pressable
|
||||||
|
accessibilityRole="button"
|
||||||
|
onPress={() => confirmDelete(t)}
|
||||||
|
className="min-h-[52px] min-w-[120px] flex-1 items-center justify-center rounded-2xl border-2 px-4 active:opacity-90"
|
||||||
|
style={{ borderColor: UI.danger, backgroundColor: '#FEF2F2' }}
|
||||||
|
>
|
||||||
|
<Text className="text-lg font-bold" style={{ color: UI.danger }}>
|
||||||
|
Supprimer
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
ListEmptyComponent={emptyList}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Pressable
|
||||||
|
accessibilityRole="button"
|
||||||
|
accessibilityLabel="Nouvelle tâche"
|
||||||
|
onPress={openCreate}
|
||||||
|
className="absolute bottom-6 right-5 min-h-[56px] justify-center rounded-2xl px-6 py-4 active:opacity-90"
|
||||||
|
style={{ backgroundColor: UI.primary, elevation: 8 }}
|
||||||
|
>
|
||||||
|
<Text className="text-lg font-bold text-white">+ Tâche</Text>
|
||||||
|
</Pressable>
|
||||||
|
|
||||||
|
<Modal visible={modalOpen} animationType="slide" transparent>
|
||||||
|
<View className="flex-1 justify-end bg-black/50">
|
||||||
|
<View className="max-h-[88%] rounded-t-3xl bg-white p-5">
|
||||||
|
<Text className="text-2xl font-bold" style={{ color: UI.text }}>
|
||||||
|
Nouvelle tâche
|
||||||
|
</Text>
|
||||||
|
<Text className="mt-4 text-base font-semibold" style={{ color: UI.textMuted }}>
|
||||||
|
Titre
|
||||||
|
</Text>
|
||||||
|
<TextInput
|
||||||
|
className="mt-2 rounded-2xl border-2 px-4 text-lg"
|
||||||
|
style={{
|
||||||
|
borderColor: UI.border,
|
||||||
|
color: UI.text,
|
||||||
|
minHeight: 52,
|
||||||
|
backgroundColor: UI.card,
|
||||||
|
}}
|
||||||
|
value={newTitre}
|
||||||
|
onChangeText={setNewTitre}
|
||||||
|
placeholder="Appeler le notaire…"
|
||||||
|
placeholderTextColor={UI.textMuted}
|
||||||
|
/>
|
||||||
|
<Text className="mt-4 text-base font-semibold" style={{ color: UI.textMuted }}>
|
||||||
|
Échéance (AAAA-MM-JJ)
|
||||||
|
</Text>
|
||||||
|
<TextInput
|
||||||
|
className="mt-2 rounded-2xl border-2 px-4 text-lg"
|
||||||
|
style={{
|
||||||
|
borderColor: UI.border,
|
||||||
|
color: UI.text,
|
||||||
|
minHeight: 52,
|
||||||
|
backgroundColor: UI.card,
|
||||||
|
}}
|
||||||
|
value={newDate}
|
||||||
|
onChangeText={setNewDate}
|
||||||
|
placeholder="2026-04-29"
|
||||||
|
placeholderTextColor={UI.textMuted}
|
||||||
|
/>
|
||||||
|
<Text className="mt-4 text-base font-semibold" style={{ color: UI.textMuted }}>
|
||||||
|
Bien (optionnel)
|
||||||
|
</Text>
|
||||||
|
<ScrollView horizontal className="mt-2 max-h-28" keyboardShouldPersistTaps="handled">
|
||||||
|
<Pressable
|
||||||
|
onPress={() => setNewBienId(undefined)}
|
||||||
|
className={`mr-2 min-h-[48px] justify-center rounded-2xl px-4 ${newBienId == null ? '' : 'border-2'}`}
|
||||||
|
style={
|
||||||
|
newBienId == null
|
||||||
|
? { backgroundColor: UI.primary }
|
||||||
|
: { borderColor: UI.border, backgroundColor: UI.screen }
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
className={`text-base font-bold ${newBienId == null ? 'text-white' : ''}`}
|
||||||
|
style={newBienId == null ? undefined : { color: UI.text }}
|
||||||
|
>
|
||||||
|
Aucun
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
{biens.map((b) => (
|
||||||
|
<Pressable
|
||||||
|
key={b.id}
|
||||||
|
onPress={() => setNewBienId(b.id)}
|
||||||
|
className="mr-2 max-w-[220px] min-h-[48px] justify-center rounded-2xl border-2 px-4"
|
||||||
|
style={{
|
||||||
|
borderColor: newBienId === b.id ? UI.primary : UI.border,
|
||||||
|
backgroundColor: newBienId === b.id ? '#EFF6FF' : UI.card,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text numberOfLines={1} className="text-base font-bold" style={{ color: UI.text }}>
|
||||||
|
{b.titre?.trim() || b.ville || b.id}
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
))}
|
||||||
|
</ScrollView>
|
||||||
|
<View className="mt-8 flex-row gap-3">
|
||||||
|
<Pressable
|
||||||
|
onPress={() => setModalOpen(false)}
|
||||||
|
className="min-h-[56px] flex-1 items-center justify-center rounded-2xl border-2 active:opacity-90"
|
||||||
|
style={{ borderColor: UI.border }}
|
||||||
|
>
|
||||||
|
<Text className="text-lg font-bold" style={{ color: UI.text }}>
|
||||||
|
Annuler
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
<Pressable
|
||||||
|
onPress={() => void submitCreate()}
|
||||||
|
disabled={isCreatePending}
|
||||||
|
className="min-h-[56px] flex-1 items-center justify-center rounded-2xl active:opacity-90"
|
||||||
|
style={{ backgroundColor: UI.primary }}
|
||||||
|
>
|
||||||
|
{isCreatePending ? (
|
||||||
|
<ActivityIndicator color="#fff" />
|
||||||
|
) : (
|
||||||
|
<Text className="text-lg font-bold text-white">Créer</Text>
|
||||||
|
)}
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</Modal>
|
||||||
|
</View>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
192
app/app/(tabs)/biens.tsx
Normal file
@ -0,0 +1,192 @@
|
|||||||
|
import { Link, Stack } from 'expo-router';
|
||||||
|
import { useEffect, useMemo, useRef } from 'react';
|
||||||
|
import { Pressable, ScrollView, Text, View } from 'react-native';
|
||||||
|
|
||||||
|
import { EmptyState } from '@/components/ui/EmptyState';
|
||||||
|
import { PipelineSkeleton } from '@/components/ui/PipelineSkeleton';
|
||||||
|
import { UI } from '@/constants/uiTheme';
|
||||||
|
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;
|
||||||
|
|
||||||
|
const loading = isLoading || etapesLoading;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Stack.Screen options={{ title: 'Biens', headerShown: true }} />
|
||||||
|
<View className="flex-1" style={{ backgroundColor: UI.screen }}>
|
||||||
|
{banner ? (
|
||||||
|
<View
|
||||||
|
className="border-b-2 px-4 py-3"
|
||||||
|
style={{ borderColor: UI.danger, backgroundColor: '#FEE2E2' }}
|
||||||
|
>
|
||||||
|
<Text className="text-base font-medium" style={{ color: '#991B1B' }}>
|
||||||
|
{banner}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
) : null}
|
||||||
|
{loading ? (
|
||||||
|
<PipelineSkeleton />
|
||||||
|
) : biens.length === 0 ? (
|
||||||
|
<View className="flex-1 justify-center px-2">
|
||||||
|
<EmptyState
|
||||||
|
title="Pipeline vide"
|
||||||
|
description="Crée un bien pour suivre tes pistes du premier contact jusqu’à l’acte."
|
||||||
|
actionLabel="Ajouter un bien"
|
||||||
|
actionHref="/bien/nouveau"
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
|
<ScrollView
|
||||||
|
horizontal
|
||||||
|
className="flex-1"
|
||||||
|
contentContainerStyle={{ padding: 12, paddingBottom: 112 }}
|
||||||
|
>
|
||||||
|
{etapes.map((e) => {
|
||||||
|
const list = grouped.m.get(e.id) ?? [];
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
key={e.id}
|
||||||
|
className="mr-3 w-56 rounded-2xl border-2 bg-white p-3"
|
||||||
|
style={{ borderColor: UI.border }}
|
||||||
|
>
|
||||||
|
<View
|
||||||
|
className="mb-3 flex-row items-center justify-between border-b-2 pb-3"
|
||||||
|
style={{ borderColor: '#E2E8F0' }}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
className="flex-1 text-lg font-bold leading-6"
|
||||||
|
style={{ color: UI.text }}
|
||||||
|
numberOfLines={2}
|
||||||
|
>
|
||||||
|
{e.nom}
|
||||||
|
</Text>
|
||||||
|
<View
|
||||||
|
className="ml-2 min-w-[36px] items-center rounded-full px-2 py-1"
|
||||||
|
style={{ backgroundColor: UI.primary }}
|
||||||
|
>
|
||||||
|
<Text className="text-base font-bold text-white">{list.length}</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
<ScrollView nestedScrollEnabled showsVerticalScrollIndicator={false}>
|
||||||
|
{list.map((b) => (
|
||||||
|
<Link key={b.id} href={`/bien/${b.id}`} asChild>
|
||||||
|
<Pressable
|
||||||
|
accessibilityRole="button"
|
||||||
|
className="mb-3 min-h-[72px] justify-center rounded-xl border-2 px-3 py-3 active:opacity-90"
|
||||||
|
style={{ borderColor: '#E2E8F0', backgroundColor: '#F8FAFC' }}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
className="text-base font-bold leading-5"
|
||||||
|
style={{ color: UI.text }}
|
||||||
|
numberOfLines={2}
|
||||||
|
>
|
||||||
|
{b.titre ?? 'Sans titre'}
|
||||||
|
</Text>
|
||||||
|
<Text
|
||||||
|
className="mt-1 text-base"
|
||||||
|
style={{ color: UI.textMuted }}
|
||||||
|
numberOfLines={1}
|
||||||
|
>
|
||||||
|
{[b.code_postal, b.ville].filter(Boolean).join(' ') || '—'}
|
||||||
|
</Text>
|
||||||
|
{prixByBien.has(b.id) ? (
|
||||||
|
<Text className="mt-2 text-base font-bold" style={{ color: UI.success }}>
|
||||||
|
{formatEUR(prixByBien.get(b.id))}
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
</Pressable>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</ScrollView>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
<View
|
||||||
|
className="mr-3 w-56 rounded-2xl border-2 border-dashed p-3"
|
||||||
|
style={{ borderColor: UI.warning, backgroundColor: '#FFFBEB' }}
|
||||||
|
>
|
||||||
|
<Text className="mb-3 text-lg font-bold" style={{ color: UI.text }}>
|
||||||
|
Sans étape
|
||||||
|
</Text>
|
||||||
|
<Text className="mb-3 text-base" style={{ color: UI.textMuted }}>
|
||||||
|
{(grouped.m.get(grouped.none) ?? []).length} bien(s)
|
||||||
|
</Text>
|
||||||
|
<ScrollView nestedScrollEnabled showsVerticalScrollIndicator={false}>
|
||||||
|
{(grouped.m.get(grouped.none) ?? []).map((b) => (
|
||||||
|
<Link key={b.id} href={`/bien/${b.id}`} asChild>
|
||||||
|
<Pressable
|
||||||
|
accessibilityRole="button"
|
||||||
|
className="mb-3 min-h-[72px] justify-center rounded-xl border-2 bg-white px-3 py-3 active:opacity-90"
|
||||||
|
style={{ borderColor: UI.border }}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
className="text-base font-bold leading-5"
|
||||||
|
style={{ color: UI.text }}
|
||||||
|
numberOfLines={2}
|
||||||
|
>
|
||||||
|
{b.titre ?? 'Sans titre'}
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</ScrollView>
|
||||||
|
</View>
|
||||||
|
</ScrollView>
|
||||||
|
)}
|
||||||
|
<Link href="/bien/nouveau" asChild>
|
||||||
|
<Pressable
|
||||||
|
accessibilityRole="button"
|
||||||
|
accessibilityLabel="Ajouter un bien"
|
||||||
|
className="absolute bottom-6 right-5 h-16 w-16 items-center justify-center rounded-2xl shadow-lg active:opacity-90"
|
||||||
|
style={{ backgroundColor: UI.primary, elevation: 8 }}
|
||||||
|
>
|
||||||
|
<Text className="text-4xl font-light leading-none text-white">+</Text>
|
||||||
|
</Pressable>
|
||||||
|
</Link>
|
||||||
|
</View>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
162
app/app/(tabs)/contacts.tsx
Normal file
@ -0,0 +1,162 @@
|
|||||||
|
import { useMemo, useState } from 'react';
|
||||||
|
import { Link, Stack } from 'expo-router';
|
||||||
|
import { Linking, Pressable, SectionList, Text, TextInput, View } from 'react-native';
|
||||||
|
|
||||||
|
import { EmptyState } from '@/components/ui/EmptyState';
|
||||||
|
import { ListSkeleton } from '@/components/ui/ListSkeleton';
|
||||||
|
import { labelContactCategorie } from '@/constants/contactCategories';
|
||||||
|
import { UI } from '@/constants/uiTheme';
|
||||||
|
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]);
|
||||||
|
|
||||||
|
const listEmpty =
|
||||||
|
!q.isPending && sections.length === 0 ? (
|
||||||
|
search.trim() ? (
|
||||||
|
<EmptyState
|
||||||
|
title="Aucun résultat"
|
||||||
|
description="Essaie un autre mot-clé ou réinitialise la recherche."
|
||||||
|
actionLabel="Effacer la recherche"
|
||||||
|
onAction={() => setSearch('')}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<EmptyState
|
||||||
|
title="Aucun contact"
|
||||||
|
description="Ajoute notaires, artisans et partenaires pour les retrouver vite."
|
||||||
|
actionLabel="Nouveau contact"
|
||||||
|
actionHref="/contact/nouveau"
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
) : null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Stack.Screen options={{ title: 'Contacts', headerShown: true }} />
|
||||||
|
<View className="flex-1" style={{ backgroundColor: UI.screen }}>
|
||||||
|
{q.error ? (
|
||||||
|
<View
|
||||||
|
className="mx-3 mt-3 rounded-2xl border-2 px-4 py-3"
|
||||||
|
style={{ borderColor: UI.danger, backgroundColor: '#FEE2E2' }}
|
||||||
|
>
|
||||||
|
<Text className="text-base font-medium" style={{ color: '#991B1B' }}>
|
||||||
|
{formatPocketBaseError(q.error)}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
) : null}
|
||||||
|
<TextInput
|
||||||
|
className="mx-3 mt-3 rounded-2xl border-2 bg-white px-4 text-lg"
|
||||||
|
style={{
|
||||||
|
borderColor: UI.border,
|
||||||
|
color: UI.text,
|
||||||
|
minHeight: 52,
|
||||||
|
paddingVertical: 12,
|
||||||
|
}}
|
||||||
|
placeholder="Rechercher…"
|
||||||
|
placeholderTextColor={UI.textMuted}
|
||||||
|
value={search}
|
||||||
|
onChangeText={setSearch}
|
||||||
|
accessibilityLabel="Recherche contacts"
|
||||||
|
/>
|
||||||
|
{q.isPending ? (
|
||||||
|
<ListSkeleton rows={7} />
|
||||||
|
) : (
|
||||||
|
<SectionList
|
||||||
|
className="flex-1 px-3 pt-2"
|
||||||
|
sections={sections}
|
||||||
|
keyExtractor={(item) => item.id}
|
||||||
|
contentContainerStyle={
|
||||||
|
sections.length === 0 ? { flexGrow: 1, paddingBottom: 100 } : { paddingBottom: 100 }
|
||||||
|
}
|
||||||
|
renderSectionHeader={({ section: { title } }) => (
|
||||||
|
<Text
|
||||||
|
className="pb-2 pt-4 text-sm font-bold uppercase tracking-wide"
|
||||||
|
style={{ color: UI.textMuted }}
|
||||||
|
>
|
||||||
|
{title}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
renderItem={({ item: c }) => (
|
||||||
|
<View
|
||||||
|
className="mb-3 rounded-2xl border-2 bg-white px-4 py-4"
|
||||||
|
style={{ borderColor: UI.border }}
|
||||||
|
>
|
||||||
|
<Link href={`/contact/${c.id}`} asChild>
|
||||||
|
<Pressable accessibilityRole="button" className="active:opacity-90">
|
||||||
|
<Text className="text-xl font-bold" style={{ color: UI.text }}>
|
||||||
|
{c.prenom ? `${c.prenom} ` : ''}
|
||||||
|
{c.nom}
|
||||||
|
</Text>
|
||||||
|
{c.societe ? (
|
||||||
|
<Text className="mt-1 text-lg" style={{ color: UI.textMuted }}>
|
||||||
|
{c.societe}
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
</Pressable>
|
||||||
|
</Link>
|
||||||
|
{c.telephone ? (
|
||||||
|
<Pressable
|
||||||
|
accessibilityRole="button"
|
||||||
|
onPress={() => openTel(c.telephone)}
|
||||||
|
className="mt-3 min-h-[48px] justify-center self-start rounded-xl px-3 active:opacity-90"
|
||||||
|
style={{ backgroundColor: '#EFF6FF' }}
|
||||||
|
>
|
||||||
|
<Text className="text-lg font-bold" style={{ color: UI.primary }}>
|
||||||
|
{c.telephone}
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
ListEmptyComponent={listEmpty}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<Link href="/contact/nouveau" asChild>
|
||||||
|
<Pressable
|
||||||
|
accessibilityRole="button"
|
||||||
|
accessibilityLabel="Nouveau contact"
|
||||||
|
className="absolute bottom-6 right-5 min-h-[56px] justify-center rounded-2xl px-6 py-4 active:opacity-90"
|
||||||
|
style={{ backgroundColor: UI.primary, elevation: 6 }}
|
||||||
|
>
|
||||||
|
<Text className="text-lg font-bold text-white">+ Contact</Text>
|
||||||
|
</Pressable>
|
||||||
|
</Link>
|
||||||
|
</View>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
333
app/app/(tabs)/index.tsx
Normal file
@ -0,0 +1,333 @@
|
|||||||
|
import { useMemo } from 'react';
|
||||||
|
import { Link, Stack } from 'expo-router';
|
||||||
|
import { Pressable, ScrollView, Text, View } from 'react-native';
|
||||||
|
|
||||||
|
import { EmptyState } from '@/components/ui/EmptyState';
|
||||||
|
import { DashboardSkeleton } from '@/components/ui/DashboardSkeleton';
|
||||||
|
import { TYPES_BIENS } from '@/constants/metier';
|
||||||
|
import { UI } from '@/constants/uiTheme';
|
||||||
|
import { useBiens } from '@/hooks/useBiens';
|
||||||
|
import type { BienExpanded } from '@/hooks/useBiens';
|
||||||
|
import { useEtapes } from '@/hooks/useEtapes';
|
||||||
|
import { useTachesList } from '@/hooks/useTaches';
|
||||||
|
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 p-4"
|
||||||
|
style={{ backgroundColor: UI.screen }}
|
||||||
|
contentContainerStyle={{ paddingBottom: 40 }}
|
||||||
|
>
|
||||||
|
{biensError ? (
|
||||||
|
<View
|
||||||
|
className="mb-3 rounded-2xl border-2 px-4 py-3"
|
||||||
|
style={{ borderColor: UI.danger, backgroundColor: '#FEE2E2' }}
|
||||||
|
>
|
||||||
|
<Text className="text-base font-medium" style={{ color: '#991B1B' }}>
|
||||||
|
{formatPocketBaseError(biensError)}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
) : null}
|
||||||
|
{tachesError ? (
|
||||||
|
<View
|
||||||
|
className="mb-3 rounded-2xl border-2 px-4 py-3"
|
||||||
|
style={{ borderColor: UI.danger, backgroundColor: '#FEE2E2' }}
|
||||||
|
>
|
||||||
|
<Text className="text-base font-medium" style={{ color: '#991B1B' }}>
|
||||||
|
{formatPocketBaseError(tachesError)}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<DashboardSkeleton />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Text className="mb-3 text-2xl font-bold" style={{ color: UI.text }}>
|
||||||
|
Alertes urgentes
|
||||||
|
</Text>
|
||||||
|
{urgent.length === 0 ? (
|
||||||
|
<EmptyState
|
||||||
|
title="Rien d’urgent"
|
||||||
|
description="Les tâches en retard ou marquées urgentes apparaîtront ici."
|
||||||
|
actionLabel="Voir l’agenda"
|
||||||
|
actionHref="/(tabs)/agenda"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<View className="mb-8 gap-3">
|
||||||
|
{urgent.slice(0, 6).map((t) => (
|
||||||
|
<Link key={t.id} href="/(tabs)/agenda" asChild>
|
||||||
|
<Pressable
|
||||||
|
accessibilityRole="button"
|
||||||
|
className="min-h-[56px] justify-center rounded-2xl border-2 px-4 py-3 active:opacity-90"
|
||||||
|
style={{
|
||||||
|
borderColor: UI.danger,
|
||||||
|
backgroundColor: '#FEF2F2',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text className="text-lg font-semibold" style={{ color: '#991B1B' }}>
|
||||||
|
{t.titre}
|
||||||
|
</Text>
|
||||||
|
{t.date_echeance ? (
|
||||||
|
<Text className="mt-1 text-base" style={{ color: UI.danger }}>
|
||||||
|
{t.date_echeance}
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
</Pressable>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Text className="mb-3 text-2xl font-bold" style={{ color: UI.text }}>
|
||||||
|
Indicateurs
|
||||||
|
</Text>
|
||||||
|
<View className="mb-8 flex-row flex-wrap gap-3">
|
||||||
|
<View
|
||||||
|
className="min-w-[108px] flex-1 rounded-2xl border bg-white p-4"
|
||||||
|
style={{ borderColor: UI.border, borderLeftWidth: 5, borderLeftColor: UI.primary }}
|
||||||
|
>
|
||||||
|
<Text className="text-3xl font-bold" style={{ color: UI.text }}>
|
||||||
|
{biens.length}
|
||||||
|
</Text>
|
||||||
|
<Text className="mt-1 text-base font-medium" style={{ color: UI.textMuted }}>
|
||||||
|
Biens
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<View
|
||||||
|
className="min-w-[108px] flex-1 rounded-2xl border bg-white p-4"
|
||||||
|
style={{ borderColor: UI.border, borderLeftWidth: 5, borderLeftColor: UI.success }}
|
||||||
|
>
|
||||||
|
<Text className="text-3xl font-bold" style={{ color: UI.text }}>
|
||||||
|
{actifs.length}
|
||||||
|
</Text>
|
||||||
|
<Text className="mt-1 text-base font-medium" style={{ color: UI.textMuted }}>
|
||||||
|
Actifs
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<View
|
||||||
|
className="min-w-[108px] flex-1 rounded-2xl border bg-white p-4"
|
||||||
|
style={{ borderColor: UI.border, borderLeftWidth: 5, borderLeftColor: UI.warning }}
|
||||||
|
>
|
||||||
|
<Text className="text-3xl font-bold" style={{ color: UI.text }}>
|
||||||
|
{taches.filter(isTaskActive).length}
|
||||||
|
</Text>
|
||||||
|
<Text className="mt-1 text-base font-medium" style={{ color: UI.textMuted }}>
|
||||||
|
Tâches ouvertes
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View className="mb-3 flex-row items-center justify-between">
|
||||||
|
<Text className="text-2xl font-bold" style={{ color: UI.text }}>
|
||||||
|
Pipeline
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<ScrollView horizontal showsHorizontalScrollIndicator={false} className="mb-8">
|
||||||
|
<View className="flex-row gap-3 pb-1">
|
||||||
|
{etapes.map((e) => {
|
||||||
|
const n = etapeCounts.get(e.id) ?? 0;
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
key={e.id}
|
||||||
|
className="min-w-[132px] rounded-2xl border-2 bg-white px-4 py-4"
|
||||||
|
style={{ borderColor: UI.border }}
|
||||||
|
>
|
||||||
|
<View className="mb-2 h-1.5 rounded-full" style={{ backgroundColor: e.couleur }} />
|
||||||
|
<Text numberOfLines={2} className="text-base font-bold" style={{ color: UI.text }}>
|
||||||
|
{e.nom}
|
||||||
|
</Text>
|
||||||
|
<Text className="mt-2 text-2xl font-bold" style={{ color: UI.primary }}>
|
||||||
|
{n}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{(etapeCounts.get('') ?? 0) > 0 ? (
|
||||||
|
<View
|
||||||
|
className="min-w-[132px] rounded-2xl border-2 border-dashed bg-white px-4 py-4"
|
||||||
|
style={{ borderColor: UI.border }}
|
||||||
|
>
|
||||||
|
<Text className="text-base font-bold" style={{ color: UI.textMuted }}>
|
||||||
|
Sans étape
|
||||||
|
</Text>
|
||||||
|
<Text className="mt-2 text-2xl font-bold" style={{ color: UI.warning }}>
|
||||||
|
{etapeCounts.get('') ?? 0}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
</ScrollView>
|
||||||
|
|
||||||
|
<View className="mb-3 flex-row items-center justify-between">
|
||||||
|
<Text className="text-2xl font-bold" style={{ color: UI.text }}>
|
||||||
|
Derniers biens
|
||||||
|
</Text>
|
||||||
|
<Link href="/(tabs)/biens" asChild>
|
||||||
|
<Pressable
|
||||||
|
hitSlop={12}
|
||||||
|
className="min-h-[48px] justify-center px-2"
|
||||||
|
accessibilityRole="link"
|
||||||
|
>
|
||||||
|
<Text className="text-lg font-bold" style={{ color: UI.primary }}>
|
||||||
|
Tout voir
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
</Link>
|
||||||
|
</View>
|
||||||
|
{derniers.length === 0 ? (
|
||||||
|
<View className="mb-8">
|
||||||
|
<EmptyState
|
||||||
|
title="Aucun bien enregistré"
|
||||||
|
description="Ajoute ton premier prospect pour démarrer le pipeline."
|
||||||
|
actionLabel="Créer un bien"
|
||||||
|
actionHref="/bien/nouveau"
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
|
<View className="mb-8 gap-3">
|
||||||
|
{derniers.map((b) => (
|
||||||
|
<Link key={b.id} href={`/bien/${b.id}`} asChild>
|
||||||
|
<Pressable
|
||||||
|
accessibilityRole="button"
|
||||||
|
className="min-h-[64px] justify-center rounded-2xl border-2 bg-white px-4 py-4 active:opacity-90"
|
||||||
|
style={{ borderColor: UI.border }}
|
||||||
|
>
|
||||||
|
<Text className="text-lg font-bold" style={{ color: UI.text }}>
|
||||||
|
{bienTitre(b)}
|
||||||
|
</Text>
|
||||||
|
<Text className="mt-1 text-base" style={{ color: UI.textMuted }}>
|
||||||
|
{b.expand?.etape?.nom ?? '—'} · {b.ville ?? ''}
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<View className="mb-3 flex-row items-center justify-between">
|
||||||
|
<Text className="text-2xl font-bold" style={{ color: UI.text }}>
|
||||||
|
Tâches du jour
|
||||||
|
</Text>
|
||||||
|
<Link href="/(tabs)/agenda" asChild>
|
||||||
|
<Pressable hitSlop={12} className="min-h-[48px] justify-center px-2">
|
||||||
|
<Text className="text-lg font-bold" style={{ color: UI.primary }}>
|
||||||
|
Agenda
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
</Link>
|
||||||
|
</View>
|
||||||
|
{part.today.length === 0 ? (
|
||||||
|
<View className="mb-8">
|
||||||
|
<EmptyState
|
||||||
|
title="Journée libre"
|
||||||
|
description="Aucune échéance aujourd’hui dans l’agenda."
|
||||||
|
actionLabel="Planifier une tâche"
|
||||||
|
actionHref="/(tabs)/agenda"
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
|
<View className="mb-8 gap-3">
|
||||||
|
{part.today.map((t) => (
|
||||||
|
<View
|
||||||
|
key={t.id}
|
||||||
|
className="min-h-[56px] justify-center rounded-2xl border-2 bg-white px-4 py-4"
|
||||||
|
style={{ borderColor: UI.border }}
|
||||||
|
>
|
||||||
|
<Text className="text-lg font-semibold" style={{ color: UI.text }}>
|
||||||
|
{t.titre}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Text className="mb-3 text-lg font-bold uppercase tracking-wide" style={{ color: UI.textMuted }}>
|
||||||
|
Raccourcis
|
||||||
|
</Text>
|
||||||
|
<View className="gap-3">
|
||||||
|
<Link href="/bien/nouveau" asChild>
|
||||||
|
<Pressable
|
||||||
|
className="min-h-[56px] items-center justify-center rounded-2xl active:opacity-90"
|
||||||
|
style={{ backgroundColor: UI.primary }}
|
||||||
|
accessibilityRole="button"
|
||||||
|
>
|
||||||
|
<Text className="text-lg font-bold text-white">Nouveau bien</Text>
|
||||||
|
</Pressable>
|
||||||
|
</Link>
|
||||||
|
<Link href="/(tabs)/contacts" asChild>
|
||||||
|
<Pressable
|
||||||
|
className="min-h-[56px] items-center justify-center rounded-2xl border-2 bg-white active:opacity-90"
|
||||||
|
style={{ borderColor: UI.primary }}
|
||||||
|
accessibilityRole="button"
|
||||||
|
>
|
||||||
|
<Text className="text-lg font-bold" style={{ color: UI.primary }}>
|
||||||
|
Contacts
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
</Link>
|
||||||
|
<Link href="/(tabs)/visites" asChild>
|
||||||
|
<Pressable
|
||||||
|
className="min-h-[56px] items-center justify-center rounded-2xl border-2 bg-white active:opacity-90"
|
||||||
|
style={{ borderColor: UI.success }}
|
||||||
|
accessibilityRole="button"
|
||||||
|
>
|
||||||
|
<Text className="text-lg font-bold" style={{ color: UI.success }}>
|
||||||
|
Visites
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
</Link>
|
||||||
|
</View>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</ScrollView>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
46
app/app/(tabs)/recherche.tsx
Normal file
@ -0,0 +1,46 @@
|
|||||||
|
import { Stack } from 'expo-router';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { Pressable, Text, View } from 'react-native';
|
||||||
|
|
||||||
|
import { GrillePrixTab } from '@/components/recherche/GrillePrixTab';
|
||||||
|
import { OpportunitesTab } from '@/components/recherche/OpportunitesTab';
|
||||||
|
import { SecteurTab } from '@/components/recherche/SecteurTab';
|
||||||
|
import { VeilleAgentsTab } from '@/components/recherche/VeilleAgentsTab';
|
||||||
|
import { UI } from '@/constants/uiTheme';
|
||||||
|
|
||||||
|
const TABS = ['Secteur', 'Opportunités', 'Grille de prix', 'Veille & agents'] as const;
|
||||||
|
|
||||||
|
export default function RechercheTab() {
|
||||||
|
const [sub, setSub] = useState(0);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Stack.Screen options={{ title: 'Recherche', headerShown: true }} />
|
||||||
|
<View className="flex-1" style={{ backgroundColor: UI.screen }}>
|
||||||
|
<View className="flex-row border-b-2 px-1" style={{ borderColor: UI.border, backgroundColor: UI.card }}>
|
||||||
|
{TABS.map((label, i) => (
|
||||||
|
<Pressable
|
||||||
|
key={label}
|
||||||
|
accessibilityRole="tab"
|
||||||
|
accessibilityState={{ selected: sub === i }}
|
||||||
|
onPress={() => setSub(i)}
|
||||||
|
className="min-h-[52px] flex-1 items-center justify-center border-b-4 py-3"
|
||||||
|
style={{ borderBottomColor: sub === i ? UI.primary : 'transparent' }}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
className="text-center text-base font-bold"
|
||||||
|
style={{ color: sub === i ? UI.primary : UI.textMuted }}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
{sub === 0 ? <SecteurTab /> : null}
|
||||||
|
{sub === 1 ? <OpportunitesTab /> : null}
|
||||||
|
{sub === 2 ? <GrillePrixTab /> : null}
|
||||||
|
{sub === 3 ? <VeilleAgentsTab /> : null}
|
||||||
|
</View>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
63
app/app/(tabs)/visites.tsx
Normal file
@ -0,0 +1,63 @@
|
|||||||
|
import { Stack, useRouter } from 'expo-router';
|
||||||
|
import { Pressable, ScrollView, Text, View } from 'react-native';
|
||||||
|
|
||||||
|
import { EmptyState } from '@/components/ui/EmptyState';
|
||||||
|
import { ListSkeleton } from '@/components/ui/ListSkeleton';
|
||||||
|
import { AVIS_VISITE } from '@/constants/metier';
|
||||||
|
import { UI } from '@/constants/uiTheme';
|
||||||
|
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" style={{ backgroundColor: UI.screen }}>
|
||||||
|
{q.error ? (
|
||||||
|
<View
|
||||||
|
className="mx-3 mt-3 rounded-2xl border-2 px-4 py-3"
|
||||||
|
style={{ borderColor: UI.danger, backgroundColor: '#FEE2E2' }}
|
||||||
|
>
|
||||||
|
<Text className="text-base font-medium" style={{ color: '#991B1B' }}>
|
||||||
|
{formatPocketBaseError(q.error)}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
) : null}
|
||||||
|
{q.isPending ? (
|
||||||
|
<ListSkeleton rows={5} />
|
||||||
|
) : (
|
||||||
|
<ScrollView className="flex-1 px-3 pt-3" contentContainerStyle={{ paddingBottom: 112 }}>
|
||||||
|
{q.data?.length === 0 ? (
|
||||||
|
<EmptyState
|
||||||
|
title="Aucune visite"
|
||||||
|
description="Les visites sont liées à un bien : ouvre un bien depuis le pipeline pour consigner une visite."
|
||||||
|
actionLabel="Ouvrir les biens"
|
||||||
|
actionHref="/(tabs)/biens"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
q.data?.map((v) => (
|
||||||
|
<Pressable
|
||||||
|
key={v.id}
|
||||||
|
accessibilityRole="button"
|
||||||
|
className="mb-3 min-h-[76px] justify-center rounded-2xl border-2 bg-white px-4 py-4 active:opacity-90"
|
||||||
|
style={{ borderColor: UI.border }}
|
||||||
|
onPress={() => router.push(`/visite/${v.id}`)}
|
||||||
|
>
|
||||||
|
<Text className="text-xl font-bold" style={{ color: UI.text }}>
|
||||||
|
{v.date_visite?.slice(0, 10) ?? '—'}
|
||||||
|
</Text>
|
||||||
|
<Text className="mt-1 text-lg" style={{ color: UI.textMuted }}>
|
||||||
|
{v.avis_global ? AVIS_VISITE[v.avis_global]?.label ?? v.avis_global : 'Avis non renseigné'}
|
||||||
|
</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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
308
app/app/bien/[id].tsx
Normal file
@ -0,0 +1,308 @@
|
|||||||
|
import { Link, Stack, useLocalSearchParams, useRouter } from 'expo-router';
|
||||||
|
import type { ReactNode } from 'react';
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import {
|
||||||
|
ActivityIndicator,
|
||||||
|
Alert,
|
||||||
|
FlatList,
|
||||||
|
Linking,
|
||||||
|
Pressable,
|
||||||
|
ScrollView,
|
||||||
|
Text,
|
||||||
|
TextInput,
|
||||||
|
View,
|
||||||
|
} from 'react-native';
|
||||||
|
|
||||||
|
import { AVIS_VISITE, TYPES_BIENS } from '@/constants/metier';
|
||||||
|
import { dvfSearchUrl, geoportailBienUrl } from '@/constants/rechercheMarche';
|
||||||
|
import { useBienDetail } from '@/hooks/useBiens';
|
||||||
|
import { useNoteLibre } from '@/hooks/useNoteLibre';
|
||||||
|
import { calculateResults, useAnalyse, 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);
|
||||||
|
const { patchAnalyse, isPatching } = useAnalyse(id);
|
||||||
|
const [prixReventeM2Draft, setPrixReventeM2Draft] = useState('');
|
||||||
|
|
||||||
|
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;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setPrixReventeM2Draft(analyse?.prix_revente_m2 != null ? String(analyse.prix_revente_m2) : '');
|
||||||
|
}, [analyse?.id, analyse?.prix_revente_m2]);
|
||||||
|
|
||||||
|
const savePrixReventeM2 = async () => {
|
||||||
|
const raw = prixReventeM2Draft.trim().replace(',', '.');
|
||||||
|
if (raw === '') {
|
||||||
|
await patchAnalyse({ prix_revente_m2: null });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const n = Number(raw);
|
||||||
|
if (!Number.isFinite(n) || n < 0) {
|
||||||
|
Alert.alert('Prix au m²', 'Saisis un nombre positif ou laisse vide.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await patchAnalyse({ prix_revente_m2: n });
|
||||||
|
};
|
||||||
|
|
||||||
|
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,
|
||||||
|
prix_revente_m2: analyse?.prix_revente_m2,
|
||||||
|
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="Analyse marché">
|
||||||
|
<View className="flex-row flex-wrap gap-3">
|
||||||
|
<Pressable
|
||||||
|
className="min-h-[48px] justify-center rounded-xl px-4 py-3"
|
||||||
|
style={{ backgroundColor: '#1D4ED8' }}
|
||||||
|
onPress={() => void Linking.openURL(dvfSearchUrl(bien.ville ?? ''))}
|
||||||
|
>
|
||||||
|
<Text className="text-center text-base font-semibold text-white">Prix secteur (DVF)</Text>
|
||||||
|
</Pressable>
|
||||||
|
<Pressable
|
||||||
|
className="min-h-[48px] justify-center rounded-xl border-2 border-slate-300 bg-white px-4 py-3"
|
||||||
|
onPress={() => {
|
||||||
|
const la = bien.latitude;
|
||||||
|
const lo = bien.longitude;
|
||||||
|
if (la == null || lo == null || Number.isNaN(Number(la)) || Number.isNaN(Number(lo))) {
|
||||||
|
Alert.alert(
|
||||||
|
'Carte',
|
||||||
|
'Ajoute latitude et longitude sur la fiche bien pour ouvrir le Géoportail centré.',
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
void Linking.openURL(geoportailBienUrl(Number(la), Number(lo)));
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text className="text-center text-base font-semibold text-slate-900">Voir sur la carte</Text>
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
<Text className="mt-4 text-sm font-medium text-slate-500">
|
||||||
|
Prix estimé revente (€/m²) — enregistré dans l'analyse financière
|
||||||
|
</Text>
|
||||||
|
<TextInput
|
||||||
|
className="mt-2 rounded-xl border border-slate-200 bg-white p-3 text-base text-slate-900"
|
||||||
|
keyboardType="decimal-pad"
|
||||||
|
placeholder="Ex. 5200"
|
||||||
|
placeholderTextColor="#94a3b8"
|
||||||
|
value={prixReventeM2Draft}
|
||||||
|
onChangeText={setPrixReventeM2Draft}
|
||||||
|
onEndEditing={() => void savePrixReventeM2()}
|
||||||
|
/>
|
||||||
|
{isPatching ? (
|
||||||
|
<ActivityIndicator className="mt-2" color="#1D4ED8" />
|
||||||
|
) : (
|
||||||
|
<Text className="mt-1 text-xs text-slate-400">Sauvegarde à la sortie du champ.</Text>
|
||||||
|
)}
|
||||||
|
</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)} />
|
||||||
|
{analyse.prix_revente_m2 != null ? (
|
||||||
|
<InfoLine label="Prix revente ref. (€/m²)" value={String(analyse.prix_revente_m2)} />
|
||||||
|
) : null}
|
||||||
|
<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 { Redirect } from 'expo-router';
|
||||||
import { ActivityIndicator, View } from 'react-native';
|
import { ActivityIndicator, View } from 'react-native';
|
||||||
|
|
||||||
import { useAuth } from '@/context/AuthContext';
|
import { useAuth } from '@/context/AuthContext';
|
||||||
|
|
||||||
export default function Index() {
|
export default function Index() {
|
||||||
const { initialized, session } = useAuth();
|
const { loading, user } = useAuth();
|
||||||
|
|
||||||
if (!initialized) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<View className="flex-1 items-center justify-center bg-slate-50">
|
<View className="flex-1 items-center justify-center bg-slate-50">
|
||||||
<ActivityIndicator size="large" color="#1D4ED8" />
|
<ActivityIndicator size="large" color="#1D4ED8" />
|
||||||
@ -13,9 +14,5 @@ export default function Index() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (session?.user) {
|
return user ? <Redirect href="/(tabs)" /> : <Redirect href="/auth/login" />;
|
||||||
return <Redirect href="/(tabs)" />;
|
|
||||||
}
|
|
||||||
|
|
||||||
return <Redirect href="/auth/login" />;
|
|
||||||
}
|
}
|
||||||
387
app/app/visite/[id].tsx
Normal file
@ -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 },
|
|
||||||
});
|
|
||||||
329
app/components/recherche/GrillePrixTab.tsx
Normal file
@ -0,0 +1,329 @@
|
|||||||
|
import { useMemo, useState } from 'react';
|
||||||
|
import {
|
||||||
|
ActivityIndicator,
|
||||||
|
Alert,
|
||||||
|
Modal,
|
||||||
|
Pressable,
|
||||||
|
ScrollView,
|
||||||
|
Text,
|
||||||
|
TextInput,
|
||||||
|
View,
|
||||||
|
} from 'react-native';
|
||||||
|
|
||||||
|
import { UI } from '@/constants/uiTheme';
|
||||||
|
import { useGrillePrix } from '@/hooks/useGrillePrix';
|
||||||
|
import type { GrillePrixEtat, GrillePrixRecord, GrillePrixTypeBien } from '@/types/collections';
|
||||||
|
import { formatPocketBaseError } from '@/utils/pocketbaseErrors';
|
||||||
|
|
||||||
|
const TYPES: GrillePrixTypeBien[] = ['appartement', 'maison', 'immeuble'];
|
||||||
|
const ETATS: GrillePrixEtat[] = ['bon_etat', 'a_renover', 'travaux_lourds'];
|
||||||
|
|
||||||
|
const TYPE_LABEL: Record<GrillePrixTypeBien, string> = {
|
||||||
|
appartement: 'Appartement',
|
||||||
|
maison: 'Maison',
|
||||||
|
immeuble: 'Immeuble',
|
||||||
|
};
|
||||||
|
|
||||||
|
const ETAT_LABEL: Record<GrillePrixEtat, string> = {
|
||||||
|
bon_etat: 'Bon état',
|
||||||
|
a_renover: 'À rénover',
|
||||||
|
travaux_lourds: 'Travaux lourds',
|
||||||
|
};
|
||||||
|
|
||||||
|
type EditorState =
|
||||||
|
| { mode: 'create' }
|
||||||
|
| { mode: 'edit'; row: GrillePrixRecord };
|
||||||
|
|
||||||
|
function parseNum(raw: string): number | null {
|
||||||
|
const n = Number(String(raw).replace(',', '.').trim());
|
||||||
|
return Number.isFinite(n) && n >= 0 ? n : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function GrillePrixTab() {
|
||||||
|
const { rows, isLoading, error, createRow, updateRow, deleteRow, isMutating } = useGrillePrix();
|
||||||
|
const [editor, setEditor] = useState<EditorState | null>(null);
|
||||||
|
const [typeBien, setTypeBien] = useState<GrillePrixTypeBien>('appartement');
|
||||||
|
const [etat, setEtat] = useState<GrillePrixEtat>('bon_etat');
|
||||||
|
const [pa, setPa] = useState('');
|
||||||
|
const [pr, setPr] = useState('');
|
||||||
|
const [ville, setVille] = useState('');
|
||||||
|
|
||||||
|
const moyenneLabel = useMemo(() => {
|
||||||
|
const vals = rows
|
||||||
|
.map((r) => r.marge_estimee_pct)
|
||||||
|
.filter((x): x is number => typeof x === 'number' && !Number.isNaN(x));
|
||||||
|
if (vals.length === 0) return '—';
|
||||||
|
const m = vals.reduce((a, b) => a + b, 0) / vals.length;
|
||||||
|
return `${m.toFixed(1)} %`;
|
||||||
|
}, [rows]);
|
||||||
|
|
||||||
|
const openCreate = () => {
|
||||||
|
setTypeBien('appartement');
|
||||||
|
setEtat('bon_etat');
|
||||||
|
setPa('');
|
||||||
|
setPr('');
|
||||||
|
setVille('');
|
||||||
|
setEditor({ mode: 'create' });
|
||||||
|
};
|
||||||
|
|
||||||
|
const openEdit = (row: GrillePrixRecord) => {
|
||||||
|
setTypeBien(row.type_bien);
|
||||||
|
setEtat(row.etat);
|
||||||
|
setPa(String(row.prix_achat_m2));
|
||||||
|
setPr(String(row.prix_revente_m2));
|
||||||
|
setVille(row.ville ?? '');
|
||||||
|
setEditor({ mode: 'edit', row });
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeEditor = () => setEditor(null);
|
||||||
|
|
||||||
|
const submitEditor = async () => {
|
||||||
|
const achat = parseNum(pa);
|
||||||
|
const revente = parseNum(pr);
|
||||||
|
if (achat == null || revente == null) {
|
||||||
|
Alert.alert('Saisie', 'Indique des prix au m² valides (≥ 0).');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const payload = {
|
||||||
|
type_bien: typeBien,
|
||||||
|
etat,
|
||||||
|
prix_achat_m2: achat,
|
||||||
|
prix_revente_m2: revente,
|
||||||
|
ville: ville.trim() || undefined,
|
||||||
|
};
|
||||||
|
if (editor?.mode === 'create') {
|
||||||
|
await createRow(payload);
|
||||||
|
} else if (editor?.mode === 'edit') {
|
||||||
|
await updateRow({ id: editor.row.id, input: payload });
|
||||||
|
}
|
||||||
|
closeEditor();
|
||||||
|
} catch (e) {
|
||||||
|
Alert.alert('Erreur', formatPocketBaseError(e));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const confirmDelete = (row: GrillePrixRecord) => {
|
||||||
|
Alert.alert('Supprimer cette ligne ?', `${TYPE_LABEL[row.type_bien]} · ${ETAT_LABEL[row.etat]}`, [
|
||||||
|
{ text: 'Annuler', style: 'cancel' },
|
||||||
|
{
|
||||||
|
text: 'Supprimer',
|
||||||
|
style: 'destructive',
|
||||||
|
onPress: () =>
|
||||||
|
void deleteRow(row.id).catch((e) => Alert.alert('Erreur', formatPocketBaseError(e))),
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<View className="flex-1 items-center justify-center py-16">
|
||||||
|
<ActivityIndicator size="large" color={UI.primary} />
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View className="flex-1">
|
||||||
|
{error ? (
|
||||||
|
<Text className="px-3 py-2 text-base text-red-700">{formatPocketBaseError(error)}</Text>
|
||||||
|
) : null}
|
||||||
|
<ScrollView horizontal className="flex-1" contentContainerStyle={{ paddingBottom: 120 }}>
|
||||||
|
<View>
|
||||||
|
<View className="min-w-[720px] flex-row border-b-2 bg-white px-2 py-3" style={{ borderColor: UI.border }}>
|
||||||
|
<Text className="w-28 text-xs font-bold uppercase" style={{ color: UI.textMuted }}>
|
||||||
|
Type
|
||||||
|
</Text>
|
||||||
|
<Text className="w-32 text-xs font-bold uppercase" style={{ color: UI.textMuted }}>
|
||||||
|
État
|
||||||
|
</Text>
|
||||||
|
<Text className="w-28 text-xs font-bold uppercase" style={{ color: UI.textMuted }}>
|
||||||
|
Achat €/m²
|
||||||
|
</Text>
|
||||||
|
<Text className="w-28 text-xs font-bold uppercase" style={{ color: UI.textMuted }}>
|
||||||
|
Revente €/m²
|
||||||
|
</Text>
|
||||||
|
<Text className="w-24 text-xs font-bold uppercase" style={{ color: UI.textMuted }}>
|
||||||
|
Marge %
|
||||||
|
</Text>
|
||||||
|
<Text className="w-28 text-xs font-bold uppercase" style={{ color: UI.textMuted }}>
|
||||||
|
Ville
|
||||||
|
</Text>
|
||||||
|
<Text className="w-20" />
|
||||||
|
</View>
|
||||||
|
{rows.length === 0 ? (
|
||||||
|
<Text className="p-6 text-base" style={{ color: UI.textMuted }}>
|
||||||
|
Aucune ligne. Appuie sur + pour créer ton référentiel.
|
||||||
|
</Text>
|
||||||
|
) : (
|
||||||
|
rows.map((r) => (
|
||||||
|
<View
|
||||||
|
key={r.id}
|
||||||
|
className="min-w-[720px] flex-row border-b px-2 py-4"
|
||||||
|
style={{ borderColor: UI.border }}
|
||||||
|
>
|
||||||
|
<Pressable
|
||||||
|
onPress={() => openEdit(r)}
|
||||||
|
className="min-w-0 flex-1 flex-row active:bg-slate-100"
|
||||||
|
>
|
||||||
|
<Text className="w-28 text-base font-semibold" style={{ color: UI.text }}>
|
||||||
|
{TYPE_LABEL[r.type_bien]}
|
||||||
|
</Text>
|
||||||
|
<Text className="w-32 text-base" style={{ color: UI.text }}>
|
||||||
|
{ETAT_LABEL[r.etat]}
|
||||||
|
</Text>
|
||||||
|
<Text className="w-28 text-base" style={{ color: UI.text }}>
|
||||||
|
{r.prix_achat_m2}
|
||||||
|
</Text>
|
||||||
|
<Text className="w-28 text-base" style={{ color: UI.text }}>
|
||||||
|
{r.prix_revente_m2}
|
||||||
|
</Text>
|
||||||
|
<Text className="w-24 text-base font-bold" style={{ color: UI.success }}>
|
||||||
|
{r.marge_estimee_pct != null ? `${r.marge_estimee_pct.toFixed(1)} %` : '—'}
|
||||||
|
</Text>
|
||||||
|
<Text className="w-28 text-base" style={{ color: UI.textMuted }} numberOfLines={1}>
|
||||||
|
{r.ville ?? '—'}
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
<Pressable onPress={() => confirmDelete(r)} className="w-20 items-center justify-center">
|
||||||
|
<Text className="font-bold" style={{ color: UI.danger }}>
|
||||||
|
✕
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</ScrollView>
|
||||||
|
|
||||||
|
<View
|
||||||
|
className="border-t-2 px-4 py-4"
|
||||||
|
style={{ borderColor: UI.border, backgroundColor: UI.card }}
|
||||||
|
>
|
||||||
|
<Text className="text-lg font-bold" style={{ color: UI.text }}>
|
||||||
|
Marge moyenne du référentiel
|
||||||
|
</Text>
|
||||||
|
<Text className="mt-1 text-3xl font-bold" style={{ color: UI.primary }}>
|
||||||
|
{moyenneLabel}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<Pressable
|
||||||
|
accessibilityLabel="Ajouter une ligne"
|
||||||
|
onPress={openCreate}
|
||||||
|
className="absolute bottom-24 right-5 h-16 w-16 items-center justify-center rounded-2xl"
|
||||||
|
style={{ backgroundColor: UI.primary, elevation: 8 }}
|
||||||
|
>
|
||||||
|
<Text className="text-3xl font-light text-white">+</Text>
|
||||||
|
</Pressable>
|
||||||
|
|
||||||
|
<Modal visible={editor != null} animationType="slide" transparent>
|
||||||
|
<View className="flex-1 justify-end bg-black/50">
|
||||||
|
<View className="rounded-t-3xl bg-white p-5">
|
||||||
|
<Text className="text-2xl font-bold" style={{ color: UI.text }}>
|
||||||
|
{editor?.mode === 'edit' ? 'Modifier la ligne' : 'Nouvelle ligne'}
|
||||||
|
</Text>
|
||||||
|
<Text className="mt-4 text-base font-semibold" style={{ color: UI.textMuted }}>
|
||||||
|
Type de bien
|
||||||
|
</Text>
|
||||||
|
<View className="mt-2 flex-row flex-wrap gap-2">
|
||||||
|
{TYPES.map((t) => (
|
||||||
|
<Pressable
|
||||||
|
key={t}
|
||||||
|
onPress={() => setTypeBien(t)}
|
||||||
|
className="min-h-[48px] rounded-xl border-2 px-4 py-2"
|
||||||
|
style={{
|
||||||
|
borderColor: typeBien === t ? UI.primary : UI.border,
|
||||||
|
backgroundColor: typeBien === t ? '#EFF6FF' : UI.card,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text className="text-base font-bold" style={{ color: UI.text }}>
|
||||||
|
{TYPE_LABEL[t]}
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
<Text className="mt-4 text-base font-semibold" style={{ color: UI.textMuted }}>
|
||||||
|
État
|
||||||
|
</Text>
|
||||||
|
<View className="mt-2 flex-row flex-wrap gap-2">
|
||||||
|
{ETATS.map((t) => (
|
||||||
|
<Pressable
|
||||||
|
key={t}
|
||||||
|
onPress={() => setEtat(t)}
|
||||||
|
className="min-h-[48px] rounded-xl border-2 px-4 py-2"
|
||||||
|
style={{
|
||||||
|
borderColor: etat === t ? UI.primary : UI.border,
|
||||||
|
backgroundColor: etat === t ? '#EFF6FF' : UI.card,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text className="text-base font-bold" style={{ color: UI.text }}>
|
||||||
|
{ETAT_LABEL[t]}
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
<Text className="mt-4 text-base font-semibold" style={{ color: UI.textMuted }}>
|
||||||
|
Prix achat (€/m²)
|
||||||
|
</Text>
|
||||||
|
<TextInput
|
||||||
|
className="mt-1 rounded-xl border-2 px-4 py-3 text-lg"
|
||||||
|
style={{ borderColor: UI.border, color: UI.text }}
|
||||||
|
keyboardType="decimal-pad"
|
||||||
|
value={pa}
|
||||||
|
onChangeText={setPa}
|
||||||
|
placeholder="4500"
|
||||||
|
placeholderTextColor={UI.textMuted}
|
||||||
|
/>
|
||||||
|
<Text className="mt-4 text-base font-semibold" style={{ color: UI.textMuted }}>
|
||||||
|
Prix revente (€/m²)
|
||||||
|
</Text>
|
||||||
|
<TextInput
|
||||||
|
className="mt-1 rounded-xl border-2 px-4 py-3 text-lg"
|
||||||
|
style={{ borderColor: UI.border, color: UI.text }}
|
||||||
|
keyboardType="decimal-pad"
|
||||||
|
value={pr}
|
||||||
|
onChangeText={setPr}
|
||||||
|
placeholder="5200"
|
||||||
|
placeholderTextColor={UI.textMuted}
|
||||||
|
/>
|
||||||
|
<Text className="mt-4 text-base font-semibold" style={{ color: UI.textMuted }}>
|
||||||
|
Ville (optionnel)
|
||||||
|
</Text>
|
||||||
|
<TextInput
|
||||||
|
className="mt-1 rounded-xl border-2 px-4 py-3 text-lg"
|
||||||
|
style={{ borderColor: UI.border, color: UI.text }}
|
||||||
|
value={ville}
|
||||||
|
onChangeText={setVille}
|
||||||
|
placeholder="Lyon"
|
||||||
|
placeholderTextColor={UI.textMuted}
|
||||||
|
/>
|
||||||
|
<View className="mt-6 flex-row gap-3">
|
||||||
|
<Pressable
|
||||||
|
onPress={closeEditor}
|
||||||
|
className="min-h-[52px] flex-1 items-center justify-center rounded-2xl border-2"
|
||||||
|
style={{ borderColor: UI.border }}
|
||||||
|
>
|
||||||
|
<Text className="text-lg font-bold" style={{ color: UI.text }}>
|
||||||
|
Annuler
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
<Pressable
|
||||||
|
onPress={() => void submitEditor()}
|
||||||
|
disabled={isMutating}
|
||||||
|
className="min-h-[52px] flex-1 items-center justify-center rounded-2xl"
|
||||||
|
style={{ backgroundColor: UI.primary }}
|
||||||
|
>
|
||||||
|
{isMutating ? (
|
||||||
|
<ActivityIndicator color="#fff" />
|
||||||
|
) : (
|
||||||
|
<Text className="text-lg font-bold text-white">Enregistrer</Text>
|
||||||
|
)}
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</Modal>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
284
app/components/recherche/OpportunitesTab.tsx
Normal file
@ -0,0 +1,284 @@
|
|||||||
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
|
import * as Clipboard from 'expo-clipboard';
|
||||||
|
import type { ReactNode } from 'react';
|
||||||
|
import { useCallback, useState } from 'react';
|
||||||
|
import {
|
||||||
|
ActivityIndicator,
|
||||||
|
Alert,
|
||||||
|
Linking,
|
||||||
|
Pressable,
|
||||||
|
ScrollView,
|
||||||
|
Text,
|
||||||
|
TextInput,
|
||||||
|
View,
|
||||||
|
} from 'react-native';
|
||||||
|
|
||||||
|
import {
|
||||||
|
LEGI_L151_36,
|
||||||
|
LEGI_L152_6,
|
||||||
|
OFF_MARKET_KEYWORDS,
|
||||||
|
PROSPECTION_CHECKLIST,
|
||||||
|
} from '@/constants/rechercheMarche';
|
||||||
|
import { UI } from '@/constants/uiTheme';
|
||||||
|
import { useNotesProspectionRecherche } from '@/hooks/useNotesProspectionRecherche';
|
||||||
|
import { formatPocketBaseError } from '@/utils/pocketbaseErrors';
|
||||||
|
|
||||||
|
const LBC = 'https://www.leboncoin.fr/';
|
||||||
|
const MOTEUR = 'https://www.moteurimmo.fr/';
|
||||||
|
|
||||||
|
function Collapsible({
|
||||||
|
title,
|
||||||
|
children,
|
||||||
|
defaultOpen,
|
||||||
|
}: {
|
||||||
|
title: string;
|
||||||
|
children: ReactNode;
|
||||||
|
defaultOpen?: boolean;
|
||||||
|
}) {
|
||||||
|
const [open, setOpen] = useState(Boolean(defaultOpen));
|
||||||
|
return (
|
||||||
|
<View className="mb-3 rounded-2xl border-2 bg-white" style={{ borderColor: UI.border }}>
|
||||||
|
<Pressable
|
||||||
|
accessibilityRole="button"
|
||||||
|
onPress={() => setOpen((o) => !o)}
|
||||||
|
className="min-h-[52px] flex-row items-center justify-between px-4 py-3"
|
||||||
|
>
|
||||||
|
<Text className="flex-1 pr-2 text-lg font-bold" style={{ color: UI.text }}>
|
||||||
|
{title}
|
||||||
|
</Text>
|
||||||
|
<Ionicons name={open ? 'chevron-up' : 'chevron-down'} size={22} color={UI.textMuted} />
|
||||||
|
</Pressable>
|
||||||
|
{open ? <View className="border-t-2 px-4 pb-4 pt-2" style={{ borderColor: UI.border }}>{children}</View> : null}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function OpportunitesTab() {
|
||||||
|
const { getState, saveChecklistItem, isLoading, isSaving } = useNotesProspectionRecherche();
|
||||||
|
const [expandedNoteId, setExpandedNoteId] = useState<string | null>(null);
|
||||||
|
const [noteDraft, setNoteDraft] = useState('');
|
||||||
|
|
||||||
|
const persistItem = useCallback(
|
||||||
|
async (questionId: string, next: { done: boolean; note: string }) => {
|
||||||
|
try {
|
||||||
|
await saveChecklistItem({ questionId, data: next });
|
||||||
|
} catch (e) {
|
||||||
|
Alert.alert('Sauvegarde', formatPocketBaseError(e));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[saveChecklistItem],
|
||||||
|
);
|
||||||
|
|
||||||
|
const openNoteEditor = (questionId: string) => {
|
||||||
|
const st = getState(questionId);
|
||||||
|
setExpandedNoteId(questionId);
|
||||||
|
setNoteDraft(st.note);
|
||||||
|
};
|
||||||
|
|
||||||
|
const saveNoteFor = async (questionId: string) => {
|
||||||
|
const st = getState(questionId);
|
||||||
|
await persistItem(questionId, { done: st.done, note: noteDraft.trim() });
|
||||||
|
setExpandedNoteId(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const copyText = async (text: string) => {
|
||||||
|
try {
|
||||||
|
await Clipboard.setStringAsync(text);
|
||||||
|
Alert.alert('Copié', 'Collage dans Leboncoin ou Moteur Immo.');
|
||||||
|
} catch {
|
||||||
|
Alert.alert('Presse-papiers', 'Copie impossible sur cet appareil.');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<View className="flex-1 items-center justify-center py-16">
|
||||||
|
<ActivityIndicator size="large" color={UI.primary} />
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ScrollView className="flex-1 px-3 pt-2" contentContainerStyle={{ paddingBottom: 120 }}>
|
||||||
|
<Collapsible title="1. Mots-clés off-market">
|
||||||
|
{OFF_MARKET_KEYWORDS.map((k) => (
|
||||||
|
<View key={k.id} className="mb-3 rounded-xl border-2 p-3" style={{ borderColor: UI.border }}>
|
||||||
|
<Text className="text-base font-bold" style={{ color: UI.text }}>
|
||||||
|
{k.label}
|
||||||
|
</Text>
|
||||||
|
<Text selectable className="mt-2 font-mono text-sm leading-5" style={{ color: UI.text }}>
|
||||||
|
{k.text}
|
||||||
|
</Text>
|
||||||
|
<Pressable
|
||||||
|
onPress={() => void copyText(k.text)}
|
||||||
|
className="mt-3 min-h-[48px] items-center justify-center rounded-xl"
|
||||||
|
style={{ backgroundColor: UI.primary }}
|
||||||
|
>
|
||||||
|
<Text className="text-base font-bold text-white">Copier</Text>
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
<View className="mt-2 flex-row flex-wrap gap-3">
|
||||||
|
<Pressable
|
||||||
|
onPress={() => void Linking.openURL(LBC)}
|
||||||
|
className="min-h-[52px] flex-1 min-w-[140px] items-center justify-center rounded-2xl border-2"
|
||||||
|
style={{ borderColor: UI.warning, backgroundColor: '#FFFBEB' }}
|
||||||
|
>
|
||||||
|
<Text className="text-lg font-bold" style={{ color: '#B45309' }}>
|
||||||
|
Leboncoin
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
<Pressable
|
||||||
|
onPress={() => void Linking.openURL(MOTEUR)}
|
||||||
|
className="min-h-[52px] flex-1 min-w-[140px] items-center justify-center rounded-2xl border-2"
|
||||||
|
style={{ borderColor: UI.warning, backgroundColor: '#FFFBEB' }}
|
||||||
|
>
|
||||||
|
<Text className="text-lg font-bold" style={{ color: '#B45309' }}>
|
||||||
|
Moteur Immo
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
<View className="mt-4 rounded-2xl border-2 p-4" style={{ borderColor: UI.primary, backgroundColor: '#EFF6FF' }}>
|
||||||
|
<Text className="text-base font-bold" style={{ color: UI.primary }}>
|
||||||
|
Astuce maison de ville
|
||||||
|
</Text>
|
||||||
|
<Text className="mt-2 text-base leading-6" style={{ color: UI.text }}>
|
||||||
|
Surface terrain max 100 m² + surface habitable min 150 m² → maisons sans jardin, moins de concurrence,
|
||||||
|
idéal division.
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</Collapsible>
|
||||||
|
|
||||||
|
<Collapsible title='2. Biens "fatigués" — décotes'>
|
||||||
|
<Text className="text-base leading-6" style={{ color: UI.text }}>
|
||||||
|
Trier par ancienneté sur Moteur Immo. 40+ mois en ligne + baisses répétées = vendeur motivé. Décote possible
|
||||||
|
sous prix marché.
|
||||||
|
</Text>
|
||||||
|
<View className="mt-3 self-start rounded-xl px-3 py-2" style={{ backgroundColor: '#FEE2E2' }}>
|
||||||
|
<Text className="text-lg font-bold" style={{ color: UI.danger }}>
|
||||||
|
−10 % à −24 %
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<Pressable
|
||||||
|
onPress={() => void Linking.openURL(MOTEUR)}
|
||||||
|
className="mt-4 min-h-[52px] items-center justify-center rounded-2xl"
|
||||||
|
style={{ backgroundColor: UI.danger }}
|
||||||
|
>
|
||||||
|
<Text className="text-center text-lg font-bold text-white">Moteur Immo — tri par ancienneté</Text>
|
||||||
|
</Pressable>
|
||||||
|
</Collapsible>
|
||||||
|
|
||||||
|
<Collapsible title="3. Vérifier la division — articles">
|
||||||
|
<View className="mb-3 rounded-2xl border-2 p-4" style={{ borderColor: UI.primary, backgroundColor: '#EFF6FF' }}>
|
||||||
|
<Text className="text-lg font-bold" style={{ color: UI.primary }}>
|
||||||
|
Article L151-36
|
||||||
|
</Text>
|
||||||
|
<Text className="mt-2 text-base leading-6" style={{ color: UI.text }}>
|
||||||
|
1 place de parking max par logement créé en zone bien desservie.
|
||||||
|
</Text>
|
||||||
|
<Pressable
|
||||||
|
onPress={() => void Linking.openURL(LEGI_L151_36)}
|
||||||
|
className="mt-3 min-h-[48px] items-center justify-center rounded-xl bg-white"
|
||||||
|
>
|
||||||
|
<Text className="text-base font-bold" style={{ color: UI.primary }}>
|
||||||
|
Ouvrir sur Légifrance →
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
<View className="rounded-2xl border-2 p-4" style={{ borderColor: UI.success, backgroundColor: '#F0FDF4' }}>
|
||||||
|
<Text className="text-lg font-bold" style={{ color: UI.success }}>
|
||||||
|
Article L152-6
|
||||||
|
</Text>
|
||||||
|
<Text className="mt-2 text-base leading-6" style={{ color: UI.text }}>
|
||||||
|
Dans 500 m d'une gare ou métro → division sans obligation parking.
|
||||||
|
</Text>
|
||||||
|
<Pressable
|
||||||
|
onPress={() => void Linking.openURL(LEGI_L152_6)}
|
||||||
|
className="mt-3 min-h-[48px] items-center justify-center rounded-xl bg-white"
|
||||||
|
>
|
||||||
|
<Text className="text-base font-bold" style={{ color: UI.success }}>
|
||||||
|
Ouvrir sur Légifrance →
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
</Collapsible>
|
||||||
|
|
||||||
|
<Collapsible title="4. Confirmer avec les pros">
|
||||||
|
<Text className="mb-2 text-base" style={{ color: UI.textMuted }}>
|
||||||
|
Coche après échange ; note la réponse pour ton dossier.
|
||||||
|
</Text>
|
||||||
|
{PROSPECTION_CHECKLIST.map((item) => {
|
||||||
|
const st = getState(item.id);
|
||||||
|
const expanded = expandedNoteId === item.id;
|
||||||
|
return (
|
||||||
|
<View key={item.id} className="mb-3 rounded-xl border-2 px-3 py-3" style={{ borderColor: UI.border }}>
|
||||||
|
<Pressable
|
||||||
|
accessibilityRole="checkbox"
|
||||||
|
accessibilityState={{ checked: st.done }}
|
||||||
|
onPress={() => void persistItem(item.id, { done: !st.done, note: st.note })}
|
||||||
|
className="min-h-[48px] flex-row items-start gap-3"
|
||||||
|
>
|
||||||
|
<View
|
||||||
|
className="mt-0.5 h-8 w-8 items-center justify-center rounded-lg border-2"
|
||||||
|
style={{
|
||||||
|
borderColor: st.done ? UI.success : UI.border,
|
||||||
|
backgroundColor: st.done ? UI.success : UI.card,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{st.done ? <Text className="font-bold text-white">✓</Text> : null}
|
||||||
|
</View>
|
||||||
|
<View className="min-w-0 flex-1">
|
||||||
|
<Text className="text-base font-bold" style={{ color: UI.textMuted }}>
|
||||||
|
{item.label}
|
||||||
|
</Text>
|
||||||
|
<Text className="mt-1 text-base leading-6" style={{ color: UI.text }}>
|
||||||
|
{item.question}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</Pressable>
|
||||||
|
<Pressable
|
||||||
|
onPress={() => (expanded ? setExpandedNoteId(null) : openNoteEditor(item.id))}
|
||||||
|
className="mt-3 min-h-[44px] justify-center rounded-xl px-3"
|
||||||
|
style={{ backgroundColor: '#F1F5F9' }}
|
||||||
|
>
|
||||||
|
<Text className="text-base font-semibold" style={{ color: UI.primary }}>
|
||||||
|
{expanded ? 'Fermer la note' : st.note ? 'Modifier la note' : 'Ajouter une note'}
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
{expanded ? (
|
||||||
|
<View className="mt-2">
|
||||||
|
<TextInput
|
||||||
|
className="min-h-[88px] rounded-xl border-2 px-3 py-2 text-base"
|
||||||
|
style={{ borderColor: UI.border, color: UI.text }}
|
||||||
|
multiline
|
||||||
|
textAlignVertical="top"
|
||||||
|
value={noteDraft}
|
||||||
|
onChangeText={setNoteDraft}
|
||||||
|
placeholder="Réponse du pro…"
|
||||||
|
placeholderTextColor={UI.textMuted}
|
||||||
|
/>
|
||||||
|
<Pressable
|
||||||
|
onPress={() => void saveNoteFor(item.id)}
|
||||||
|
disabled={isSaving}
|
||||||
|
className="mt-2 min-h-[48px] items-center justify-center rounded-xl"
|
||||||
|
style={{ backgroundColor: UI.primary }}
|
||||||
|
>
|
||||||
|
{isSaving ? (
|
||||||
|
<ActivityIndicator color="#fff" />
|
||||||
|
) : (
|
||||||
|
<Text className="text-base font-bold text-white">Enregistrer la note</Text>
|
||||||
|
)}
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
) : st.note ? (
|
||||||
|
<Text className="mt-2 text-base italic" style={{ color: UI.textMuted }}>
|
||||||
|
{st.note}
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Collapsible>
|
||||||
|
</ScrollView>
|
||||||
|
);
|
||||||
|
}
|
||||||
168
app/components/recherche/SecteurTab.tsx
Normal file
@ -0,0 +1,168 @@
|
|||||||
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
|
import type { ComponentProps } from 'react';
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import {
|
||||||
|
ActivityIndicator,
|
||||||
|
Alert,
|
||||||
|
Keyboard,
|
||||||
|
Linking,
|
||||||
|
Pressable,
|
||||||
|
ScrollView,
|
||||||
|
Text,
|
||||||
|
TextInput,
|
||||||
|
View,
|
||||||
|
} from 'react-native';
|
||||||
|
|
||||||
|
import {
|
||||||
|
dvfSearchUrl,
|
||||||
|
meilleursAgentsUrlForVille,
|
||||||
|
SECTOR_TOOLS,
|
||||||
|
type SectorTool,
|
||||||
|
} from '@/constants/rechercheMarche';
|
||||||
|
import { UI } from '@/constants/uiTheme';
|
||||||
|
import { useAnalyseSecteurForVille, useSaveAnalyseSecteur } from '@/hooks/useAnalysesSecteur';
|
||||||
|
import { formatPocketBaseError } from '@/utils/pocketbaseErrors';
|
||||||
|
|
||||||
|
type IonName = ComponentProps<typeof Ionicons>['name'];
|
||||||
|
|
||||||
|
function resolveToolUrl(tool: SectorTool, ville: string): string {
|
||||||
|
if (tool.id === 'ma') return meilleursAgentsUrlForVille(ville);
|
||||||
|
if (tool.id === 'dvf') return dvfSearchUrl(ville);
|
||||||
|
return tool.url;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SecteurTab() {
|
||||||
|
const [ville, setVille] = useState('');
|
||||||
|
const [notes, setNotes] = useState('');
|
||||||
|
const secteurQ = useAnalyseSecteurForVille(ville);
|
||||||
|
const saveMut = useSaveAnalyseSecteur();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (secteurQ.data?.notes != null) setNotes(secteurQ.data.notes);
|
||||||
|
}, [secteurQ.data?.id, secteurQ.data?.notes]);
|
||||||
|
|
||||||
|
const onOpenTool = async (tool: SectorTool) => {
|
||||||
|
const url = resolveToolUrl(tool, ville);
|
||||||
|
const ok = await Linking.canOpenURL(url);
|
||||||
|
if (!ok) {
|
||||||
|
Alert.alert('Lien', 'Impossible d’ouvrir ce lien sur cet appareil.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
void Linking.openURL(url);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onAnalyser = () => {
|
||||||
|
const v = ville.trim();
|
||||||
|
if (!v) {
|
||||||
|
Alert.alert('Ville', 'Indique une ville ou une commune pour cadrer l’analyse.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Keyboard.dismiss();
|
||||||
|
Alert.alert(
|
||||||
|
'Secteur',
|
||||||
|
`Analyse pour « ${v} » : utilise les outils ci-dessous (données externes), puis consigne tes notes en bas de page.`,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onSaveNotes = async () => {
|
||||||
|
const v = ville.trim();
|
||||||
|
if (!v) {
|
||||||
|
Alert.alert('Ville', 'Renseigne la ville avant de sauvegarder les notes.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await saveMut.mutateAsync({ ville: v, notes });
|
||||||
|
} catch (e) {
|
||||||
|
Alert.alert('Erreur', formatPocketBaseError(e));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ScrollView className="flex-1 px-3 pt-2" contentContainerStyle={{ paddingBottom: 120 }} keyboardShouldPersistTaps="handled">
|
||||||
|
<Text className="text-base font-semibold" style={{ color: UI.text }}>
|
||||||
|
Ville / commune
|
||||||
|
</Text>
|
||||||
|
<TextInput
|
||||||
|
className="mt-2 rounded-2xl border-2 px-4 text-lg"
|
||||||
|
style={{ borderColor: UI.border, color: UI.text, minHeight: 52, backgroundColor: UI.card }}
|
||||||
|
placeholder="Ex. Lyon 3e, Bordeaux…"
|
||||||
|
placeholderTextColor={UI.textMuted}
|
||||||
|
value={ville}
|
||||||
|
onChangeText={setVille}
|
||||||
|
onSubmitEditing={onAnalyser}
|
||||||
|
/>
|
||||||
|
<Pressable
|
||||||
|
accessibilityRole="button"
|
||||||
|
onPress={onAnalyser}
|
||||||
|
className="mt-3 min-h-[52px] items-center justify-center rounded-2xl active:opacity-90"
|
||||||
|
style={{ backgroundColor: UI.primary }}
|
||||||
|
>
|
||||||
|
<Text className="text-lg font-bold text-white">Analyser</Text>
|
||||||
|
</Pressable>
|
||||||
|
|
||||||
|
<Text className="mb-2 mt-8 text-xl font-bold" style={{ color: UI.text }}>
|
||||||
|
Outils marché
|
||||||
|
</Text>
|
||||||
|
<Text className="mb-3 text-base" style={{ color: UI.textMuted }}>
|
||||||
|
Données externes — ouverture dans le navigateur.
|
||||||
|
</Text>
|
||||||
|
{SECTOR_TOOLS.map((tool) => (
|
||||||
|
<Pressable
|
||||||
|
key={tool.id}
|
||||||
|
accessibilityRole="button"
|
||||||
|
onPress={() => void onOpenTool(tool)}
|
||||||
|
className="mb-3 flex-row items-center rounded-2xl border-2 bg-white p-4 active:opacity-90"
|
||||||
|
style={{ borderColor: UI.border }}
|
||||||
|
>
|
||||||
|
<View
|
||||||
|
className="mr-3 h-12 w-12 items-center justify-center rounded-xl"
|
||||||
|
style={{ backgroundColor: '#EFF6FF' }}
|
||||||
|
>
|
||||||
|
<Ionicons name={tool.icon as IonName} size={24} color={UI.primary} />
|
||||||
|
</View>
|
||||||
|
<View className="min-w-0 flex-1">
|
||||||
|
<Text className="text-lg font-bold" style={{ color: UI.text }}>
|
||||||
|
{tool.title}
|
||||||
|
</Text>
|
||||||
|
<Text className="mt-1 text-base leading-5" style={{ color: UI.textMuted }}>
|
||||||
|
{tool.description}
|
||||||
|
</Text>
|
||||||
|
<View className="mt-2 self-start rounded-full px-2 py-1" style={{ backgroundColor: '#E0E7FF' }}>
|
||||||
|
<Text className="text-xs font-bold" style={{ color: UI.primary }}>
|
||||||
|
Externe →
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</Pressable>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<Text className="mb-2 mt-4 text-xl font-bold" style={{ color: UI.text }}>
|
||||||
|
Notes secteur
|
||||||
|
</Text>
|
||||||
|
{secteurQ.isFetching ? <ActivityIndicator color={UI.primary} className="mb-2" /> : null}
|
||||||
|
<TextInput
|
||||||
|
className="min-h-[140px] rounded-2xl border-2 px-4 py-3 text-lg"
|
||||||
|
style={{ borderColor: UI.border, color: UI.text, backgroundColor: UI.card }}
|
||||||
|
multiline
|
||||||
|
textAlignVertical="top"
|
||||||
|
placeholder="Synthèse prix, tension, typologie…"
|
||||||
|
placeholderTextColor={UI.textMuted}
|
||||||
|
value={notes}
|
||||||
|
onChangeText={setNotes}
|
||||||
|
/>
|
||||||
|
<Pressable
|
||||||
|
accessibilityRole="button"
|
||||||
|
onPress={() => void onSaveNotes()}
|
||||||
|
disabled={saveMut.isPending}
|
||||||
|
className="mt-3 min-h-[52px] items-center justify-center rounded-2xl active:opacity-90"
|
||||||
|
style={{ backgroundColor: UI.success }}
|
||||||
|
>
|
||||||
|
{saveMut.isPending ? (
|
||||||
|
<ActivityIndicator color="#fff" />
|
||||||
|
) : (
|
||||||
|
<Text className="text-lg font-bold text-white">Sauvegarder</Text>
|
||||||
|
)}
|
||||||
|
</Pressable>
|
||||||
|
</ScrollView>
|
||||||
|
);
|
||||||
|
}
|
||||||
433
app/components/recherche/VeilleAgentsTab.tsx
Normal file
@ -0,0 +1,433 @@
|
|||||||
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { useCallback, useState } from 'react';
|
||||||
|
import {
|
||||||
|
ActivityIndicator,
|
||||||
|
Alert,
|
||||||
|
Pressable,
|
||||||
|
RefreshControl,
|
||||||
|
ScrollView,
|
||||||
|
Text,
|
||||||
|
TextInput,
|
||||||
|
View,
|
||||||
|
} from 'react-native';
|
||||||
|
|
||||||
|
import { UI } from '@/constants/uiTheme';
|
||||||
|
import {
|
||||||
|
agentAlertesScan,
|
||||||
|
agentDvf,
|
||||||
|
agentImmobilier,
|
||||||
|
agentMarchand,
|
||||||
|
agentRedaction,
|
||||||
|
agentVeille,
|
||||||
|
} from '@/services/agentsApi';
|
||||||
|
import { getCurrentUserId, pb } from '@/services/pocketbase';
|
||||||
|
import type {
|
||||||
|
AlerteRechercheRecord,
|
||||||
|
AnnonceVeilleRecord,
|
||||||
|
CourrierImmobilierRecord,
|
||||||
|
RechercheSauvegardeeRecord,
|
||||||
|
TransactionSecteurRecord,
|
||||||
|
} from '@/types/collections';
|
||||||
|
import { formatPocketBaseError } from '@/utils/pocketbaseErrors';
|
||||||
|
|
||||||
|
function showLong(title: string, body: string) {
|
||||||
|
Alert.alert(title, body.length > 3500 ? `${body.slice(0, 3500)}…` : body);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function VeilleAgentsTab() {
|
||||||
|
const uid = getCurrentUserId();
|
||||||
|
const qc = useQueryClient();
|
||||||
|
|
||||||
|
const [nomRecherche, setNomRecherche] = useState('');
|
||||||
|
const [critereJson, setCritereJson] = useState('{}');
|
||||||
|
const [titreAnnonce, setTitreAnnonce] = useState('');
|
||||||
|
const [urlAnnonce, setUrlAnnonce] = useState('');
|
||||||
|
const [libelleDvf, setLibelleDvf] = useState('');
|
||||||
|
const [prixM2, setPrixM2] = useState('');
|
||||||
|
const [nbVentes, setNbVentes] = useState('');
|
||||||
|
|
||||||
|
const invalidateVeille = useCallback(() => {
|
||||||
|
void qc.invalidateQueries({ queryKey: ['veille'] });
|
||||||
|
}, [qc]);
|
||||||
|
|
||||||
|
const recherches = useQuery({
|
||||||
|
queryKey: ['veille', 'recherches_sauvegardees', uid],
|
||||||
|
queryFn: () =>
|
||||||
|
pb.collection('recherches_sauvegardees').getFullList<RechercheSauvegardeeRecord>({ sort: '-updated' }),
|
||||||
|
enabled: Boolean(uid),
|
||||||
|
});
|
||||||
|
|
||||||
|
const alertes = useQuery({
|
||||||
|
queryKey: ['veille', 'alertes_recherche', uid],
|
||||||
|
queryFn: () => pb.collection('alertes_recherche').getFullList<AlerteRechercheRecord>({ sort: '-updated' }),
|
||||||
|
enabled: Boolean(uid),
|
||||||
|
});
|
||||||
|
|
||||||
|
const annonces = useQuery({
|
||||||
|
queryKey: ['veille', 'annonces_veille', uid],
|
||||||
|
queryFn: () => pb.collection('annonces_veille').getFullList<AnnonceVeilleRecord>({ sort: '-updated' }),
|
||||||
|
enabled: Boolean(uid),
|
||||||
|
});
|
||||||
|
|
||||||
|
const trans = useQuery({
|
||||||
|
queryKey: ['veille', 'transactions_secteur', uid],
|
||||||
|
queryFn: () => pb.collection('transactions_secteur').getFullList<TransactionSecteurRecord>({ sort: '-updated' }),
|
||||||
|
enabled: Boolean(uid),
|
||||||
|
});
|
||||||
|
|
||||||
|
const courriers = useQuery({
|
||||||
|
queryKey: ['veille', 'courriers_immobilier', uid],
|
||||||
|
queryFn: () => pb.collection('courriers_immobilier').getFullList<CourrierImmobilierRecord>({ sort: '-updated' }),
|
||||||
|
enabled: Boolean(uid),
|
||||||
|
});
|
||||||
|
|
||||||
|
const createRecherche = useMutation({
|
||||||
|
mutationFn: async () => {
|
||||||
|
if (!uid) throw new Error('Non connecté');
|
||||||
|
const nom = nomRecherche.trim();
|
||||||
|
if (!nom) throw new Error('Nom requis');
|
||||||
|
return pb.collection('recherches_sauvegardees').create<RechercheSauvegardeeRecord>({
|
||||||
|
user: uid,
|
||||||
|
nom,
|
||||||
|
critere_json: critereJson.trim() || '{}',
|
||||||
|
actif: true,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
setNomRecherche('');
|
||||||
|
invalidateVeille();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const createAlerteSimple = useMutation({
|
||||||
|
mutationFn: async () => {
|
||||||
|
if (!uid) throw new Error('Non connecté');
|
||||||
|
return pb.collection('alertes_recherche').create<AlerteRechercheRecord>({
|
||||||
|
user: uid,
|
||||||
|
nom: `Veille ${new Date().toLocaleDateString('fr-FR')}`,
|
||||||
|
canal: 'in_app',
|
||||||
|
actif: true,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onSuccess: invalidateVeille,
|
||||||
|
onError: (err) => Alert.alert('Erreur', formatPocketBaseError(err)),
|
||||||
|
});
|
||||||
|
|
||||||
|
const scanAlertes = useMutation({
|
||||||
|
mutationFn: () => agentAlertesScan(),
|
||||||
|
onSuccess: (r) => {
|
||||||
|
invalidateVeille();
|
||||||
|
Alert.alert('Scan alertes', `${r.processed} alertes mises à jour.\n${r.note ?? ''}`);
|
||||||
|
},
|
||||||
|
onError: (e) => Alert.alert('Erreur', formatPocketBaseError(e)),
|
||||||
|
});
|
||||||
|
|
||||||
|
const pushAnnonce = useMutation({
|
||||||
|
mutationFn: () =>
|
||||||
|
agentVeille({
|
||||||
|
titre: titreAnnonce.trim(),
|
||||||
|
url: urlAnnonce.trim() || undefined,
|
||||||
|
source: 'manuel',
|
||||||
|
}),
|
||||||
|
onSuccess: (r) => {
|
||||||
|
setTitreAnnonce('');
|
||||||
|
setUrlAnnonce('');
|
||||||
|
invalidateVeille();
|
||||||
|
Alert.alert('Veille', r.dedupe ? 'Doublon ignoré.' : `Enregistrée (${r.id}).`);
|
||||||
|
},
|
||||||
|
onError: (e) => Alert.alert('Erreur', formatPocketBaseError(e)),
|
||||||
|
});
|
||||||
|
|
||||||
|
const pushDvf = useMutation({
|
||||||
|
mutationFn: () => {
|
||||||
|
const lib = libelleDvf.trim();
|
||||||
|
if (!lib) throw new Error('Libellé requis');
|
||||||
|
const pm = Number(String(prixM2).replace(',', '.'));
|
||||||
|
const nv = Number(String(nbVentes).trim());
|
||||||
|
return agentDvf({
|
||||||
|
libelle: lib,
|
||||||
|
prix_m2_median: Number.isFinite(pm) ? pm : undefined,
|
||||||
|
nb_ventes: Number.isFinite(nv) ? nv : undefined,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onSuccess: (r) => {
|
||||||
|
setLibelleDvf('');
|
||||||
|
setPrixM2('');
|
||||||
|
setNbVentes('');
|
||||||
|
invalidateVeille();
|
||||||
|
showLong('Synthèse marché', r.synthese);
|
||||||
|
},
|
||||||
|
onError: (e) => Alert.alert('Erreur', formatPocketBaseError(e)),
|
||||||
|
});
|
||||||
|
|
||||||
|
const runImmobilier = useMutation({
|
||||||
|
mutationFn: () =>
|
||||||
|
agentImmobilier({
|
||||||
|
objectif: 'Prospection ciblée secteur',
|
||||||
|
contexte: 'Génère un plan + un message court pour relancer des mandataires potentiels.',
|
||||||
|
save: false,
|
||||||
|
}),
|
||||||
|
onSuccess: (r) => showLong('Agent immobilier', r.brouillon),
|
||||||
|
onError: (e) => Alert.alert('Erreur', formatPocketBaseError(e)),
|
||||||
|
});
|
||||||
|
|
||||||
|
const runMarchand = useMutation({
|
||||||
|
mutationFn: () =>
|
||||||
|
agentMarchand({
|
||||||
|
titre: titreAnnonce.trim() || 'Annonce test',
|
||||||
|
notes: 'Comparer avec ma grille perso (onglet Grille de prix).',
|
||||||
|
}),
|
||||||
|
onSuccess: (r) => showLong('Agent marchand de biens', r.analyse),
|
||||||
|
onError: (e) => Alert.alert('Erreur', formatPocketBaseError(e)),
|
||||||
|
});
|
||||||
|
|
||||||
|
const runRedaction = useMutation({
|
||||||
|
mutationFn: () =>
|
||||||
|
agentRedaction({
|
||||||
|
kind: 'annonce_agence',
|
||||||
|
bullets: ['Lumineux', 'Proche transports', 'Charges faibles'],
|
||||||
|
save: false,
|
||||||
|
}),
|
||||||
|
onSuccess: (r) => showLong('Agent rédaction', r.texte),
|
||||||
|
onError: (e) => Alert.alert('Erreur', formatPocketBaseError(e)),
|
||||||
|
});
|
||||||
|
|
||||||
|
const loading =
|
||||||
|
recherches.isPending ||
|
||||||
|
alertes.isPending ||
|
||||||
|
annonces.isPending ||
|
||||||
|
trans.isPending ||
|
||||||
|
courriers.isPending;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ScrollView
|
||||||
|
className="flex-1 px-4 py-3"
|
||||||
|
style={{ backgroundColor: UI.screen }}
|
||||||
|
refreshControl={
|
||||||
|
<RefreshControl
|
||||||
|
refreshing={loading}
|
||||||
|
onRefresh={() => {
|
||||||
|
void recherches.refetch();
|
||||||
|
void alertes.refetch();
|
||||||
|
void annonces.refetch();
|
||||||
|
void trans.refetch();
|
||||||
|
void courriers.refetch();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Text className="mb-2 text-lg font-bold" style={{ color: UI.text }}>
|
||||||
|
Agents IA (MVP)
|
||||||
|
</Text>
|
||||||
|
<Text className="mb-4 text-sm" style={{ color: UI.textMuted }}>
|
||||||
|
Connexion serveur + clé Anthropic requises. Les données restent dans PocketBase.
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<View className="mb-6 gap-2">
|
||||||
|
<Text className="font-semibold" style={{ color: UI.text }}>
|
||||||
|
Lancer un agent
|
||||||
|
</Text>
|
||||||
|
<View className="flex-row flex-wrap gap-2">
|
||||||
|
<AgentButton label="Immobilier" loading={runImmobilier.isPending} onPress={() => runImmobilier.mutate()} />
|
||||||
|
<AgentButton
|
||||||
|
label="Marchand"
|
||||||
|
loading={runMarchand.isPending}
|
||||||
|
onPress={() => runMarchand.mutate()}
|
||||||
|
/>
|
||||||
|
<AgentButton label="Rédaction" loading={runRedaction.isPending} onPress={() => runRedaction.mutate()} />
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View className="mb-6 rounded-lg border p-3" style={{ borderColor: UI.border, backgroundColor: UI.card }}>
|
||||||
|
<Text className="mb-2 font-semibold" style={{ color: UI.text }}>
|
||||||
|
Agent data / secteur (stub DVF)
|
||||||
|
</Text>
|
||||||
|
<TextInput
|
||||||
|
className="mb-2 rounded border px-2 py-2"
|
||||||
|
style={{ borderColor: UI.border, color: UI.text }}
|
||||||
|
placeholder="Libellé zone (ex. Lyon 6e)"
|
||||||
|
placeholderTextColor={UI.textMuted}
|
||||||
|
value={libelleDvf}
|
||||||
|
onChangeText={setLibelleDvf}
|
||||||
|
/>
|
||||||
|
<View className="mb-2 flex-row gap-2">
|
||||||
|
<TextInput
|
||||||
|
className="flex-1 rounded border px-2 py-2"
|
||||||
|
style={{ borderColor: UI.border, color: UI.text }}
|
||||||
|
placeholder="Prix m² médian"
|
||||||
|
placeholderTextColor={UI.textMuted}
|
||||||
|
keyboardType="decimal-pad"
|
||||||
|
value={prixM2}
|
||||||
|
onChangeText={setPrixM2}
|
||||||
|
/>
|
||||||
|
<TextInput
|
||||||
|
className="flex-1 rounded border px-2 py-2"
|
||||||
|
style={{ borderColor: UI.border, color: UI.text }}
|
||||||
|
placeholder="Nb ventes"
|
||||||
|
placeholderTextColor={UI.textMuted}
|
||||||
|
keyboardType="number-pad"
|
||||||
|
value={nbVentes}
|
||||||
|
onChangeText={setNbVentes}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
<Pressable
|
||||||
|
className="items-center rounded-lg py-3"
|
||||||
|
style={{ backgroundColor: UI.primary }}
|
||||||
|
onPress={() => pushDvf.mutate()}
|
||||||
|
disabled={pushDvf.isPending || !libelleDvf.trim()}
|
||||||
|
>
|
||||||
|
{pushDvf.isPending ? (
|
||||||
|
<ActivityIndicator color="#fff" />
|
||||||
|
) : (
|
||||||
|
<Text className="font-semibold text-white">Enregistrer + synthèse IA</Text>
|
||||||
|
)}
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View className="mb-6 rounded-lg border p-3" style={{ borderColor: UI.border, backgroundColor: UI.card }}>
|
||||||
|
<Text className="mb-2 font-semibold" style={{ color: UI.text }}>
|
||||||
|
Agent veille (dédoublonnage MD5)
|
||||||
|
</Text>
|
||||||
|
<TextInput
|
||||||
|
className="mb-2 rounded border px-2 py-2"
|
||||||
|
style={{ borderColor: UI.border, color: UI.text }}
|
||||||
|
placeholder="Titre annonce"
|
||||||
|
placeholderTextColor={UI.textMuted}
|
||||||
|
value={titreAnnonce}
|
||||||
|
onChangeText={setTitreAnnonce}
|
||||||
|
/>
|
||||||
|
<TextInput
|
||||||
|
className="mb-2 rounded border px-2 py-2"
|
||||||
|
style={{ borderColor: UI.border, color: UI.text }}
|
||||||
|
placeholder="URL (optionnel)"
|
||||||
|
placeholderTextColor={UI.textMuted}
|
||||||
|
value={urlAnnonce}
|
||||||
|
onChangeText={setUrlAnnonce}
|
||||||
|
autoCapitalize="none"
|
||||||
|
/>
|
||||||
|
<Pressable
|
||||||
|
className="items-center rounded-lg py-3"
|
||||||
|
style={{ backgroundColor: UI.primary }}
|
||||||
|
onPress={() => pushAnnonce.mutate()}
|
||||||
|
disabled={pushAnnonce.isPending || !titreAnnonce.trim()}
|
||||||
|
>
|
||||||
|
{pushAnnonce.isPending ? (
|
||||||
|
<ActivityIndicator color="#fff" />
|
||||||
|
) : (
|
||||||
|
<Text className="font-semibold text-white">Ajouter à la veille</Text>
|
||||||
|
)}
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View className="mb-6 rounded-lg border p-3" style={{ borderColor: UI.border, backgroundColor: UI.card }}>
|
||||||
|
<Text className="mb-2 font-semibold" style={{ color: UI.text }}>
|
||||||
|
Recherches sauvegardées
|
||||||
|
</Text>
|
||||||
|
<TextInput
|
||||||
|
className="mb-2 rounded border px-2 py-2"
|
||||||
|
style={{ borderColor: UI.border, color: UI.text }}
|
||||||
|
placeholder="Nom"
|
||||||
|
placeholderTextColor={UI.textMuted}
|
||||||
|
value={nomRecherche}
|
||||||
|
onChangeText={setNomRecherche}
|
||||||
|
/>
|
||||||
|
<TextInput
|
||||||
|
className="mb-2 rounded border px-2 py-2"
|
||||||
|
style={{ borderColor: UI.border, color: UI.text }}
|
||||||
|
placeholder='Critères JSON (ex. {"prix_max":250000})'
|
||||||
|
placeholderTextColor={UI.textMuted}
|
||||||
|
value={critereJson}
|
||||||
|
onChangeText={setCritereJson}
|
||||||
|
multiline
|
||||||
|
/>
|
||||||
|
<Pressable
|
||||||
|
className="mb-3 items-center rounded-lg py-2"
|
||||||
|
style={{ backgroundColor: UI.border }}
|
||||||
|
onPress={() => createRecherche.mutate()}
|
||||||
|
disabled={createRecherche.isPending}
|
||||||
|
>
|
||||||
|
<Text style={{ color: UI.text }}>Créer la recherche</Text>
|
||||||
|
</Pressable>
|
||||||
|
{recherches.data?.map((r) => (
|
||||||
|
<Text key={r.id} className="text-sm" style={{ color: UI.textMuted }}>
|
||||||
|
• {r.nom}
|
||||||
|
</Text>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View className="mb-6 rounded-lg border p-3" style={{ borderColor: UI.border, backgroundColor: UI.card }}>
|
||||||
|
<Text className="mb-2 font-semibold" style={{ color: UI.text }}>
|
||||||
|
Agent alertes (stub scan)
|
||||||
|
</Text>
|
||||||
|
<Pressable
|
||||||
|
className="mb-2 items-center rounded-lg py-2"
|
||||||
|
style={{ backgroundColor: UI.border }}
|
||||||
|
onPress={() => createAlerteSimple.mutate()}
|
||||||
|
disabled={createAlerteSimple.isPending}
|
||||||
|
>
|
||||||
|
<Text style={{ color: UI.text }}>Nouvelle alerte in-app</Text>
|
||||||
|
</Pressable>
|
||||||
|
<Pressable
|
||||||
|
className="items-center rounded-lg py-2"
|
||||||
|
style={{ backgroundColor: UI.border }}
|
||||||
|
onPress={() => scanAlertes.mutate()}
|
||||||
|
disabled={scanAlertes.isPending}
|
||||||
|
>
|
||||||
|
{scanAlertes.isPending ? (
|
||||||
|
<ActivityIndicator />
|
||||||
|
) : (
|
||||||
|
<Text style={{ color: UI.text }}>Scanner mes alertes actives</Text>
|
||||||
|
)}
|
||||||
|
</Pressable>
|
||||||
|
{alertes.data?.map((a) => (
|
||||||
|
<Text key={a.id} className="mt-1 text-sm" style={{ color: UI.textMuted }}>
|
||||||
|
• {a.nom} ({a.canal}) {a.actif === false ? '— off' : ''}
|
||||||
|
</Text>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<Text className="mb-1 font-semibold" style={{ color: UI.text }}>
|
||||||
|
Annonces veille ({annonces.data?.length ?? 0})
|
||||||
|
</Text>
|
||||||
|
{annonces.data?.slice(0, 8).map((a) => (
|
||||||
|
<Text key={a.id} className="text-sm" style={{ color: UI.textMuted }}>
|
||||||
|
[{a.statut}] {a.titre}
|
||||||
|
</Text>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<Text className="mb-1 mt-4 font-semibold" style={{ color: UI.text }}>
|
||||||
|
Transactions secteur ({trans.data?.length ?? 0})
|
||||||
|
</Text>
|
||||||
|
{trans.data?.slice(0, 6).map((t) => (
|
||||||
|
<Text key={t.id} className="text-sm" style={{ color: UI.textMuted }}>
|
||||||
|
{t.libelle}
|
||||||
|
{t.prix_m2_median != null ? ` — ${t.prix_m2_median} €/m²` : ''}
|
||||||
|
</Text>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<Text className="mb-1 mt-4 font-semibold" style={{ color: UI.text }}>
|
||||||
|
Courriers ({courriers.data?.length ?? 0})
|
||||||
|
</Text>
|
||||||
|
{courriers.data?.slice(0, 5).map((c) => (
|
||||||
|
<Text key={c.id} className="text-sm" style={{ color: UI.textMuted }}>
|
||||||
|
{c.titre} ({c.kind}/{c.etat})
|
||||||
|
</Text>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<View className="h-24" />
|
||||||
|
</ScrollView>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AgentButton(props: { label: string; loading: boolean; onPress: () => void }) {
|
||||||
|
return (
|
||||||
|
<Pressable
|
||||||
|
className="rounded-lg px-4 py-2"
|
||||||
|
style={{ backgroundColor: UI.primary }}
|
||||||
|
onPress={props.onPress}
|
||||||
|
disabled={props.loading}
|
||||||
|
>
|
||||||
|
{props.loading ? <ActivityIndicator color="#fff" size="small" /> : <Text className="text-white">{props.label}</Text>}
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
}
|
||||||
34
app/components/ui/DashboardSkeleton.tsx
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
import { View } from 'react-native';
|
||||||
|
|
||||||
|
import { UI } from '@/constants/uiTheme';
|
||||||
|
|
||||||
|
export function DashboardSkeleton() {
|
||||||
|
return (
|
||||||
|
<View className="p-4" accessibilityLabel="Chargement du tableau de bord">
|
||||||
|
<View className="mb-2 h-7 w-[55%] rounded-lg" style={{ backgroundColor: '#E2E8F0' }} />
|
||||||
|
<View className="mb-4 h-24 rounded-2xl border" style={{ borderColor: UI.border, backgroundColor: UI.card }} />
|
||||||
|
<View className="mb-2 h-7 w-[40%] rounded-lg" style={{ backgroundColor: '#E2E8F0' }} />
|
||||||
|
<View className="mb-4 flex-row gap-3">
|
||||||
|
{[1, 2, 3].map((k) => (
|
||||||
|
<View
|
||||||
|
key={k}
|
||||||
|
className="h-24 flex-1 rounded-2xl border"
|
||||||
|
style={{ borderColor: UI.border, backgroundColor: UI.card }}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
<View className="mb-2 h-7 w-[35%] rounded-lg" style={{ backgroundColor: '#E2E8F0' }} />
|
||||||
|
<View className="mb-4 h-20 rounded-2xl border" style={{ borderColor: UI.border, backgroundColor: '#F8FAFC' }} />
|
||||||
|
<View className="mb-2 h-7 w-[45%] rounded-lg" style={{ backgroundColor: '#E2E8F0' }} />
|
||||||
|
<View className="gap-3">
|
||||||
|
{[1, 2, 3].map((k) => (
|
||||||
|
<View
|
||||||
|
key={k}
|
||||||
|
className="h-16 rounded-2xl border"
|
||||||
|
style={{ borderColor: UI.border, backgroundColor: UI.card }}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
57
app/components/ui/EmptyState.tsx
Normal file
@ -0,0 +1,57 @@
|
|||||||
|
import { Link, type Href } from 'expo-router';
|
||||||
|
import { Pressable, Text, View } from 'react-native';
|
||||||
|
|
||||||
|
import { UI } from '@/constants/uiTheme';
|
||||||
|
|
||||||
|
export type EmptyStateProps = {
|
||||||
|
title: string;
|
||||||
|
description?: string;
|
||||||
|
actionLabel?: string;
|
||||||
|
actionHref?: Href;
|
||||||
|
onAction?: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function EmptyState({ title, description, actionLabel, actionHref, onAction }: EmptyStateProps) {
|
||||||
|
const actionNode =
|
||||||
|
actionLabel && actionHref != null ? (
|
||||||
|
<Link href={actionHref} asChild>
|
||||||
|
<Pressable
|
||||||
|
accessibilityRole="button"
|
||||||
|
className="mt-6 min-h-[52px] w-full max-w-sm items-center justify-center rounded-2xl px-5 active:opacity-90"
|
||||||
|
style={{ backgroundColor: UI.primary }}
|
||||||
|
>
|
||||||
|
<Text className="text-center text-lg font-semibold text-white">{actionLabel}</Text>
|
||||||
|
</Pressable>
|
||||||
|
</Link>
|
||||||
|
) : actionLabel && onAction ? (
|
||||||
|
<Pressable
|
||||||
|
accessibilityRole="button"
|
||||||
|
onPress={onAction}
|
||||||
|
className="mt-6 min-h-[52px] w-full max-w-sm items-center justify-center rounded-2xl px-5 active:opacity-90"
|
||||||
|
style={{ backgroundColor: UI.primary }}
|
||||||
|
>
|
||||||
|
<Text className="text-center text-lg font-semibold text-white">{actionLabel}</Text>
|
||||||
|
</Pressable>
|
||||||
|
) : null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View className="items-center justify-center px-5 py-10">
|
||||||
|
<Text
|
||||||
|
className="text-center text-xl font-bold leading-7"
|
||||||
|
style={{ color: UI.text }}
|
||||||
|
accessibilityRole="header"
|
||||||
|
>
|
||||||
|
{title}
|
||||||
|
</Text>
|
||||||
|
{description ? (
|
||||||
|
<Text
|
||||||
|
className="mt-3 max-w-sm text-center text-base leading-6"
|
||||||
|
style={{ color: UI.textMuted }}
|
||||||
|
>
|
||||||
|
{description}
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
{actionNode}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
39
app/components/ui/ListSkeleton.tsx
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
import { View } from 'react-native';
|
||||||
|
|
||||||
|
import { UI } from '@/constants/uiTheme';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
rows?: number;
|
||||||
|
/** Hauteur des cartes (liste verticale). */
|
||||||
|
variant?: 'card' | 'compact';
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ListSkeleton({ rows = 6, variant = 'card' }: Props) {
|
||||||
|
const gap = variant === 'compact' ? 10 : 14;
|
||||||
|
const pad = variant === 'compact' ? 12 : 16;
|
||||||
|
return (
|
||||||
|
<View className="px-3 pt-3" accessibilityLabel="Chargement en cours">
|
||||||
|
{Array.from({ length: rows }).map((_, i) => (
|
||||||
|
<View
|
||||||
|
key={i}
|
||||||
|
className="mb-3 rounded-2xl border bg-white"
|
||||||
|
style={{
|
||||||
|
borderColor: UI.border,
|
||||||
|
padding: pad,
|
||||||
|
gap,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<View
|
||||||
|
className="h-5 rounded-md"
|
||||||
|
style={{ width: '62%', backgroundColor: '#E2E8F0' }}
|
||||||
|
/>
|
||||||
|
<View className="h-4 rounded-md" style={{ width: '92%', backgroundColor: '#F1F5F9' }} />
|
||||||
|
<View className="h-4 rounded-md" style={{ width: '78%', backgroundColor: '#F1F5F9' }} />
|
||||||
|
{variant === 'card' ? (
|
||||||
|
<View className="h-4 rounded-md" style={{ width: '44%', backgroundColor: '#E2E8F0' }} />
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
37
app/components/ui/PipelineSkeleton.tsx
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
import { ScrollView, View } from 'react-native';
|
||||||
|
|
||||||
|
import { UI } from '@/constants/uiTheme';
|
||||||
|
|
||||||
|
const COL_W = 216;
|
||||||
|
|
||||||
|
export function PipelineSkeleton() {
|
||||||
|
return (
|
||||||
|
<ScrollView
|
||||||
|
horizontal
|
||||||
|
className="flex-1"
|
||||||
|
contentContainerStyle={{ padding: 12, paddingBottom: 96 }}
|
||||||
|
accessibilityLabel="Chargement du pipeline"
|
||||||
|
>
|
||||||
|
{Array.from({ length: 4 }).map((_, col) => (
|
||||||
|
<View
|
||||||
|
key={col}
|
||||||
|
className="mr-3 rounded-2xl border bg-white p-3"
|
||||||
|
style={{ width: COL_W, borderColor: UI.border }}
|
||||||
|
>
|
||||||
|
<View className="mb-3 h-6 w-[85%] rounded-md" style={{ backgroundColor: '#E2E8F0' }} />
|
||||||
|
<View className="mb-2 h-3 w-10 rounded" style={{ backgroundColor: '#CBD5E1' }} />
|
||||||
|
{Array.from({ length: 3 }).map((__, row) => (
|
||||||
|
<View
|
||||||
|
key={row}
|
||||||
|
className="mb-2 rounded-xl border p-3"
|
||||||
|
style={{ borderColor: '#E2E8F0', backgroundColor: '#F8FAFC' }}
|
||||||
|
>
|
||||||
|
<View className="mb-2 h-4 w-[90%] rounded" style={{ backgroundColor: '#E2E8F0' }} />
|
||||||
|
<View className="h-3 w-[55%] rounded" style={{ backgroundColor: '#F1F5F9' }} />
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
</ScrollView>
|
||||||
|
);
|
||||||
|
}
|
||||||
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' },
|
||||||
|
};
|
||||||
103
app/constants/rechercheMarche.ts
Normal file
@ -0,0 +1,103 @@
|
|||||||
|
export type SectorTool = {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
url: string;
|
||||||
|
/** Nom Ionicons (outline). */
|
||||||
|
icon: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const SECTOR_TOOLS: SectorTool[] = [
|
||||||
|
{
|
||||||
|
id: 'ma',
|
||||||
|
title: 'Prix au m² — Meilleurs Agents',
|
||||||
|
description: 'Prix moyen, évolution, comparaison communes',
|
||||||
|
url: 'https://www.meilleursagents.com/prix-immobilier/',
|
||||||
|
icon: 'stats-chart-outline',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'dvf',
|
||||||
|
title: 'Transactions réelles — DVF',
|
||||||
|
description: 'Prix de vente réels, toutes transactions',
|
||||||
|
url: 'https://dvf.etalab.gouv.fr/',
|
||||||
|
icon: 'document-text-outline',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'moteur',
|
||||||
|
title: 'Annonces actives — Moteur Immo',
|
||||||
|
description: 'Agrège tous les sites, historique baisses de prix',
|
||||||
|
url: 'https://www.moteurimmo.fr/',
|
||||||
|
icon: 'search-outline',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'insee',
|
||||||
|
title: 'Données INSEE',
|
||||||
|
description: 'Revenus médians, démographie, vacance logements',
|
||||||
|
url: 'https://www.insee.fr/fr/statistiques/zones/1405599',
|
||||||
|
icon: 'business-outline',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'geo',
|
||||||
|
title: 'Carte & PLU — Géoportail',
|
||||||
|
description: 'Transports, écoles, zones PLU, foncier',
|
||||||
|
url: 'https://www.geoportail.gouv.fr/',
|
||||||
|
icon: 'map-outline',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'pappers',
|
||||||
|
title: 'Concurrence MDB — Pappers Immo',
|
||||||
|
description: 'Historique transactions, propriétaires, sociétés actives',
|
||||||
|
url: 'https://immobilier.pappers.fr/',
|
||||||
|
icon: 'people-outline',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export const OFF_MARKET_KEYWORDS: { id: string; label: string; text: string }[] = [
|
||||||
|
{
|
||||||
|
id: 'k1',
|
||||||
|
label: 'Pack division studios',
|
||||||
|
text: '"studio" + "lots réunis" + "vendu libre" + "deux studios"',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'k2',
|
||||||
|
label: 'Multi-lots / réunion',
|
||||||
|
text: '"appartements réunis" + "configuration possible" + "immeuble" + "multi-lots"',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export const PROSPECTION_CHECKLIST: { id: string; label: string; question: string }[] = [
|
||||||
|
{ id: 'agent_immo', label: 'Agents immo', question: 'À combien ça part vraiment ? C’est rapide à vendre ?' },
|
||||||
|
{ id: 'notaire', label: 'Notaires', question: 'Quelles tendances dans vos actes récents ?' },
|
||||||
|
{ id: 'geometre', label: 'Géomètres', question: 'Divisions fréquentes sur ce secteur ?' },
|
||||||
|
{ id: 'banquier', label: 'Banquiers', question: 'Vous financez souvent des projets ici ?' },
|
||||||
|
{ id: 'mdb', label: 'Autres MDB', question: 'Tu trouves facilement sur ce secteur ?' },
|
||||||
|
{ id: 'artisan', label: 'Artisans', question: 'Vous travaillez beaucoup dans ce quartier ?' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const CATEGORIE_NOTES_PROSPECTION = 'recherche_opportunites';
|
||||||
|
|
||||||
|
export const LEGI_L151_36 =
|
||||||
|
'https://www.legifrance.gouv.fr/codes/article_lc/LEGIARTI000031211239';
|
||||||
|
export const LEGI_L152_6 =
|
||||||
|
'https://www.legifrance.gouv.fr/codes/article_lc/LEGIARTI000043978020/2021-08-25';
|
||||||
|
|
||||||
|
export function meilleursAgentsUrlForVille(ville: string): string {
|
||||||
|
const v = ville.trim();
|
||||||
|
if (!v) return SECTOR_TOOLS[0].url;
|
||||||
|
return `https://www.meilleursagents.com/prix-immobilier/${encodeURIComponent(v)}/`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function dvfSearchUrl(ville: string): string {
|
||||||
|
const v = ville.trim();
|
||||||
|
if (!v) return 'https://dvf.etalab.gouv.fr/';
|
||||||
|
return `https://dvf.etalab.gouv.fr/?q=${encodeURIComponent(v)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function geoportailBienUrl(lat: number, lon: number): string {
|
||||||
|
return `https://www.geoportail.gouv.fr/?lon=${lon}&lat=${lat}&z=17`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function marginPctFromPrices(achat: number, revente: number): number | null {
|
||||||
|
if (!Number.isFinite(achat) || !Number.isFinite(revente) || achat <= 0) return null;
|
||||||
|
return ((revente - achat) / achat) * 100;
|
||||||
|
}
|
||||||
12
app/constants/uiTheme.ts
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
/** Palette terrain / extérieur — contraste élevé, actions distinctes. */
|
||||||
|
export const UI = {
|
||||||
|
primary: '#1D4ED8',
|
||||||
|
success: '#16A34A',
|
||||||
|
warning: '#D97706',
|
||||||
|
danger: '#DC2626',
|
||||||
|
screen: '#F1F5F9',
|
||||||
|
card: '#FFFFFF',
|
||||||
|
text: '#0F172A',
|
||||||
|
textMuted: '#475569',
|
||||||
|
border: '#CBD5E1',
|
||||||
|
} as const;
|
||||||
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 },
|
|
||||||
});
|
|
||||||
197
app/hooks/useAnalyse.ts
Normal file
@ -0,0 +1,197 @@
|
|||||||
|
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;
|
||||||
|
/** Prix de revente estimé au m² (marché). */
|
||||||
|
prix_revente_m2?: 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,
|
||||||
|
...(form.prix_revente_m2 !== undefined && form.prix_revente_m2 !== null
|
||||||
|
? { prix_revente_m2: form.prix_revente_m2 }
|
||||||
|
: {}),
|
||||||
|
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] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const patchMutation = useMutation({
|
||||||
|
mutationFn: async (patch: { prix_revente_m2?: number | null }) => {
|
||||||
|
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];
|
||||||
|
if (existing) {
|
||||||
|
return pb.collection('analyses_financieres').update<AnalyseFinanciereRecord>(existing.id, patch);
|
||||||
|
}
|
||||||
|
return pb.collection('analyses_financieres').create<AnalyseFinanciereRecord>({
|
||||||
|
user: uid,
|
||||||
|
bien: bienId,
|
||||||
|
type_bien_fiscal: 'ancien',
|
||||||
|
...patch,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
void queryClient.invalidateQueries({ queryKey: ['analyse_financiere', bienId, uid] });
|
||||||
|
void queryClient.invalidateQueries({ queryKey: ['bien_detail', bienId] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
analyse: query.data ?? null,
|
||||||
|
isLoading: query.isPending,
|
||||||
|
error: query.error,
|
||||||
|
refetch: query.refetch,
|
||||||
|
fetchAnalyse: query.refetch,
|
||||||
|
saveAnalyse: saveMutation.mutateAsync,
|
||||||
|
isSaving: saveMutation.isPending,
|
||||||
|
patchAnalyse: patchMutation.mutateAsync,
|
||||||
|
isPatching: patchMutation.isPending,
|
||||||
|
calculateResults,
|
||||||
|
};
|
||||||
|
}
|
||||||
60
app/hooks/useAnalysesSecteur.ts
Normal file
@ -0,0 +1,60 @@
|
|||||||
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
|
|
||||||
|
import { getCurrentUserId, pb } from '@/services/pocketbase';
|
||||||
|
import type { AnalyseSecteurRecord } from '@/types/collections';
|
||||||
|
|
||||||
|
function escapeFilterValue(s: string): string {
|
||||||
|
return s.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAnalyseSecteurForVille(ville: string) {
|
||||||
|
const uid = getCurrentUserId();
|
||||||
|
const key = ville.trim().toLowerCase();
|
||||||
|
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['analyse_secteur', uid, key],
|
||||||
|
queryFn: async (): Promise<AnalyseSecteurRecord | null> => {
|
||||||
|
if (!uid || !key) return null;
|
||||||
|
const esc = escapeFilterValue(ville.trim());
|
||||||
|
const list = await pb.collection('analyses_secteur').getFullList<AnalyseSecteurRecord>({
|
||||||
|
filter: `user="${uid}" && ville="${esc}"`,
|
||||||
|
sort: '-updated',
|
||||||
|
});
|
||||||
|
return list[0] ?? null;
|
||||||
|
},
|
||||||
|
enabled: Boolean(uid && key),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useSaveAnalyseSecteur() {
|
||||||
|
const uid = getCurrentUserId();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async (payload: { ville: string; notes: string }) => {
|
||||||
|
if (!uid) throw new Error('Non connecté');
|
||||||
|
const ville = payload.ville.trim();
|
||||||
|
if (!ville) throw new Error('Ville requise');
|
||||||
|
const esc = escapeFilterValue(ville);
|
||||||
|
const existing = await pb.collection('analyses_secteur').getFullList<AnalyseSecteurRecord>({
|
||||||
|
filter: `user="${uid}" && ville="${esc}"`,
|
||||||
|
sort: '-updated',
|
||||||
|
});
|
||||||
|
const row = existing[0];
|
||||||
|
if (row) {
|
||||||
|
return pb.collection('analyses_secteur').update<AnalyseSecteurRecord>(row.id, {
|
||||||
|
notes: payload.notes,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return pb.collection('analyses_secteur').create<AnalyseSecteurRecord>({
|
||||||
|
user: uid,
|
||||||
|
ville,
|
||||||
|
notes: payload.notes,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onSuccess: (_, v) => {
|
||||||
|
const key = v.ville.trim().toLowerCase();
|
||||||
|
void queryClient.invalidateQueries({ queryKey: ['analyse_secteur', uid, key] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
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/useGrillePrix.ts
Normal file
@ -0,0 +1,79 @@
|
|||||||
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
|
|
||||||
|
import { marginPctFromPrices } from '@/constants/rechercheMarche';
|
||||||
|
import { getCurrentUserId, pb } from '@/services/pocketbase';
|
||||||
|
import type { GrillePrixEtat, GrillePrixRecord, GrillePrixTypeBien } from '@/types/collections';
|
||||||
|
|
||||||
|
export type GrillePrixInput = {
|
||||||
|
type_bien: GrillePrixTypeBien;
|
||||||
|
etat: GrillePrixEtat;
|
||||||
|
prix_achat_m2: number;
|
||||||
|
prix_revente_m2: number;
|
||||||
|
ville?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
function withMargin(input: GrillePrixInput): Record<string, unknown> {
|
||||||
|
const marge = marginPctFromPrices(input.prix_achat_m2, input.prix_revente_m2);
|
||||||
|
return {
|
||||||
|
type_bien: input.type_bien,
|
||||||
|
etat: input.etat,
|
||||||
|
prix_achat_m2: input.prix_achat_m2,
|
||||||
|
prix_revente_m2: input.prix_revente_m2,
|
||||||
|
ville: input.ville?.trim() || undefined,
|
||||||
|
marge_estimee_pct: marge != null ? Math.round(marge * 100) / 100 : undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useGrillePrix() {
|
||||||
|
const uid = getCurrentUserId();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
const query = useQuery({
|
||||||
|
queryKey: ['grille_prix', uid],
|
||||||
|
queryFn: async () => {
|
||||||
|
if (!uid) return [] as GrillePrixRecord[];
|
||||||
|
return pb.collection('grille_prix').getFullList<GrillePrixRecord>({
|
||||||
|
filter: `user="${uid}"`,
|
||||||
|
sort: '-updated',
|
||||||
|
});
|
||||||
|
},
|
||||||
|
enabled: Boolean(uid),
|
||||||
|
});
|
||||||
|
|
||||||
|
const invalidate = () => {
|
||||||
|
void queryClient.invalidateQueries({ queryKey: ['grille_prix', uid] });
|
||||||
|
};
|
||||||
|
|
||||||
|
const createRow = useMutation({
|
||||||
|
mutationFn: async (input: GrillePrixInput) => {
|
||||||
|
if (!uid) throw new Error('Non connecté');
|
||||||
|
return pb.collection('grille_prix').create<GrillePrixRecord>({
|
||||||
|
user: uid,
|
||||||
|
...withMargin(input),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onSuccess: invalidate,
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateRow = useMutation({
|
||||||
|
mutationFn: async ({ id, input }: { id: string; input: GrillePrixInput }) => {
|
||||||
|
return pb.collection('grille_prix').update<GrillePrixRecord>(id, withMargin(input));
|
||||||
|
},
|
||||||
|
onSuccess: invalidate,
|
||||||
|
});
|
||||||
|
|
||||||
|
const deleteRow = useMutation({
|
||||||
|
mutationFn: async (id: string) => pb.collection('grille_prix').delete(id),
|
||||||
|
onSuccess: invalidate,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
rows: query.data ?? [],
|
||||||
|
isLoading: query.isPending,
|
||||||
|
error: query.error,
|
||||||
|
createRow: createRow.mutateAsync,
|
||||||
|
updateRow: updateRow.mutateAsync,
|
||||||
|
deleteRow: deleteRow.mutateAsync,
|
||||||
|
isMutating: createRow.isPending || updateRow.isPending || deleteRow.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 };
|
||||||
|
}
|
||||||
90
app/hooks/useNotesProspectionRecherche.ts
Normal file
@ -0,0 +1,90 @@
|
|||||||
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
|
|
||||||
|
import { CATEGORIE_NOTES_PROSPECTION } from '@/constants/rechercheMarche';
|
||||||
|
import { getCurrentUserId, pb } from '@/services/pocketbase';
|
||||||
|
import type { NoteProspectionRecord } from '@/types/collections';
|
||||||
|
|
||||||
|
function escapePb(s: string): string {
|
||||||
|
return s.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ChecklistPersist = { done: boolean; note: string };
|
||||||
|
|
||||||
|
function encodeReponse(data: ChecklistPersist): string {
|
||||||
|
return JSON.stringify(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
function decodeReponse(raw?: string | null): ChecklistPersist {
|
||||||
|
if (!raw?.trim()) return { done: false, note: '' };
|
||||||
|
try {
|
||||||
|
const v = JSON.parse(raw) as unknown;
|
||||||
|
if (v && typeof v === 'object' && 'done' in v) {
|
||||||
|
const o = v as { done?: boolean; note?: string };
|
||||||
|
return { done: Boolean(o.done), note: typeof o.note === 'string' ? o.note : '' };
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return { done: false, note: raw };
|
||||||
|
}
|
||||||
|
return { done: false, note: '' };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useNotesProspectionRecherche() {
|
||||||
|
const uid = getCurrentUserId();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
const query = useQuery({
|
||||||
|
queryKey: ['notes_prospection', uid, CATEGORIE_NOTES_PROSPECTION],
|
||||||
|
queryFn: async () => {
|
||||||
|
if (!uid) return [] as NoteProspectionRecord[];
|
||||||
|
return pb.collection('notes_prospection').getFullList<NoteProspectionRecord>({
|
||||||
|
filter: `user="${uid}" && categorie="${CATEGORIE_NOTES_PROSPECTION}"`,
|
||||||
|
sort: '-id',
|
||||||
|
});
|
||||||
|
},
|
||||||
|
enabled: Boolean(uid),
|
||||||
|
});
|
||||||
|
|
||||||
|
const byQuestionId = (questionId: string): NoteProspectionRecord | undefined =>
|
||||||
|
query.data?.find((r) => r.question === questionId);
|
||||||
|
|
||||||
|
const upsert = useMutation({
|
||||||
|
mutationFn: async (payload: { questionId: string; data: ChecklistPersist }) => {
|
||||||
|
if (!uid) throw new Error('Non connecté');
|
||||||
|
const reponse = encodeReponse(payload.data);
|
||||||
|
const qe = escapePb(payload.questionId);
|
||||||
|
const existing = await pb.collection('notes_prospection').getFullList<NoteProspectionRecord>({
|
||||||
|
filter: `user="${uid}" && categorie="${escapePb(CATEGORIE_NOTES_PROSPECTION)}" && question="${qe}"`,
|
||||||
|
});
|
||||||
|
const row = existing[0];
|
||||||
|
if (row) {
|
||||||
|
return pb.collection('notes_prospection').update<NoteProspectionRecord>(row.id, {
|
||||||
|
reponse,
|
||||||
|
categorie: CATEGORIE_NOTES_PROSPECTION,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return pb.collection('notes_prospection').create<NoteProspectionRecord>({
|
||||||
|
user: uid,
|
||||||
|
question: payload.questionId,
|
||||||
|
reponse,
|
||||||
|
categorie: CATEGORIE_NOTES_PROSPECTION,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
void queryClient.invalidateQueries({
|
||||||
|
queryKey: ['notes_prospection', uid, CATEGORIE_NOTES_PROSPECTION],
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
rows: query.data ?? [],
|
||||||
|
isLoading: query.isPending,
|
||||||
|
error: query.error,
|
||||||
|
getState(questionId: string): ChecklistPersist {
|
||||||
|
const row = byQuestionId(questionId);
|
||||||
|
return decodeReponse(row?.reponse);
|
||||||
|
},
|
||||||
|
saveChecklistItem: upsert.mutateAsync,
|
||||||
|
isSaving: upsert.isPending,
|
||||||
|
};
|
||||||
|
}
|
||||||
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,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
2921
mb-app/package-lock.json → app/package-lock.json
generated
@ -1,8 +1,10 @@
|
|||||||
{
|
{
|
||||||
"name": "mb-app",
|
"name": "app",
|
||||||
"main": "expo-router/entry",
|
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
|
"private": true,
|
||||||
|
"main": "expo-router/entry",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
"postinstall": "node scripts/ensure-assets.js",
|
||||||
"start": "expo start",
|
"start": "expo start",
|
||||||
"android": "expo start --android",
|
"android": "expo start --android",
|
||||||
"ios": "expo start --ios",
|
"ios": "expo start --ios",
|
||||||
@ -12,41 +14,37 @@
|
|||||||
"@expo/vector-icons": "^15.0.3",
|
"@expo/vector-icons": "^15.0.3",
|
||||||
"@react-native-async-storage/async-storage": "2.2.0",
|
"@react-native-async-storage/async-storage": "2.2.0",
|
||||||
"@react-navigation/native": "^7.1.8",
|
"@react-navigation/native": "^7.1.8",
|
||||||
"@supabase/supabase-js": "^2.105.1",
|
"@tanstack/react-query": "^5.90.0",
|
||||||
"@tanstack/react-query": "^5.100.9",
|
"expo": "~54.0.0",
|
||||||
"expo": "~54.0.33",
|
"expo-clipboard": "~8.0.0",
|
||||||
"expo-constants": "~18.0.13",
|
"expo-constants": "~18.0.13",
|
||||||
"expo-document-picker": "~14.0.8",
|
"expo-document-picker": "~14.0.8",
|
||||||
"expo-file-system": "~19.0.22",
|
|
||||||
"expo-font": "~14.0.11",
|
"expo-font": "~14.0.11",
|
||||||
"expo-image-picker": "~17.0.11",
|
"expo-haptics": "~15.0.7",
|
||||||
|
"expo-image-picker": "~17.0.8",
|
||||||
"expo-linking": "~8.0.11",
|
"expo-linking": "~8.0.11",
|
||||||
"expo-notifications": "~0.32.17",
|
"expo-router": "~6.0.0",
|
||||||
"expo-router": "~6.0.23",
|
|
||||||
"expo-splash-screen": "~31.0.13",
|
"expo-splash-screen": "~31.0.13",
|
||||||
"expo-status-bar": "~3.0.9",
|
"expo-status-bar": "~3.0.8",
|
||||||
"expo-web-browser": "~15.0.10",
|
"expo-web-browser": "~15.0.10",
|
||||||
"nativewind": "^4.2.3",
|
"nativewind": "^4.1.23",
|
||||||
|
"pocketbase": "^0.26.0",
|
||||||
"react": "19.1.0",
|
"react": "19.1.0",
|
||||||
"react-dom": "19.1.0",
|
"react-dom": "19.1.0",
|
||||||
"react-native": "0.81.5",
|
"react-native": "0.81.4",
|
||||||
"react-native-gesture-handler": "~2.28.0",
|
"react-native-gesture-handler": "~2.28.0",
|
||||||
"react-native-maps": "1.20.1",
|
|
||||||
"react-native-paper": "^5.15.1",
|
|
||||||
"react-native-reanimated": "~4.1.1",
|
"react-native-reanimated": "~4.1.1",
|
||||||
"react-native-safe-area-context": "~5.6.0",
|
"react-native-safe-area-context": "~5.6.0",
|
||||||
"react-native-screens": "~4.16.0",
|
"react-native-screens": "~4.16.0",
|
||||||
"react-native-url-polyfill": "^3.0.0",
|
|
||||||
"react-native-web": "~0.21.0",
|
"react-native-web": "~0.21.0",
|
||||||
"react-native-worklets": "0.5.1",
|
"react-native-worklets": "0.5.1",
|
||||||
"tailwindcss": "^3.4.19",
|
"tailwindcss": "^3.4.17",
|
||||||
"zustand": "^5.0.12"
|
"zustand": "^5.0.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@babel/core": "^7.26.0",
|
||||||
"@types/react": "~19.1.0",
|
"@types/react": "~19.1.0",
|
||||||
"prettier-plugin-tailwindcss": "^0.8.0",
|
"babel-preset-expo": "~54.0.0",
|
||||||
"react-test-renderer": "19.1.0",
|
|
||||||
"typescript": "~5.9.2"
|
"typescript": "~5.9.2"
|
||||||
},
|
}
|
||||||
"private": true
|
|
||||||
}
|
}
|
||||||
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);
|
||||||
66
app/services/agentsApi.ts
Normal file
@ -0,0 +1,66 @@
|
|||||||
|
import { pb } from '@/services/pocketbase';
|
||||||
|
|
||||||
|
export type AgentImmobilierBody = {
|
||||||
|
objectif?: string;
|
||||||
|
contexte?: string;
|
||||||
|
save?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AgentMarchandBody = {
|
||||||
|
titre?: string;
|
||||||
|
prix?: number;
|
||||||
|
surface?: number;
|
||||||
|
code_postal?: string;
|
||||||
|
ville?: string;
|
||||||
|
notes?: string;
|
||||||
|
grille_json?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AgentDvfBody = {
|
||||||
|
libelle: string;
|
||||||
|
code_insee?: string;
|
||||||
|
annee?: number;
|
||||||
|
prix_m2_median?: number;
|
||||||
|
nb_ventes?: number;
|
||||||
|
detail_json?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AgentVeilleBody = {
|
||||||
|
titre: string;
|
||||||
|
url?: string;
|
||||||
|
source?: string;
|
||||||
|
prix?: number;
|
||||||
|
surface?: number;
|
||||||
|
code_postal?: string;
|
||||||
|
ville?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AgentRedactionBody = {
|
||||||
|
kind?: string;
|
||||||
|
bullets?: string[];
|
||||||
|
save?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function agentImmobilier(body: AgentImmobilierBody): Promise<{ brouillon: string; courrier_id?: string }> {
|
||||||
|
return pb.send('/api/mdb/agent-immobilier', { method: 'POST', body });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function agentMarchand(body: AgentMarchandBody): Promise<{ analyse: string }> {
|
||||||
|
return pb.send('/api/mdb/agent-marchand', { method: 'POST', body });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function agentDvf(body: AgentDvfBody): Promise<{ id: string; synthese: string }> {
|
||||||
|
return pb.send('/api/mdb/agent-dvf', { method: 'POST', body });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function agentVeille(body: AgentVeilleBody): Promise<{ id: string; dedupe: boolean; message?: string }> {
|
||||||
|
return pb.send('/api/mdb/agent-veille', { method: 'POST', body });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function agentRedaction(body: AgentRedactionBody): Promise<{ texte: string; courrier_id?: string }> {
|
||||||
|
return pb.send('/api/mdb/agent-redaction', { method: 'POST', body });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function agentAlertesScan(): Promise<{ processed: number; note?: string }> {
|
||||||
|
return pb.send('/api/mdb/agent-alertes-scan', { method: 'POST', body: {} });
|
||||||
|
}
|
||||||
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"]
|
||||||
|
}
|
||||||
261
app/types/collections.ts
Normal file
@ -0,0 +1,261 @@
|
|||||||
|
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;
|
||||||
|
/** Prix de revente estimé au m² (référence marché / grille perso). */
|
||||||
|
prix_revente_m2?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AnalyseSecteurRecord = RecordModel & {
|
||||||
|
user: string;
|
||||||
|
ville: string;
|
||||||
|
notes?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type NoteProspectionRecord = RecordModel & {
|
||||||
|
user: string;
|
||||||
|
question: string;
|
||||||
|
reponse?: string;
|
||||||
|
categorie?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type GrillePrixTypeBien = 'appartement' | 'maison' | 'immeuble';
|
||||||
|
export type GrillePrixEtat = 'bon_etat' | 'a_renover' | 'travaux_lourds';
|
||||||
|
|
||||||
|
export type GrillePrixRecord = RecordModel & {
|
||||||
|
user: string;
|
||||||
|
type_bien: GrillePrixTypeBien;
|
||||||
|
etat: GrillePrixEtat;
|
||||||
|
prix_achat_m2: number;
|
||||||
|
prix_revente_m2: number;
|
||||||
|
marge_estimee_pct?: number;
|
||||||
|
ville?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type RechercheSauvegardeeRecord = RecordModel & {
|
||||||
|
user: string;
|
||||||
|
nom: string;
|
||||||
|
critere_json?: string;
|
||||||
|
actif?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AlerteRechercheRecord = RecordModel & {
|
||||||
|
user: string;
|
||||||
|
recherche?: string;
|
||||||
|
nom: string;
|
||||||
|
canal: 'in_app' | 'email' | 'push';
|
||||||
|
actif?: boolean;
|
||||||
|
derniere_verification?: string;
|
||||||
|
dernier_nb_resultats?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AnnonceVeilleStatut = 'nouveau' | 'vu' | 'ecarte' | 'raccroche';
|
||||||
|
|
||||||
|
export type AnnonceVeilleRecord = RecordModel & {
|
||||||
|
user: string;
|
||||||
|
titre: string;
|
||||||
|
url?: string;
|
||||||
|
source?: string;
|
||||||
|
prix?: number;
|
||||||
|
surface?: number;
|
||||||
|
code_postal?: string;
|
||||||
|
ville?: string;
|
||||||
|
empreinte?: string;
|
||||||
|
statut: AnnonceVeilleStatut;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type FluxSourceType = 'api' | 'manuel' | 'csv';
|
||||||
|
|
||||||
|
export type FluxSourceRecord = RecordModel & {
|
||||||
|
user: string;
|
||||||
|
nom: string;
|
||||||
|
type: FluxSourceType;
|
||||||
|
notes?: string;
|
||||||
|
actif?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TransactionSecteurSource = 'manuel' | 'dvf_import' | 'api_tiers';
|
||||||
|
|
||||||
|
export type TransactionSecteurRecord = RecordModel & {
|
||||||
|
user: string;
|
||||||
|
libelle: string;
|
||||||
|
code_insee?: string;
|
||||||
|
annee?: number;
|
||||||
|
prix_m2_median?: number;
|
||||||
|
nb_ventes?: number;
|
||||||
|
source: TransactionSecteurSource;
|
||||||
|
detail_json?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CourrierImmobilierKind = 'prospection' | 'annonce_agence' | 'relance';
|
||||||
|
export type CourrierImmobilierEtat = 'brouillon' | 'pret';
|
||||||
|
|
||||||
|
export type CourrierImmobilierRecord = RecordModel & {
|
||||||
|
user: string;
|
||||||
|
titre: string;
|
||||||
|
corps?: string;
|
||||||
|
kind: CourrierImmobilierKind;
|
||||||
|
etat: CourrierImmobilierEtat;
|
||||||
|
};
|
||||||
|
|
||||||
|
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
|
|
||||||
16
docker/docker-compose.dev.yml
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
services:
|
||||||
|
pocketbase:
|
||||||
|
image: ghcr.io/muchobien/pocketbase:latest
|
||||||
|
container_name: mdb-pocketbase-dev
|
||||||
|
restart: unless-stopped
|
||||||
|
command: --dir=/pb_data --hooksDir=/pb_hooks
|
||||||
|
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;
|
|
||||||
}
|
|
||||||
}`;
|
|
||||||