This commit is contained in:
Bastien COIGNOUX
2026-05-04 08:28:32 +02:00
parent 7f94f83940
commit 695d4e76d0
46 changed files with 13390 additions and 251 deletions

193
GUIDE_COMPLET.md Normal file
View File

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

6
app/.gitignore vendored Normal file
View File

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

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

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

31
app/app.json Normal file
View File

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

View File

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

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

@ -0,0 +1,13 @@
import { Stack } from 'expo-router';
import { Text, View } from 'react-native';
export default function AgendaTab() {
return (
<>
<Stack.Screen options={{ title: 'Agenda', headerShown: true }} />
<View className="flex-1 items-center justify-center bg-slate-50 p-6">
<Text className="text-center text-slate-600">Agenda (tâches) à brancher sur la collection `taches`.</Text>
</View>
</>
);
}

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

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

View File

@ -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<ContactRecord>({
filter: `user="${uid}"`,
sort: 'nom',
});
},
enabled: Boolean(uid),
});
return (
<>
<Stack.Screen options={{ title: 'Contacts', headerShown: true }} />
<View className="flex-1 bg-slate-50">
{q.error ? (
<Text className="p-4 text-red-700">{formatPocketBaseError(q.error)}</Text>
) : null}
{q.isPending ? (
<View className="flex-1 items-center justify-center">
<ActivityIndicator color="#1D4ED8" />
</View>
) : (
<ScrollView className="flex-1 p-3" contentContainerStyle={{ paddingBottom: 80 }}>
{q.data?.map((c) => (
<Link key={c.id} href={`/contact/${c.id}`} asChild>
<Pressable className="mb-2 rounded-xl border border-slate-200 bg-white px-3 py-3">
<Text className="font-semibold text-slate-900">
{c.prenom ? `${c.prenom} ` : ''}
{c.nom}
</Text>
{c.societe ? <Text className="text-sm text-slate-500">{c.societe}</Text> : null}
</Pressable>
</Link>
))}
</ScrollView>
)}
<Link href="/contact/nouveau" asChild>
<Pressable className="absolute bottom-6 right-5 rounded-full bg-blue-700 px-4 py-3">
<Text className="font-semibold text-white">+ Contact</Text>
</Pressable>
</Link>
</View>
</>
);
}

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

@ -0,0 +1,20 @@
import { Link, Stack } from 'expo-router';
import { Text, View } from 'react-native';
export default function DashboardScreen() {
return (
<>
<Stack.Screen options={{ title: 'Dashboard', headerShown: true }} />
<View className="flex-1 bg-slate-50 p-4">
<Text className="mb-2 text-lg font-bold text-slate-900">Bienvenue</Text>
<Text className="mb-4 text-slate-600">Raccourcis :</Text>
<Link href="/(tabs)/biens" className="mb-2 text-base font-semibold text-blue-700">
Voir les biens (pipeline)
</Link>
<Link href="/bien/nouveau" className="mb-2 text-base font-semibold text-blue-700">
Nouveau bien
</Link>
</View>
</>
);
}

View File

@ -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<VisiteRecord>({
filter: `user="${uid}"`,
sort: '-date_visite',
});
},
enabled: Boolean(uid),
});
return (
<>
<Stack.Screen options={{ title: 'Visites', headerShown: true }} />
<View className="flex-1 bg-slate-50">
{q.error ? (
<Text className="p-4 text-red-700">{formatPocketBaseError(q.error)}</Text>
) : null}
{q.isPending ? (
<View className="flex-1 items-center justify-center">
<ActivityIndicator color="#1D4ED8" />
</View>
) : (
<ScrollView className="flex-1 p-3">
{q.data?.length === 0 ? (
<Text className="text-slate-600">Aucune visite.</Text>
) : null}
{q.data?.map((v) => (
<Pressable
key={v.id}
className="mb-2 rounded-xl border border-slate-200 bg-white p-3"
onPress={() => router.push(`/visite/${v.id}`)}
>
<Text className="font-semibold text-slate-900">{v.date_visite?.slice(0, 10) ?? '—'}</Text>
<Text className="text-sm text-slate-600">
{v.avis_global ? AVIS_VISITE[v.avis_global]?.label ?? v.avis_global : '—'}
</Text>
</Pressable>
))}
</ScrollView>
)}
</View>
</>
);
}

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

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

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

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

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

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

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

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

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

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

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

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

