diff --git a/GUIDE_COMPLET.md b/GUIDE_COMPLET.md
new file mode 100644
index 0000000..28b6d24
--- /dev/null
+++ b/GUIDE_COMPLET.md
@@ -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
diff --git a/app/.gitignore b/app/.gitignore
new file mode 100644
index 0000000..5873d9a
--- /dev/null
+++ b/app/.gitignore
@@ -0,0 +1,6 @@
+
+# @generated expo-cli sync-2b81b286409207a5da26e14c78851eb30d8ccbdb
+# The following patterns were generated by expo-cli
+
+expo-env.d.ts
+# @end expo-cli
\ No newline at end of file
diff --git a/app/app.config.js b/app/app.config.js
new file mode 100644
index 0000000..d99e75a
--- /dev/null
+++ b/app/app.config.js
@@ -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');
diff --git a/app/app.json b/app/app.json
new file mode 100644
index 0000000..c22baab
--- /dev/null
+++ b/app/app.json
@@ -0,0 +1,31 @@
+{
+ "expo": {
+ "name": "mdb",
+ "slug": "mdb",
+ "version": "1.0.0",
+ "orientation": "portrait",
+ "icon": "./assets/images/icon.png",
+ "scheme": "mdb",
+ "userInterfaceStyle": "automatic",
+ "newArchEnabled": true,
+ "splash": {
+ "image": "./assets/images/splash-icon.png",
+ "resizeMode": "contain",
+ "backgroundColor": "#f8fafc"
+ },
+ "ios": { "supportsTablet": true },
+ "android": {
+ "adaptiveIcon": {
+ "foregroundImage": "./assets/images/adaptive-icon.png",
+ "backgroundColor": "#f8fafc"
+ }
+ },
+ "web": {
+ "bundler": "metro",
+ "output": "static",
+ "favicon": "./assets/images/favicon.png"
+ },
+ "plugins": ["expo-router"],
+ "experiments": { "typedRoutes": true }
+ }
+}
diff --git a/app/app/(tabs)/_layout.tsx b/app/app/(tabs)/_layout.tsx
new file mode 100644
index 0000000..a35f1a6
--- /dev/null
+++ b/app/app/(tabs)/_layout.tsx
@@ -0,0 +1,50 @@
+import { Ionicons } from '@expo/vector-icons';
+import { Tabs } from 'expo-router';
+
+export default function TabsLayout() {
+ return (
+
+ ,
+ }}
+ />
+ ,
+ }}
+ />
+ ,
+ }}
+ />
+ ,
+ }}
+ />
+ ,
+ }}
+ />
+
+ );
+}
diff --git a/app/app/(tabs)/agenda.tsx b/app/app/(tabs)/agenda.tsx
new file mode 100644
index 0000000..c91bb6e
--- /dev/null
+++ b/app/app/(tabs)/agenda.tsx
@@ -0,0 +1,13 @@
+import { Stack } from 'expo-router';
+import { Text, View } from 'react-native';
+
+export default function AgendaTab() {
+ return (
+ <>
+
+
+ Agenda (tâches) — à brancher sur la collection `taches`.
+
+ >
+ );
+}
diff --git a/app/app/(tabs)/biens.tsx b/app/app/(tabs)/biens.tsx
new file mode 100644
index 0000000..377f553
--- /dev/null
+++ b/app/app/(tabs)/biens.tsx
@@ -0,0 +1,129 @@
+import { Link, Stack } from 'expo-router';
+import { useEffect, useMemo, useRef } from 'react';
+import { ActivityIndicator, Pressable, ScrollView, Text, View } from 'react-native';
+
+import { useBiens, type BienExpanded } from '@/hooks/useBiens';
+import { useEtapes } from '@/hooks/useEtapes';
+import { formatEUR } from '@/utils/format';
+import { formatPocketBaseError } from '@/utils/pocketbaseErrors';
+
+export default function BiensScreen() {
+ const { biens, prixByBien, isLoading, error } = useBiens();
+ const {
+ etapes,
+ isLoading: etapesLoading,
+ error: etapesError,
+ initEtapesDefaut,
+ initError: etapesInitMutationError,
+ } = useEtapes();
+ const initOnce = useRef(false);
+
+ useEffect(() => {
+ if (etapesLoading || etapes.length > 0 || initOnce.current) return;
+ initOnce.current = true;
+ void initEtapesDefaut().catch(() => {
+ initOnce.current = false;
+ });
+ }, [etapesLoading, etapes.length, initEtapesDefaut]);
+
+ const grouped = useMemo(() => {
+ const m = new Map();
+ const none = '__none__';
+ for (const e of etapes) m.set(e.id, []);
+ m.set(none, []);
+ for (const b of biens) {
+ const k = b.etape && m.has(b.etape) ? b.etape : none;
+ if (!m.has(k)) m.set(k, []);
+ m.get(k)!.push(b);
+ }
+ return { m, none };
+ }, [biens, etapes]);
+
+ const banner =
+ error != null
+ ? formatPocketBaseError(error)
+ : etapesError != null
+ ? formatPocketBaseError(etapesError)
+ : etapesInitMutationError != null
+ ? formatPocketBaseError(etapesInitMutationError)
+ : null;
+
+ return (
+ <>
+
+
+ {banner ? (
+
+ {banner}
+
+ ) : null}
+ {isLoading || etapesLoading ? (
+
+
+
+ ) : (
+
+ {etapes.map((e) => {
+ const list = grouped.m.get(e.id) ?? [];
+ return (
+
+
+
+ {e.nom}
+
+ {list.length}
+
+ {list.length} bien(s)
+
+ {list.map((b) => (
+
+
+
+ {b.titre ?? 'Sans titre'}
+
+
+ {[b.code_postal, b.ville].filter(Boolean).join(' ') || '—'}
+
+ {prixByBien.has(b.id) ? (
+
+ {formatEUR(prixByBien.get(b.id))}
+
+ ) : null}
+
+
+ ))}
+
+
+ );
+ })}
+
+ Sans étape
+
+ {(grouped.m.get(grouped.none) ?? []).length} bien(s)
+
+
+ {(grouped.m.get(grouped.none) ?? []).map((b) => (
+
+
+
+ {b.titre ?? 'Sans titre'}
+
+
+
+ ))}
+
+
+
+ )}
+
+
+ +
+
+
+
+ >
+ );
+}
diff --git a/app/app/(tabs)/contacts.tsx b/app/app/(tabs)/contacts.tsx
new file mode 100644
index 0000000..3d1c152
--- /dev/null
+++ b/app/app/(tabs)/contacts.tsx
@@ -0,0 +1,57 @@
+import { useQuery } from '@tanstack/react-query';
+import { Link, Stack } from 'expo-router';
+import { ActivityIndicator, Pressable, ScrollView, Text, View } from 'react-native';
+
+import { getCurrentUserId, pb } from '@/services/pocketbase';
+import type { ContactRecord } from '@/types/collections';
+import { formatPocketBaseError } from '@/utils/pocketbaseErrors';
+
+export default function ContactsTab() {
+ const uid = getCurrentUserId();
+ const q = useQuery({
+ queryKey: ['contacts_list', uid],
+ queryFn: async () => {
+ if (!uid) return [];
+ return pb.collection('contacts').getFullList({
+ filter: `user="${uid}"`,
+ sort: 'nom',
+ });
+ },
+ enabled: Boolean(uid),
+ });
+
+ return (
+ <>
+
+
+ {q.error ? (
+ {formatPocketBaseError(q.error)}
+ ) : null}
+ {q.isPending ? (
+
+
+
+ ) : (
+
+ {q.data?.map((c) => (
+
+
+
+ {c.prenom ? `${c.prenom} ` : ''}
+ {c.nom}
+
+ {c.societe ? {c.societe} : null}
+
+
+ ))}
+
+ )}
+
+
+ + Contact
+
+
+
+ >
+ );
+}
diff --git a/app/app/(tabs)/index.tsx b/app/app/(tabs)/index.tsx
new file mode 100644
index 0000000..89e81e7
--- /dev/null
+++ b/app/app/(tabs)/index.tsx
@@ -0,0 +1,20 @@
+import { Link, Stack } from 'expo-router';
+import { Text, View } from 'react-native';
+
+export default function DashboardScreen() {
+ return (
+ <>
+
+
+ Bienvenue
+ Raccourcis :
+
+ Voir les biens (pipeline)
+
+
+ Nouveau bien
+
+
+ >
+ );
+}
diff --git a/app/app/(tabs)/visites.tsx b/app/app/(tabs)/visites.tsx
new file mode 100644
index 0000000..ddd2a1d
--- /dev/null
+++ b/app/app/(tabs)/visites.tsx
@@ -0,0 +1,58 @@
+import { useQuery } from '@tanstack/react-query';
+import { Stack, useRouter } from 'expo-router';
+import { ActivityIndicator, Pressable, ScrollView, Text, View } from 'react-native';
+
+import { AVIS_VISITE } from '@/constants/metier';
+import { getCurrentUserId, pb } from '@/services/pocketbase';
+import type { VisiteRecord } from '@/types/collections';
+import { formatPocketBaseError } from '@/utils/pocketbaseErrors';
+
+export default function VisitesTab() {
+ const uid = getCurrentUserId();
+ const router = useRouter();
+ const q = useQuery({
+ queryKey: ['visites_list', uid],
+ queryFn: async () => {
+ if (!uid) return [];
+ return pb.collection('visites').getFullList({
+ filter: `user="${uid}"`,
+ sort: '-date_visite',
+ });
+ },
+ enabled: Boolean(uid),
+ });
+
+ return (
+ <>
+
+
+ {q.error ? (
+ {formatPocketBaseError(q.error)}
+ ) : null}
+ {q.isPending ? (
+
+
+
+ ) : (
+
+ {q.data?.length === 0 ? (
+ Aucune visite.
+ ) : null}
+ {q.data?.map((v) => (
+ router.push(`/visite/${v.id}`)}
+ >
+ {v.date_visite?.slice(0, 10) ?? '—'}
+
+ {v.avis_global ? AVIS_VISITE[v.avis_global]?.label ?? v.avis_global : '—'}
+
+
+ ))}
+
+ )}
+
+ >
+ );
+}
diff --git a/app/app/_layout.tsx b/app/app/_layout.tsx
new file mode 100644
index 0000000..294a76c
--- /dev/null
+++ b/app/app/_layout.tsx
@@ -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 (
+
+
+
+
+
+ );
+}
diff --git a/app/app/auth/_layout.tsx b/app/app/auth/_layout.tsx
new file mode 100644
index 0000000..1b21801
--- /dev/null
+++ b/app/app/auth/_layout.tsx
@@ -0,0 +1,13 @@
+import { Stack } from 'expo-router';
+
+export default function AuthLayout() {
+ return (
+
+ );
+}
diff --git a/app/app/auth/login.tsx b/app/app/auth/login.tsx
new file mode 100644
index 0000000..012679a
--- /dev/null
+++ b/app/app/auth/login.tsx
@@ -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(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 (
+ <>
+
+
+ {err ? (
+
+ {err}
+
+ ) : null}
+ Email
+
+ Mot de passe
+
+
+ Se connecter
+
+
+
+ Créer un compte
+
+
+
+ >
+ );
+}
diff --git a/app/app/auth/register.tsx b/app/app/auth/register.tsx
new file mode 100644
index 0000000..34875ab
--- /dev/null
+++ b/app/app/auth/register.tsx
@@ -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(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 (
+ <>
+
+
+ {err ? (
+
+ {err}
+
+ ) : null}
+ Nom
+
+ Email
+
+ Mot de passe
+
+
+ S'inscrire
+
+
+
+ Déjà un compte ? Connexion
+
+
+
+ >
+ );
+}
diff --git a/app/app/bien/[id].tsx b/app/app/bien/[id].tsx
new file mode 100644
index 0000000..f5c7d2c
--- /dev/null
+++ b/app/app/bien/[id].tsx
@@ -0,0 +1,234 @@
+import { Link, Stack, useLocalSearchParams, useRouter } from 'expo-router';
+import type { ReactNode } from 'react';
+import {
+ ActivityIndicator,
+ FlatList,
+ Pressable,
+ ScrollView,
+ Text,
+ TextInput,
+ View,
+} from 'react-native';
+
+import { AVIS_VISITE, TYPES_BIENS } from '@/constants/metier';
+import { useBienDetail } from '@/hooks/useBiens';
+import { useNoteLibre } from '@/hooks/useNoteLibre';
+import { calculateResults, type AnalyseFormInput } from '@/hooks/useAnalyse';
+import { formatEUR } from '@/utils/format';
+
+function routeParamId(raw: string | string[] | undefined): string | undefined {
+ if (raw == null) return undefined;
+ return Array.isArray(raw) ? raw[0] : raw;
+}
+
+export default function BienDetailScreen() {
+ const { id: rawId } = useLocalSearchParams<{ id?: string | string[] }>();
+ const id = routeParamId(rawId);
+ const router = useRouter();
+ const { bundle, isLoading, error } = useBienDetail(id);
+ const { draft, setDraft, hydrated } = useNoteLibre(id, bundle?.notes);
+
+ if (!id) {
+ return (
+ <>
+
+
+ Identifiant manquant.
+
+ >
+ );
+ }
+
+ if (isLoading) {
+ return (
+ <>
+
+
+
+
+ >
+ );
+ }
+
+ if (error || !bundle) {
+ return (
+ <>
+
+
+
+ {error instanceof Error ? error.message : 'Impossible de charger ce bien.'}
+
+ router.replace('/(tabs)/biens')}
+ >
+ Vers la liste des biens
+
+
+ >
+ );
+ }
+
+ const { bien, visites, notes, documents, analyse } = bundle;
+ const etape = bien.expand?.etape;
+
+ const analyseInput: AnalyseFormInput = {
+ prix_achat: analyse?.prix_achat,
+ type_bien_fiscal: analyse?.type_bien_fiscal,
+ frais_notaire: analyse?.frais_notaire,
+ frais_agence_achat: analyse?.frais_agence_achat,
+ budget_travaux: analyse?.budget_travaux,
+ reserve_imprevus_pct: analyse?.reserve_imprevus_pct,
+ duree_portage_mois: analyse?.duree_portage_mois,
+ taux_credit: analyse?.taux_credit,
+ taxe_fonciere_annuelle: analyse?.taxe_fonciere_annuelle,
+ charges_copropriete_mensuelle: analyse?.charges_copropriete_mensuelle,
+ prix_revente_cible: analyse?.prix_revente_cible,
+ frais_agence_vente_pct: analyse?.frais_agence_vente_pct,
+ taux_impot: analyse?.taux_impot,
+ };
+ const calc = calculateResults(analyseInput);
+
+ return (
+ <>
+
+
+
+ {etape ? (
+
+ {etape.nom}
+
+ ) : (
+ Aucune étape assignée.
+ )}
+ {bien.titre ?? 'Sans titre'}
+
+ {[bien.adresse, bien.code_postal, bien.ville].filter(Boolean).join(' · ') || '—'}
+
+
+
+ Ouvrir le calculateur
+
+
+
+
+
+
+
+ {!analyse ? (
+ Aucune analyse enregistrée. Utilisez le calculateur.
+ ) : (
+ <>
+
+
+
+
+
+
+
+
+ >
+ )}
+
+
+
+ {visites.length === 0 ? (
+ Aucune visite.
+ ) : (
+ v.id}
+ scrollEnabled={false}
+ renderItem={({ item }) => (
+ router.push(`/visite/${item.id}`)}
+ >
+ {item.date_visite?.slice(0, 10) ?? '—'}
+
+ {item.avis_global ? AVIS_VISITE[item.avis_global]?.label ?? item.avis_global : '—'}
+
+
+ )}
+ />
+ )}
+
+
+
+
+ Note libre (sauvegarde automatique après 500 ms sans frappe).
+
+ {!hydrated ? (
+
+ ) : (
+
+ )}
+ {notes.some((n) => n.type_note && n.type_note !== 'libre') ? (
+ Autres notes
+ ) : null}
+ {notes
+ .filter((n) => n.type_note && n.type_note !== 'libre')
+ .map((n) => (
+
+ {n.updated?.slice(0, 16) ?? ''}
+ {n.contenu}
+
+ ))}
+
+
+
+ {documents.length === 0 ? (
+ Aucun document.
+ ) : (
+ documents.map((d) => (
+
+ {d.nom}
+ {d.type_document ? {d.type_document} : null}
+
+ ))
+ )}
+
+
+ >
+ );
+}
+
+function Section({ title, children }: { title: string; children: ReactNode }) {
+ return (
+
+ {title}
+ {children}
+
+ );
+}
+
+function InfoLine({ label, value }: { label: string; value: string }) {
+ return (
+
+ {label}
+ {value}
+
+ );
+}
diff --git a/app/app/bien/nouveau.tsx b/app/app/bien/nouveau.tsx
new file mode 100644
index 0000000..1f03bd1
--- /dev/null
+++ b/app/app/bien/nouveau.tsx
@@ -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 = {
+ 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 (
+
+ {message}
+
+ );
+}
+
+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(null);
+ const [createError, setCreateError] = useState(null);
+ const [initPipelineMsg, setInitPipelineMsg] = useState(null);
+
+ const [typeBien, setTypeBien] = useState('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('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 (
+ <>
+
+
+ Connectez-vous pour créer un bien.
+
+ >
+ );
+ }
+
+ return (
+ <>
+
+
+ {pipelineBanner ? : null}
+
+
+ {[1, 2, 3].map((s) => (
+ = s ? '#1D4ED8' : '#E2E8F0' }}
+ />
+ ))}
+
+ Étape {step} / 3
+
+ {stepHint ? : null}
+ {step === 3 && createError ? : null}
+
+ {!firstEtapeId && !etapesLoading ? (
+
+
+ Aucune étape pipeline disponible. Le bien sera créé sans étape ; vous pourrez l’assigner plus tard.
+
+
+ ) : null}
+
+ {step === 1 ? (
+
+ Localisation
+ Type de bien
+ setPickerTypeOpen(true)}
+ >
+ {TYPES_BIENS[typeBien]}
+
+
+
+
+
+
+ ) : null}
+
+ {step === 2 ? (
+
+ Caractéristiques
+
+
+
+ Source
+ setPickerSourceOpen(true)}
+ >
+ {SOURCE_LABELS[source]}
+
+
+ Off-market
+
+
+
+ Note (optionnel)
+
+ setStep(1)} onNext={goNext2} />
+
+ ) : null}
+
+ {step === 3 ? (
+
+ Résumé
+
+
+
+
+
+
+
+
+ 80
+ ? `${noteProjet.slice(0, 80)}…`
+ : noteProjet
+ : '—'
+ }
+ />
+
+ setStep(2)}
+ onNext={onCreate}
+ nextLabel={submitting ? 'Création…' : 'Créer'}
+ nextDisabled={submitting || etapesLoading}
+ />
+ {etapesLoading ? : null}
+
+ ) : null}
+
+
+
+ setPickerTypeOpen(false)}>
+
+ Type de bien
+
+ {(Object.keys(TYPES_BIENS) as BienType[]).map((k) => (
+ {
+ setTypeBien(k);
+ setPickerTypeOpen(false);
+ }}
+ >
+ {TYPES_BIENS[k]}
+
+ ))}
+
+
+
+
+
+
+ setPickerSourceOpen(false)}>
+
+ Source
+ {SOURCES.map((k) => (
+ {
+ setSource(k);
+ setPickerSourceOpen(false);
+ }}
+ >
+ {SOURCE_LABELS[k]}
+
+ ))}
+
+
+
+ >
+ );
+}
+
+function Field({
+ label,
+ value,
+ onChangeText,
+ keyboard,
+}: {
+ label: string;
+ value: string;
+ onChangeText: (t: string) => void;
+ keyboard?: 'numeric';
+}) {
+ return (
+
+ {label}
+
+
+ );
+}
+
+function SummaryRow({ label, value }: { label: string; value: string }) {
+ return (
+
+ {label}
+ {value}
+
+ );
+}
+
+function NavButtons({
+ showPrev = true,
+ onPrev,
+ onNext,
+ nextLabel = 'Suivant',
+ nextDisabled,
+}: {
+ showPrev?: boolean;
+ onPrev?: () => void;
+ onNext: () => void;
+ nextLabel?: string;
+ nextDisabled?: boolean;
+}) {
+ return (
+
+ {showPrev ? (
+
+ Retour
+
+ ) : (
+
+ )}
+
+ {nextLabel}
+
+
+ );
+}
diff --git a/app/app/calculateur/[bienId].tsx b/app/app/calculateur/[bienId].tsx
new file mode 100644
index 0000000..afdae32
--- /dev/null
+++ b/app/app/calculateur/[bienId].tsx
@@ -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(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 (
+ <>
+
+
+ Bien manquant.
+
+ >
+ );
+ }
+
+ return (
+ <>
+
+
+ {isLoading ? (
+
+
+
+ ) : (
+
+ {err ? (
+ {err}
+ ) : null}
+ Prix d'achat (€)
+
+ Type fiscal
+
+ setTypeFiscal('ancien')}
+ >
+ Ancien
+
+ setTypeFiscal('neuf')}
+ >
+ Neuf
+
+
+ Budget travaux (€)
+
+ Prix revente cible (€)
+
+
+
+ Aperçu
+ Frais notaire (estim.) : {formatEUR(calc.frais_notaire)}
+ Prix de revient : {formatEUR(calc.prix_revient)}
+ Marge nette : {formatEUR(calc.marge_nette)}
+
+ Rendement net / revient : {calc.rendement_net_pct.toFixed(1)} %
+
+
+
+
+
+ {isSaving ? 'Enregistrement…' : 'Enregistrer'}
+
+
+
+ )}
+
+ >
+ );
+}
diff --git a/app/app/contact/[id].tsx b/app/app/contact/[id].tsx
new file mode 100644
index 0000000..933ab08
--- /dev/null
+++ b/app/app/contact/[id].tsx
@@ -0,0 +1,101 @@
+import { useQuery } from '@tanstack/react-query';
+import { Stack, useLocalSearchParams } from 'expo-router';
+import { ActivityIndicator, ScrollView, Text, View } from 'react-native';
+
+import { getCurrentUserId, pb } from '@/services/pocketbase';
+import type { ContactRecord } from '@/types/collections';
+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 ContactDetailScreen() {
+ const { id: rawId } = useLocalSearchParams<{ id?: string | string[] }>();
+ const id = routeParamId(rawId);
+ const uid = getCurrentUserId();
+
+ const q = useQuery({
+ queryKey: ['contact', id],
+ queryFn: async () => {
+ if (!id) throw new Error('id');
+ return pb.collection('contacts').getOne(id);
+ },
+ enabled: Boolean(id),
+ });
+
+ if (!id) {
+ return (
+ <>
+
+
+ Identifiant manquant.
+
+ >
+ );
+ }
+
+ if (q.isPending) {
+ return (
+ <>
+
+
+
+
+ >
+ );
+ }
+
+ if (q.error || !q.data) {
+ return (
+ <>
+
+
+
+ {q.error ? formatPocketBaseError(q.error) : 'Introuvable.'}
+
+
+ >
+ );
+ }
+
+ const c = q.data;
+ if (uid && c.user !== uid) {
+ return (
+ <>
+
+
+ Accès refusé.
+
+ >
+ );
+ }
+
+ return (
+ <>
+
+
+
+ {c.prenom ? `${c.prenom} ` : ''}
+ {c.nom}
+
+ {c.societe ? {c.societe} : null}
+ Catégorie
+ {c.categorie}
+ {c.email ? (
+ <>
+ Email
+ {c.email}
+ >
+ ) : null}
+ {c.telephone ? (
+ <>
+ Téléphone
+ {c.telephone}
+ >
+ ) : null}
+
+ >
+ );
+}
diff --git a/app/app/contact/nouveau.tsx b/app/app/contact/nouveau.tsx
new file mode 100644
index 0000000..eb18f31
--- /dev/null
+++ b/app/app/contact/nouveau.tsx
@@ -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(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 (
+ <>
+
+
+ Connexion requise.
+
+ >
+ );
+ }
+
+ return (
+ <>
+
+
+ {err ? (
+
+ {err}
+
+ ) : null}
+ Nom *
+
+ Prénom
+
+
+ Enregistrer
+
+
+ >
+ );
+}
diff --git a/app/app/index.tsx b/app/app/index.tsx
new file mode 100644
index 0000000..4b47019
--- /dev/null
+++ b/app/app/index.tsx
@@ -0,0 +1,18 @@
+import { Redirect } from 'expo-router';
+import { ActivityIndicator, View } from 'react-native';
+
+import { useAuth } from '@/context/AuthContext';
+
+export default function Index() {
+ const { loading, user } = useAuth();
+
+ if (loading) {
+ return (
+
+
+
+ );
+ }
+
+ return user ? : ;
+}
diff --git a/app/app/visite/[id].tsx b/app/app/visite/[id].tsx
new file mode 100644
index 0000000..4bbbe58
--- /dev/null
+++ b/app/app/visite/[id].tsx
@@ -0,0 +1,97 @@
+import { useQuery } from '@tanstack/react-query';
+import { Stack, useLocalSearchParams } from 'expo-router';
+import { ActivityIndicator, ScrollView, Text, View } from 'react-native';
+
+import { AVIS_VISITE } from '@/constants/metier';
+import { getCurrentUserId, pb } from '@/services/pocketbase';
+import type { VisiteRecord } from '@/types/collections';
+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 VisiteDetailScreen() {
+ const { id: rawId } = useLocalSearchParams<{ id?: string | string[] }>();
+ const id = routeParamId(rawId);
+ const uid = getCurrentUserId();
+
+ const q = useQuery({
+ queryKey: ['visite_detail', id],
+ queryFn: async () => {
+ if (!id) throw new Error('id');
+ return pb.collection('visites').getOne(id, { expand: 'bien' });
+ },
+ enabled: Boolean(id),
+ });
+
+ if (!id) {
+ return (
+ <>
+
+
+ Identifiant manquant.
+
+ >
+ );
+ }
+
+ if (q.isPending) {
+ return (
+ <>
+
+
+
+
+ >
+ );
+ }
+
+ if (q.error || !q.data) {
+ return (
+ <>
+
+
+
+ {q.error ? formatPocketBaseError(q.error) : 'Introuvable.'}
+
+
+ >
+ );
+ }
+
+ const v = q.data;
+ if (uid && v.user !== uid) {
+ return (
+ <>
+
+
+ Accès refusé.
+
+ >
+ );
+ }
+
+ const titre = v.date_visite?.slice(0, 10) ?? 'Visite';
+
+ return (
+ <>
+
+
+ Date
+ {v.date_visite ?? '—'}
+ Avis
+
+ {v.avis_global ? AVIS_VISITE[v.avis_global]?.label ?? v.avis_global : '—'}
+
+ {v.notes_brutes ? (
+ <>
+ Notes
+ {v.notes_brutes}
+ >
+ ) : null}
+
+ >
+ );
+}
diff --git a/app/assets/images/adaptive-icon.png b/app/assets/images/adaptive-icon.png
new file mode 100644
index 0000000..f37764b
Binary files /dev/null and b/app/assets/images/adaptive-icon.png differ
diff --git a/app/assets/images/favicon.png b/app/assets/images/favicon.png
new file mode 100644
index 0000000..f37764b
Binary files /dev/null and b/app/assets/images/favicon.png differ
diff --git a/app/assets/images/icon.png b/app/assets/images/icon.png
new file mode 100644
index 0000000..f37764b
Binary files /dev/null and b/app/assets/images/icon.png differ
diff --git a/app/assets/images/splash-icon.png b/app/assets/images/splash-icon.png
new file mode 100644
index 0000000..f37764b
Binary files /dev/null and b/app/assets/images/splash-icon.png differ
diff --git a/app/babel.config.js b/app/babel.config.js
new file mode 100644
index 0000000..1d1ac9c
--- /dev/null
+++ b/app/babel.config.js
@@ -0,0 +1,9 @@
+module.exports = function (api) {
+ api.cache(true);
+ return {
+ presets: [
+ ['babel-preset-expo', { jsxImportSource: 'nativewind' }],
+ 'nativewind/babel',
+ ],
+ };
+};
diff --git a/app/constants/metier.ts b/app/constants/metier.ts
new file mode 100644
index 0000000..e58bf81
--- /dev/null
+++ b/app/constants/metier.ts
@@ -0,0 +1,19 @@
+import type { BienType } from '@/types/collections';
+
+export const TYPES_BIENS: Record = {
+ appartement: 'Appartement',
+ maison: 'Maison',
+ immeuble: 'Immeuble',
+ terrain: 'Terrain',
+ local_commercial: 'Local commercial',
+ parking: 'Parking',
+ cave: 'Cave',
+ autre: 'Autre',
+};
+
+export const AVIS_VISITE: Record = {
+ coup_de_coeur: { label: 'Coup de cœur' },
+ interessant: { label: 'Intéressant' },
+ neutre: { label: 'Neutre' },
+ a_eviter: { label: 'À éviter' },
+};
diff --git a/app/context/AuthContext.tsx b/app/context/AuthContext.tsx
new file mode 100644
index 0000000..2231bbd
--- /dev/null
+++ b/app/context/AuthContext.tsx
@@ -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;
+ register: (params: { email: string; password: string; name: string }) => Promise;
+ logout: () => void;
+};
+
+const AuthContext = createContext(null);
+
+export function AuthProvider({ children }: { children: ReactNode }) {
+ const [user, setUser] = useState(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 {children};
+}
+
+export function useAuth(): AuthContextValue {
+ const ctx = useContext(AuthContext);
+ if (!ctx) throw new Error('useAuth must be used within AuthProvider');
+ return ctx;
+}
+
+export { isAuthenticated };
diff --git a/app/global.css b/app/global.css
new file mode 100644
index 0000000..b5c61c9
--- /dev/null
+++ b/app/global.css
@@ -0,0 +1,3 @@
+@tailwind base;
+@tailwind components;
+@tailwind utilities;
diff --git a/app/hooks/useAnalyse.ts b/app/hooks/useAnalyse.ts
new file mode 100644
index 0000000..6e1e9bb
--- /dev/null
+++ b/app/hooks/useAnalyse.ts
@@ -0,0 +1,166 @@
+import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
+
+import { getCurrentUserId, pb } from '@/services/pocketbase';
+import type { AnalyseFinanciereRecord, TypeBienFiscal } from '@/types/collections';
+import { roundMoney } from '@/utils/format';
+
+export type AnalyseFormInput = {
+ prix_achat?: number;
+ type_bien_fiscal?: TypeBienFiscal;
+ frais_notaire?: number;
+ frais_agence_achat?: number;
+ budget_travaux?: number;
+ reserve_imprevus_pct?: number;
+ duree_portage_mois?: number;
+ taux_credit?: number;
+ taxe_fonciere_annuelle?: number;
+ charges_copropriete_mensuelle?: number;
+ prix_revente_cible?: number;
+ frais_agence_vente_pct?: number;
+ taux_impot?: number;
+};
+
+export type AnalyseCalculated = {
+ frais_notaire: number;
+ travaux_total: number;
+ frais_portage_total: number;
+ prix_revient: number;
+ marge_brute: number;
+ marge_nette: number;
+ marge_brute_pct: number;
+ marge_nette_pct: number;
+ rendement_net_pct: number;
+};
+
+export function calculateResults(data: AnalyseFormInput): AnalyseCalculated {
+ const prixAchat = data.prix_achat ?? 0;
+ const typeFiscal = data.type_bien_fiscal ?? 'ancien';
+ const fraisNotaireAuto = prixAchat * (typeFiscal === 'neuf' ? 0.02 : 0.075);
+ const fraisNotaire = data.frais_notaire ?? fraisNotaireAuto;
+
+ const budgetTravaux = data.budget_travaux ?? 0;
+ const reservePct = data.reserve_imprevus_pct ?? 0;
+ const travauxTotal = budgetTravaux * (1 + reservePct / 100);
+
+ const tauxCredit = data.taux_credit ?? 0;
+ const taxeFon = data.taxe_fonciere_annuelle ?? 0;
+ const charges = data.charges_copropriete_mensuelle ?? 0;
+ const dureeMois = data.duree_portage_mois ?? 0;
+ const mensualiteCredit = (prixAchat * (tauxCredit / 100)) / 12;
+ const mensualiteFoncier = taxeFon / 12;
+ const fraisPortageTotal = (mensualiteCredit + mensualiteFoncier + charges) * dureeMois;
+
+ const fraisAgenceAchat = data.frais_agence_achat ?? 0;
+ const prixRevient = prixAchat + fraisNotaire + fraisAgenceAchat + travauxTotal + fraisPortageTotal;
+
+ const prixRevente = data.prix_revente_cible ?? 0;
+ const margeBrute = prixRevente - prixRevient;
+
+ const fraisAgenceVentePct = data.frais_agence_vente_pct ?? 0;
+ const tauxImpot = data.taux_impot ?? 0;
+ const fraisAgenceVente = prixRevente * (fraisAgenceVentePct / 100);
+ const impotSurMarge = margeBrute * (tauxImpot / 100);
+ const margeNette = margeBrute - fraisAgenceVente - impotSurMarge;
+
+ const margeBrutePct = prixRevient > 0 ? (margeBrute / prixRevient) * 100 : 0;
+ const margeNettePct = prixRevente > 0 ? (margeNette / prixRevente) * 100 : 0;
+ const rendementNetPct = prixRevient > 0 ? (margeNette / prixRevient) * 100 : 0;
+
+ return {
+ frais_notaire: roundMoney(fraisNotaire),
+ travaux_total: roundMoney(travauxTotal),
+ frais_portage_total: roundMoney(fraisPortageTotal),
+ prix_revient: roundMoney(prixRevient),
+ marge_brute: roundMoney(margeBrute),
+ marge_nette: roundMoney(margeNette),
+ marge_brute_pct: roundMoney(margeBrutePct),
+ marge_nette_pct: roundMoney(margeNettePct),
+ rendement_net_pct: roundMoney(rendementNetPct),
+ };
+}
+
+export function rendementColor(rendementNetPct: number): string {
+ if (rendementNetPct > 15) return '#16A34A';
+ if (rendementNetPct >= 8) return '#EA580C';
+ return '#DC2626';
+}
+
+function formToRecord(form: AnalyseFormInput, calc: AnalyseCalculated): Record {
+ return {
+ prix_achat: form.prix_achat,
+ type_bien_fiscal: form.type_bien_fiscal,
+ frais_notaire: calc.frais_notaire,
+ frais_agence_achat: form.frais_agence_achat,
+ budget_travaux: form.budget_travaux,
+ reserve_imprevus_pct: form.reserve_imprevus_pct,
+ duree_portage_mois: form.duree_portage_mois,
+ taux_credit: form.taux_credit,
+ taxe_fonciere_annuelle: form.taxe_fonciere_annuelle,
+ charges_copropriete_mensuelle: form.charges_copropriete_mensuelle,
+ prix_revente_cible: form.prix_revente_cible,
+ frais_agence_vente_pct: form.frais_agence_vente_pct,
+ taux_impot: form.taux_impot,
+ marge_brute: calc.marge_brute,
+ marge_brute_pct: calc.marge_brute_pct,
+ marge_nette: calc.marge_nette,
+ marge_nette_pct: calc.marge_nette_pct,
+ };
+}
+
+export function useAnalyse(bienId: string | undefined) {
+ const uid = getCurrentUserId();
+ const queryClient = useQueryClient();
+
+ const query = useQuery({
+ queryKey: ['analyse_financiere', bienId, uid],
+ queryFn: async (): Promise => {
+ if (!bienId || !uid) return null;
+ const res = await pb.collection('analyses_financieres').getList(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(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(existing.id, payload);
+ }
+ return pb.collection('analyses_financieres').create({
+ user: uid,
+ bien: bienId,
+ ...payload,
+ });
+ },
+ onSuccess: () => {
+ void queryClient.invalidateQueries({ queryKey: ['analyse_financiere', bienId, uid] });
+ void queryClient.invalidateQueries({ queryKey: ['bien_detail', bienId] });
+ void queryClient.invalidateQueries({ queryKey: ['biens', uid] });
+ },
+ });
+
+ return {
+ analyse: query.data ?? null,
+ isLoading: query.isPending,
+ error: query.error,
+ refetch: query.refetch,
+ fetchAnalyse: query.refetch,
+ saveAnalyse: saveMutation.mutateAsync,
+ isSaving: saveMutation.isPending,
+ calculateResults,
+ };
+}
diff --git a/app/hooks/useBiens.ts b/app/hooks/useBiens.ts
new file mode 100644
index 0000000..a35c0f4
--- /dev/null
+++ b/app/hooks/useBiens.ts
@@ -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