View File

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

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

@ -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<ContactRecord>(id);
},
enabled: Boolean(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">{c.categorie}</Text>
{c.email ? (
<>
<Text className="mt-3 text-sm text-slate-500">Email</Text>
<Text className="text-base text-slate-900">{c.email}</Text>
</>
) : null}
{c.telephone ? (
<>
<Text className="mt-3 text-sm text-slate-500">Téléphone</Text>
<Text className="text-base text-slate-900">{c.telephone}</Text>
</>
) : null}
</ScrollView>
</>
);
}

View File

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

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

@ -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 (
<View className="flex-1 items-center justify-center bg-slate-50">
<ActivityIndicator size="large" color="#1D4ED8" />
</View>
);
}
return user ? <Redirect href="/(tabs)" /> : <Redirect href="/auth/login" />;
}

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

@ -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<VisiteRecord>(id, { expand: 'bien' });
},
enabled: Boolean(id),
});
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 || !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 v = q.data;
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';
return (
<>
<Stack.Screen options={{ title: titre, headerShown: true }} />
<ScrollView className="flex-1 bg-slate-50 p-4">
<Text className="text-lg font-bold text-slate-900">Date</Text>
<Text className="text-slate-800">{v.date_visite ?? '—'}</Text>
<Text className="mt-4 text-lg font-bold text-slate-900">Avis</Text>
<Text className="text-slate-800">
{v.avis_global ? AVIS_VISITE[v.avis_global]?.label ?? v.avis_global : '—'}
</Text>
{v.notes_brutes ? (
<>
<Text className="mt-4 text-lg font-bold text-slate-900">Notes</Text>
<Text className="text-slate-800">{v.notes_brutes}</Text>
</>
) : null}
</ScrollView>
</>
);
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 B

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 B

9
app/babel.config.js Normal file
View File

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

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

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

View File

@ -0,0 +1,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 };

3
app/global.css Normal file
View File

@ -0,0 +1,3 @@
@tailwind base;
@tailwind components;
@tailwind utilities;

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

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

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

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

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

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

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

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

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

@ -0,0 +1,6 @@
const { getDefaultConfig } = require('expo/metro-config');
const { withNativeWind } = require('nativewind/metro');
const config = getDefaultConfig(__dirname);
module.exports = withNativeWind(config, { input: './global.css' });

1
app/nativewind-env.d.ts vendored Normal file
View File

@ -0,0 +1 @@
/// <reference types="nativewind/types" />

10226
app/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

49
app/package.json Normal file
View File

@ -0,0 +1,49 @@
{
"name": "app",
"version": "1.0.0",
"private": true,
"main": "expo-router/entry",
"scripts": {
"postinstall": "node scripts/ensure-assets.js",
"start": "expo start",
"android": "expo start --android",
"ios": "expo start --ios",
"web": "expo start --web"
},
"dependencies": {
"@expo/vector-icons": "^15.0.3",
"@react-native-async-storage/async-storage": "2.2.0",
"@react-navigation/native": "^7.1.8",
"@tanstack/react-query": "^5.90.0",
"expo": "~54.0.0",
"expo-constants": "~18.0.13",
"expo-document-picker": "~14.0.8",
"expo-font": "~14.0.11",
"expo-haptics": "~15.0.7",
"expo-image-picker": "~17.0.8",
"expo-linking": "~8.0.11",
"expo-router": "~6.0.0",
"expo-splash-screen": "~31.0.13",
"expo-status-bar": "~3.0.8",
"expo-web-browser": "~15.0.10",
"nativewind": "^4.1.23",
"pocketbase": "^0.26.0",
"react": "19.1.0",
"react-dom": "19.1.0",
"react-native": "0.81.4",
"react-native-gesture-handler": "~2.28.0",
"react-native-reanimated": "~4.1.1",
"react-native-safe-area-context": "~5.6.0",
"react-native-screens": "~4.16.0",
"react-native-web": "~0.21.0",
"react-native-worklets": "0.5.1",
"tailwindcss": "^3.4.17",
"zustand": "^5.0.3"
},
"devDependencies": {
"@babel/core": "^7.26.0",
"@types/react": "~19.1.0",
"babel-preset-expo": "~54.0.0",
"typescript": "~5.9.2"
}
}

View File

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

View File

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

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

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

10
app/tsconfig.json Normal file
View File

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

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

@ -0,0 +1,133 @@
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;
email?: string;
telephone?: string;
};
export type AnalyseFinanciereRecord = RecordModel & {
user: string;
bien: string;
prix_achat?: number;
type_bien_fiscal?: TypeBienFiscal;
frais_notaire?: number;
frais_agence_achat?: number;
budget_travaux?: number;
reserve_imprevus_pct?: number;
duree_portage_mois?: number;
taux_credit?: number;
taxe_fonciere_annuelle?: number;
charges_copropriete_mensuelle?: number;
prix_revente_cible?: number;
frais_agence_vente_pct?: number;
taux_impot?: number;
marge_brute?: number;
marge_brute_pct?: number;
marge_nette?: number;
marge_nette_pct?: number;
notes?: string;
};
export type VisiteRecord = RecordModel & {
user: string;
bien: string;
date_visite: string;
type_visite?: string;
avis_global?: string;
notes_brutes?: string;
};
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;
};

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

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

View File

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

View File

@ -1,16 +1,25 @@
/// <reference path="../pb_data/types.d.ts" />
migrate((app) => {
/**
* Collections métier PocketBase (v0.23+).
* Ne recrée PAS `users` : elle existe déjà (auth par défaut).
* Idempotent : si une collection existe déjà, on la réutilise (évite "name must be unique" au redémarrage).
*/
migrate(
(app) => {
const usersId = app.findCollectionByNameOrId("users").id;
// ── 0. Collection users (auth) ───────────────────────────
const users = new Collection({
name: "users",
type: "auth"
});
app.save(users);
const usersId = users.id;
function loadOrCreate(name, factory) {
try {
return app.findCollectionByNameOrId(name);
} catch {
const col = factory();
app.save(col);
return col;
}
}
// ── 1. Étapes pipeline ───────────────────────────────────
const etapes = new Collection({
const etapes = loadOrCreate("etapes_pipeline", () => new Collection({
name: "etapes_pipeline",
type: "base",
fields: [
@ -18,18 +27,17 @@ migrate((app) => {
{ name: "nom", type: "text", required: true },
{ name: "ordre", type: "number", required: true },
{ name: "couleur", type: "text" },
{ name: "is_terminal", type: "bool" }
{ name: "is_terminal", type: "bool" },
],
listRule: "@request.auth.id != '' && user = @request.auth.id",
viewRule: "@request.auth.id != '' && user = @request.auth.id",
createRule: "@request.auth.id != ''",
updateRule: "@request.auth.id != '' && user = @request.auth.id",
deleteRule: "@request.auth.id != '' && user = @request.auth.id"
});
app.save(etapes);
listRule: "@request.auth.id != \"\" && user = @request.auth.id",
viewRule: "@request.auth.id != \"\" && user = @request.auth.id",
createRule: "@request.auth.id != \"\"",
updateRule: "@request.auth.id != \"\" && user = @request.auth.id",
deleteRule: "@request.auth.id != \"\" && user = @request.auth.id",
}));
// ── 2. Contacts ──────────────────────────────────────────
const contacts = new Collection({
const contacts = loadOrCreate("contacts", () => new Collection({
name: "contacts",
type: "base",
fields: [
@ -48,18 +56,17 @@ migrate((app) => {
{ name: "recommande", type: "bool" },
{ name: "taux_horaire", type: "number" },
{ name: "notes", type: "text" },
{ name: "is_favori", type: "bool" }
{ name: "is_favori", type: "bool" },
],
listRule: "@request.auth.id != '' && user = @request.auth.id",
viewRule: "@request.auth.id != '' && user = @request.auth.id",
createRule: "@request.auth.id != ''",
updateRule: "@request.auth.id != '' && user = @request.auth.id",
deleteRule: "@request.auth.id != '' && user = @request.auth.id"
});
app.save(contacts);
listRule: "@request.auth.id != \"\" && user = @request.auth.id",
viewRule: "@request.auth.id != \"\" && user = @request.auth.id",
createRule: "@request.auth.id != \"\"",
updateRule: "@request.auth.id != \"\" && user = @request.auth.id",
deleteRule: "@request.auth.id != \"\" && user = @request.auth.id",
}));
// ── 3. Biens ─────────────────────────────────────────────
const biens = new Collection({
const biens = loadOrCreate("biens", () => new Collection({
name: "biens",
type: "base",
fields: [
@ -92,18 +99,17 @@ migrate((app) => {
{ name: "description", type: "text" },
{ name: "points_forts", type: "text" },
{ name: "points_faibles", type: "text" },
{ name: "photo_principale", type: "file", options: { maxSelect: 1, maxSize: 10485760, mimeTypes: ["image/jpeg","image/png","image/webp"] } }
{ name: "photo_principale", type: "file", options: { maxSelect: 1, maxSize: 10485760, mimeTypes: ["image/jpeg", "image/png", "image/webp"] } },
],
listRule: "@request.auth.id != '' && user = @request.auth.id",
viewRule: "@request.auth.id != '' && user = @request.auth.id",
createRule: "@request.auth.id != ''",
updateRule: "@request.auth.id != '' && user = @request.auth.id",
deleteRule: "@request.auth.id != '' && user = @request.auth.id"
});
app.save(biens);
listRule: "@request.auth.id != \"\" && user = @request.auth.id",
viewRule: "@request.auth.id != \"\" && user = @request.auth.id",
createRule: "@request.auth.id != \"\"",
updateRule: "@request.auth.id != \"\" && user = @request.auth.id",
deleteRule: "@request.auth.id != \"\" && user = @request.auth.id",
}));
// ── 4. Analyses financières ───────────────────────────────
const analyses = new Collection({
loadOrCreate("analyses_financieres", () => new Collection({
name: "analyses_financieres",
type: "base",
fields: [
@ -126,18 +132,17 @@ migrate((app) => {
{ name: "marge_brute_pct", type: "number" },
{ name: "marge_nette", type: "number" },
{ name: "marge_nette_pct", type: "number" },
{ name: "notes", type: "text" }
{ name: "notes", type: "text" },
],
listRule: "@request.auth.id != '' && user = @request.auth.id",
viewRule: "@request.auth.id != '' && user = @request.auth.id",
createRule: "@request.auth.id != ''",
updateRule: "@request.auth.id != '' && user = @request.auth.id",
deleteRule: "@request.auth.id != '' && user = @request.auth.id"
});
app.save(analyses);
listRule: "@request.auth.id != \"\" && user = @request.auth.id",
viewRule: "@request.auth.id != \"\" && user = @request.auth.id",
createRule: "@request.auth.id != \"\"",
updateRule: "@request.auth.id != \"\" && user = @request.auth.id",
deleteRule: "@request.auth.id != \"\" && user = @request.auth.id",
}));
// ── 5. Visites ────────────────────────────────────────────
const visites = new Collection({
loadOrCreate("visites", () => new Collection({
name: "visites",
type: "base",
fields: [
@ -153,18 +158,17 @@ migrate((app) => {
{ name: "estimation_travaux_max", type: "number" },
{ name: "avis_global", type: "select", options: { maxSelect: 1, values: ["coup_de_coeur", "interessant", "neutre", "a_eviter"] } },
{ name: "score_opportunite", type: "number", options: { min: 1, max: 10 } },
{ name: "photos", type: "file", options: { maxSelect: 20, maxSize: 10485760, mimeTypes: ["image/jpeg","image/png","image/webp"] } }
{ name: "photos", type: "file", options: { maxSelect: 20, maxSize: 10485760, mimeTypes: ["image/jpeg", "image/png", "image/webp"] } },
],
listRule: "@request.auth.id != '' && user = @request.auth.id",
viewRule: "@request.auth.id != '' && user = @request.auth.id",
createRule: "@request.auth.id != ''",
updateRule: "@request.auth.id != '' && user = @request.auth.id",
deleteRule: "@request.auth.id != '' && user = @request.auth.id"
});
app.save(visites);
listRule: "@request.auth.id != \"\" && user = @request.auth.id",
viewRule: "@request.auth.id != \"\" && user = @request.auth.id",
createRule: "@request.auth.id != \"\"",
updateRule: "@request.auth.id != \"\" && user = @request.auth.id",
deleteRule: "@request.auth.id != \"\" && user = @request.auth.id",
}));
// ── 6. Tâches ─────────────────────────────────────────────
const taches = new Collection({
loadOrCreate("taches", () => new Collection({
name: "taches",
type: "base",
fields: [
@ -178,36 +182,34 @@ migrate((app) => {
{ name: "statut", type: "select", options: { maxSelect: 1, values: ["a_faire", "en_cours", "fait", "annule"] } },
{ name: "date_echeance", type: "date" },
{ name: "date_rappel", type: "date" },
{ name: "is_urgent", type: "bool" }
{ name: "is_urgent", type: "bool" },
],
listRule: "@request.auth.id != '' && user = @request.auth.id",
viewRule: "@request.auth.id != '' && user = @request.auth.id",
createRule: "@request.auth.id != ''",
updateRule: "@request.auth.id != '' && user = @request.auth.id",
deleteRule: "@request.auth.id != '' && user = @request.auth.id"
});
app.save(taches);
listRule: "@request.auth.id != \"\" && user = @request.auth.id",
viewRule: "@request.auth.id != \"\" && user = @request.auth.id",
createRule: "@request.auth.id != \"\"",
updateRule: "@request.auth.id != \"\" && user = @request.auth.id",
deleteRule: "@request.auth.id != \"\" && user = @request.auth.id",
}));
// ── 7. Notes biens ────────────────────────────────────────
const notes = new Collection({
loadOrCreate("notes_biens", () => new Collection({
name: "notes_biens",
type: "base",
fields: [
{ name: "user", type: "relation", required: true, options: { collectionId: usersId, maxSelect: 1, cascadeDelete: true } },
{ name: "bien", type: "relation", required: true, options: { collectionId: biens.id, maxSelect: 1, cascadeDelete: true } },
{ name: "contenu", type: "text", required: true },
{ name: "type_note", type: "select", options: { maxSelect: 1, values: ["libre","contact_vendeur","negociation","info_marche"] } }
{ name: "type_note", type: "select", options: { maxSelect: 1, values: ["libre", "contact_vendeur", "negociation", "info_marche"] } },
],
listRule: "@request.auth.id != '' && user = @request.auth.id",
viewRule: "@request.auth.id != '' && user = @request.auth.id",
createRule: "@request.auth.id != ''",
updateRule: "@request.auth.id != '' && user = @request.auth.id",
deleteRule: "@request.auth.id != '' && user = @request.auth.id"
});
app.save(notes);
listRule: "@request.auth.id != \"\" && user = @request.auth.id",
viewRule: "@request.auth.id != \"\" && user = @request.auth.id",
createRule: "@request.auth.id != \"\"",
updateRule: "@request.auth.id != \"\" && user = @request.auth.id",
deleteRule: "@request.auth.id != \"\" && user = @request.auth.id",
}));
// ── 8. Documents biens ────────────────────────────────────
const documents = new Collection({
loadOrCreate("documents_biens", () => new Collection({
name: "documents_biens",
type: "base",
fields: [
@ -217,18 +219,17 @@ migrate((app) => {
{ name: "type_document", type: "select", options: { maxSelect: 1, values: ["compromis", "acte", "dpe", "diagnostics", "devis", "facture", "titre_propriete", "autre"] } },
{ name: "date_document", type: "date" },
{ name: "notes", type: "text" },
{ name: "fichier", type: "file", required: true, options: { maxSelect: 1, maxSize: 52428800, mimeTypes: ["application/pdf","image/jpeg","image/png"] } }
{ name: "fichier", type: "file", required: true, options: { maxSelect: 1, maxSize: 52428800, mimeTypes: ["application/pdf", "image/jpeg", "image/png"] } },
],
listRule: "@request.auth.id != '' && user = @request.auth.id",
viewRule: "@request.auth.id != '' && user = @request.auth.id",
createRule: "@request.auth.id != ''",
updateRule: "@request.auth.id != '' && user = @request.auth.id",
deleteRule: "@request.auth.id != '' && user = @request.auth.id"
});
app.save(documents);
listRule: "@request.auth.id != \"\" && user = @request.auth.id",
viewRule: "@request.auth.id != \"\" && user = @request.auth.id",
createRule: "@request.auth.id != \"\"",
updateRule: "@request.auth.id != \"\" && user = @request.auth.id",
deleteRule: "@request.auth.id != \"\" && user = @request.auth.id",
}));
// ── 9. Devis travaux ─────────────────────────────────────
const devis = new Collection({
// ── 9. Devis travaux ─────────────────────────────────────
loadOrCreate("devis_travaux", () => new Collection({
name: "devis_travaux",
type: "base",
fields: [
@ -245,18 +246,30 @@ migrate((app) => {
{ name: "date_debut", type: "date" },
{ name: "date_fin_prevu", type: "date" },
{ name: "notes", type: "text" },
{ name: "fichier_pdf", type: "file", options: { maxSelect: 1, maxSize: 10485760, mimeTypes: ["application/pdf"] } }
{ name: "fichier_pdf", type: "file", options: { maxSelect: 1, maxSize: 10485760, mimeTypes: ["application/pdf"] } },
],
listRule: "@request.auth.id != '' && user = @request.auth.id",
viewRule: "@request.auth.id != '' && user = @request.auth.id",
createRule: "@request.auth.id != ''",
updateRule: "@request.auth.id != '' && user = @request.auth.id",
deleteRule: "@request.auth.id != '' && user = @request.auth.id"
});
app.save(devis);
}, (app) => {
for (const name of ["devis_travaux","documents_biens","notes_biens","taches","visites","analyses_financieres","biens","contacts","etapes_pipeline","users"]) {
try { app.delete(app.findCollectionByNameOrId(name)); } catch(_) {}
listRule: "@request.auth.id != \"\" && user = @request.auth.id",
viewRule: "@request.auth.id != \"\" && user = @request.auth.id",
createRule: "@request.auth.id != \"\"",
updateRule: "@request.auth.id != \"\" && user = @request.auth.id",
deleteRule: "@request.auth.id != \"\" && user = @request.auth.id",
}));
},
(app) => {
for (const name of [
"devis_travaux",
"documents_biens",
"notes_biens",
"taches",
"visites",
"analyses_financieres",
"biens",
"contacts",
"etapes_pipeline",
]) {
try {
app.delete(app.findCollectionByNameOrId(name));
} catch (_) {}
}
});
},
);

View File

@ -0,0 +1,40 @@
/**
* Remplace `!= ''` par `!= ""` dans les règles API (syntaxe attendue par PocketBase).
* Sans ça, les GET list peuvent répondre 400 alors que les données existent.
*/
migrate(
(app) => {
const names = [
"etapes_pipeline",
"contacts",
"biens",
"analyses_financieres",
"visites",
"taches",
"notes_biens",
"documents_biens",
"devis_travaux",
];
const ruleKeys = ["listRule", "viewRule", "createRule", "updateRule", "deleteRule"];
for (const name of names) {
let col;
try {
col = app.findCollectionByNameOrId(name);
} catch {
continue;
}
let changed = false;
for (const key of ruleKeys) {
const v = col[key];
if (typeof v === "string" && v.includes("!= ''")) {
col[key] = v.replace(/!= ''/g, '!= ""');
changed = true;
}
}
if (changed) {
app.save(col);
}
}
},
() => {},
);