diff --git a/.cursorrules b/.cursorrules
index e43484f..818ecdc 100644
--- a/.cursorrules
+++ b/.cursorrules
@@ -24,7 +24,8 @@ export const pb = new PocketBase(process.env.EXPO_PUBLIC_PB_URL);
## Collections PocketBase (toutes créées via migration)
etapes_pipeline, contacts, biens, analyses_financieres,
-visites, taches, notes_biens, documents_biens, devis_travaux
+visites, taches, notes_biens, documents_biens, devis_travaux,
+analyses_secteur, notes_prospection, grille_prix
## Règles de code
- TypeScript strict, jamais de any
diff --git a/AGENTS.md b/AGENTS.md
index 2c4d267..483340e 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -12,6 +12,31 @@
- [x] Module Visites + IA (`pb_hooks/generate_rapport.pb.js`, route `POST /api/mdb/generate-rapport`)
- [x] Module Agenda (tâches, snooze, création modal)
- [x] Dashboard (alertes, KPIs, pipeline, derniers biens, tâches du jour)
+- [x] Module Recherche & Analyse marché (onglet Recherche : Secteur / Opportunités / Grille de prix + fiche bien)
+
+## Roadmap — Agrégation type MoteurImmo & agents IA
+
+Référence produit : [moteurimmo.fr](https://moteurimmo.fr/) (agrégation multi-portails, alertes, DVF/transactions, API pro).
+
+**Écart actuel** : pas d’ingestion de flux externes ni de moteur d’alertes ; la grille / secteur restent des données **saisies ou locales** (PocketBase), pas une veille marché temps réel.
+
+### Personas agents (rôles métier + briques techniques)
+
+| Agent | Mission | Briques typiques |
+|-------|---------|------------------|
+| **Immobilier** | Off-market, diffusion agence (priorité site maison), prospection pour alimenter le pipe | Collections opportunités / contacts / tâches ; hooks ou jobs pour brouillons de contenu ; pas de scraping illégal — privilégier saisie, imports CSV, API partenaires. |
+| **Marchand de biens** | Prix secteur, €/m², repérage bonnes offres | Grille perso + DVF / transactions (open data) ; scoring simple ; alertes sur critères (prix/m², surface, zone). |
+| **Data / DVF** | Normaliser transactions publiques, relier zone ↔ bien | Import DVF (fichiers ou API tiers), tables dérivées, carto plus tard. |
+| **Veille annonces** | Agréger sources autorisées (API, flux partenaires, [API MoteurImmo](https://moteurimmo.fr/) si abonnement) | Collections `sources_flux`, `annonces_brutes`, `alertes_recherche` ; cron PocketBase ou worker externe ; dédoublonnage. |
+| **Alertes & notif** | Push / email quand une annonce ou une transac matche une recherche sauvegardée | Règles métier + Expo notifications ; file d’événements côté PB. |
+| **Rédaction / CRM** | Textes vitrine, relances, synthèses pour prospection | Réutiliser le pattern hook IA (`generate_rapport`) par type de prompt. |
+
+### Phases suggérées
+
+1. **Modèle de données** : recherches sauvegardées, alertes, log d’ingestion (sans agrégateur massif au début).
+2. **Données publiques** : DVF ou extrait local par zone (preuve de valeur pour €/m² réel).
+3. **Une source API fiable** (partenaire ou open data) avant tout volume type MoteurImmo.
+4. **UI** : liste annonces unifiée + filtres + onglet alertes dans Recherche.
## Infos techniques
- PocketBase : http://localhost:8090
diff --git a/app/app/(tabs)/_layout.tsx b/app/app/(tabs)/_layout.tsx
index a35f1a6..24e1720 100644
--- a/app/app/(tabs)/_layout.tsx
+++ b/app/app/(tabs)/_layout.tsx
@@ -45,6 +45,13 @@ export default function TabsLayout() {
tabBarIcon: ({ color, size }) => ,
}}
/>
+ ,
+ }}
+ />
);
}
diff --git a/app/app/(tabs)/agenda.tsx b/app/app/(tabs)/agenda.tsx
index 18d665c..7adab73 100644
--- a/app/app/(tabs)/agenda.tsx
+++ b/app/app/(tabs)/agenda.tsx
@@ -12,6 +12,9 @@ import {
View,
} from 'react-native';
+import { EmptyState } from '@/components/ui/EmptyState';
+import { ListSkeleton } from '@/components/ui/ListSkeleton';
+import { UI } from '@/constants/uiTheme';
import { useBiens } from '@/hooks/useBiens';
import { useTachesList } from '@/hooks/useTaches';
import type { TacheExpanded } from '@/types/collections';
@@ -91,25 +94,43 @@ export default function AgendaTab() {
]);
};
+ const emptyList =
+ !isLoading && sections.length === 0 ? (
+
+ ) : null;
+
return (
<>
-
+
{error ? (
- {formatPocketBaseError(error)}
+
+
+ {formatPocketBaseError(error)}
+
+
) : null}
{isLoading ? (
-
-
-
+
) : (
item.id}
- contentContainerStyle={{ padding: 12, paddingBottom: 96 }}
+ contentContainerStyle={
+ sections.length === 0 ? { flexGrow: 1, padding: 12, paddingBottom: 112 } : { padding: 12, paddingBottom: 112 }
+ }
renderSectionHeader={({ section }) => (
{section.title}
@@ -118,85 +139,138 @@ export default function AgendaTab() {
const done = t.statut === 'fait';
const badge = bienLabel(t);
return (
-
-
+
+
void toggleDone(t)}
- className={`mt-0.5 h-6 w-6 items-center justify-center rounded border ${done ? 'border-green-600 bg-green-600' : 'border-slate-300 bg-white'}`}
+ className="mt-1 h-12 w-12 items-center justify-center rounded-xl border-2 active:opacity-90"
+ style={{
+ borderColor: done ? UI.success : UI.border,
+ backgroundColor: done ? UI.success : UI.card,
+ }}
>
- {done ? ✓ : null}
+ {done ? (
+ ✓
+ ) : null}
{t.titre}
{badge ? (
- {badge}
+
+ {badge}
+
) : null}
{t.date_echeance ? (
- {t.date_echeance}
+
+ {t.date_echeance}
+
) : null}
-
+
void snoozeOneDay(t)}
- className="rounded-lg bg-slate-100 px-3 py-2"
+ className="min-h-[52px] min-w-[140px] flex-1 items-center justify-center rounded-2xl border-2 px-4 active:opacity-90"
+ style={{ borderColor: UI.warning, backgroundColor: '#FFFBEB' }}
>
- Snooze +1 j
+
+ +1 jour
+
confirmDelete(t)}
- className="rounded-lg bg-red-50 px-3 py-2"
+ className="min-h-[52px] min-w-[120px] flex-1 items-center justify-center rounded-2xl border-2 px-4 active:opacity-90"
+ style={{ borderColor: UI.danger, backgroundColor: '#FEF2F2' }}
>
- Supprimer
+
+ Supprimer
+
);
}}
- ListEmptyComponent={
- Aucune tâche à afficher.
- }
+ ListEmptyComponent={emptyList}
/>
)}
- + Tâche
+ + Tâche
-
-
- Nouvelle tâche
- Titre
+
+
+
+ Nouvelle tâche
+
+
+ Titre
+
- Échéance (AAAA-MM-JJ)
+
+ Échéance (AAAA-MM-JJ)
+
- Bien (optionnel)
-
+
+ Bien (optionnel)
+
+
setNewBienId(undefined)}
- className={`mr-2 rounded-full px-3 py-2 ${newBienId == null ? 'bg-slate-800' : 'bg-slate-100'}`}
+ className={`mr-2 min-h-[48px] justify-center rounded-2xl px-4 ${newBienId == null ? '' : 'border-2'}`}
+ style={
+ newBienId == null
+ ? { backgroundColor: UI.primary }
+ : { borderColor: UI.border, backgroundColor: UI.screen }
+ }
>
-
+
Aucun
@@ -204,33 +278,38 @@ export default function AgendaTab() {
setNewBienId(b.id)}
- className={`mr-2 max-w-[200px] rounded-full px-3 py-2 ${newBienId === b.id ? 'bg-blue-700' : 'bg-slate-100'}`}
+ className="mr-2 max-w-[220px] min-h-[48px] justify-center rounded-2xl border-2 px-4"
+ style={{
+ borderColor: newBienId === b.id ? UI.primary : UI.border,
+ backgroundColor: newBienId === b.id ? '#EFF6FF' : UI.card,
+ }}
>
-
+
{b.titre?.trim() || b.ville || b.id}
))}
-
+
setModalOpen(false)}
- className="flex-1 items-center rounded-xl border border-slate-200 py-3"
+ className="min-h-[56px] flex-1 items-center justify-center rounded-2xl border-2 active:opacity-90"
+ style={{ borderColor: UI.border }}
>
- Annuler
+
+ Annuler
+
void submitCreate()}
disabled={isCreatePending}
- className="flex-1 items-center rounded-xl bg-blue-700 py-3"
+ className="min-h-[56px] flex-1 items-center justify-center rounded-2xl active:opacity-90"
+ style={{ backgroundColor: UI.primary }}
>
{isCreatePending ? (
) : (
- Créer
+ Créer
)}
diff --git a/app/app/(tabs)/biens.tsx b/app/app/(tabs)/biens.tsx
index 377f553..f7e48fe 100644
--- a/app/app/(tabs)/biens.tsx
+++ b/app/app/(tabs)/biens.tsx
@@ -1,7 +1,10 @@
import { Link, Stack } from 'expo-router';
import { useEffect, useMemo, useRef } from 'react';
-import { ActivityIndicator, Pressable, ScrollView, Text, View } from 'react-native';
+import { Pressable, ScrollView, Text, View } from 'react-native';
+import { EmptyState } from '@/components/ui/EmptyState';
+import { PipelineSkeleton } from '@/components/ui/PipelineSkeleton';
+import { UI } from '@/constants/uiTheme';
import { useBiens, type BienExpanded } from '@/hooks/useBiens';
import { useEtapes } from '@/hooks/useEtapes';
import { formatEUR } from '@/utils/format';
@@ -48,44 +51,89 @@ export default function BiensScreen() {
? formatPocketBaseError(etapesInitMutationError)
: null;
+ const loading = isLoading || etapesLoading;
+
return (
<>
-
+
{banner ? (
-
- {banner}
+
+
+ {banner}
+
) : null}
- {isLoading || etapesLoading ? (
-
-
+ {loading ? (
+
+ ) : biens.length === 0 ? (
+
+
) : (
-
+
{etapes.map((e) => {
const list = grouped.m.get(e.id) ?? [];
return (
-
-
-
+
+
+
{e.nom}
- {list.length}
+
+ {list.length}
+
- {list.length} bien(s)
-
+
{list.map((b) => (
-
-
+
+
{b.titre ?? 'Sans titre'}
-
+
{[b.code_postal, b.ville].filter(Boolean).join(' ') || '—'}
{prixByBien.has(b.id) ? (
-
+
{formatEUR(prixByBien.get(b.id))}
) : null}
@@ -96,16 +144,29 @@ export default function BiensScreen() {
);
})}
-
- Sans étape
-
+
+
+ Sans étape
+
+
{(grouped.m.get(grouped.none) ?? []).length} bien(s)
-
+
{(grouped.m.get(grouped.none) ?? []).map((b) => (
-
-
+
+
{b.titre ?? 'Sans titre'}
@@ -117,10 +178,12 @@ export default function BiensScreen() {
)}
- +
+ +
diff --git a/app/app/(tabs)/contacts.tsx b/app/app/(tabs)/contacts.tsx
index 6442bdc..8c09f5d 100644
--- a/app/app/(tabs)/contacts.tsx
+++ b/app/app/(tabs)/contacts.tsx
@@ -1,16 +1,11 @@
import { useMemo, useState } from 'react';
import { Link, Stack } from 'expo-router';
-import {
- ActivityIndicator,
- Linking,
- Pressable,
- SectionList,
- Text,
- TextInput,
- View,
-} from 'react-native';
+import { Linking, Pressable, SectionList, Text, TextInput, View } from 'react-native';
+import { EmptyState } from '@/components/ui/EmptyState';
+import { ListSkeleton } from '@/components/ui/ListSkeleton';
import { labelContactCategorie } from '@/constants/contactCategories';
+import { UI } from '@/constants/uiTheme';
import { useContactsList } from '@/hooks/useContacts';
import type { ContactRecord } from '@/types/collections';
import { formatPocketBaseError } from '@/utils/pocketbaseErrors';
@@ -51,59 +46,114 @@ export default function ContactsTab() {
.sort((a, b) => a.title.localeCompare(b.title, 'fr'));
}, [q.data, search]);
+ const listEmpty =
+ !q.isPending && sections.length === 0 ? (
+ search.trim() ? (
+ setSearch('')}
+ />
+ ) : (
+
+ )
+ ) : null;
+
return (
<>
-
+
{q.error ? (
- {formatPocketBaseError(q.error)}
+
+
+ {formatPocketBaseError(q.error)}
+
+
) : null}
{q.isPending ? (
-
-
-
+
) : (
item.id}
- contentContainerStyle={{ paddingBottom: 88 }}
+ contentContainerStyle={
+ sections.length === 0 ? { flexGrow: 1, paddingBottom: 100 } : { paddingBottom: 100 }
+ }
renderSectionHeader={({ section: { title } }) => (
- {title}
+
+ {title}
+
)}
renderItem={({ item: c }) => (
-
+
-
-
+
+
{c.prenom ? `${c.prenom} ` : ''}
{c.nom}
- {c.societe ? {c.societe} : null}
+ {c.societe ? (
+
+ {c.societe}
+
+ ) : null}
{c.telephone ? (
- openTel(c.telephone)} className="mt-2 self-start">
- {c.telephone}
+ openTel(c.telephone)}
+ className="mt-3 min-h-[48px] justify-center self-start rounded-xl px-3 active:opacity-90"
+ style={{ backgroundColor: '#EFF6FF' }}
+ >
+
+ {c.telephone}
+
) : null}
)}
- ListEmptyComponent={
- Aucun contact.
- }
+ ListEmptyComponent={listEmpty}
/>
)}
-
- + Contact
+
+ + Contact
diff --git a/app/app/(tabs)/index.tsx b/app/app/(tabs)/index.tsx
index 7b97509..26b614a 100644
--- a/app/app/(tabs)/index.tsx
+++ b/app/app/(tabs)/index.tsx
@@ -1,12 +1,15 @@
import { useMemo } from 'react';
import { Link, Stack } from 'expo-router';
-import { ActivityIndicator, Pressable, ScrollView, Text, View } from 'react-native';
+import { Pressable, ScrollView, Text, View } from 'react-native';
+import { EmptyState } from '@/components/ui/EmptyState';
+import { DashboardSkeleton } from '@/components/ui/DashboardSkeleton';
+import { TYPES_BIENS } from '@/constants/metier';
+import { UI } from '@/constants/uiTheme';
import { useBiens } from '@/hooks/useBiens';
+import type { BienExpanded } from '@/hooks/useBiens';
import { useEtapes } from '@/hooks/useEtapes';
import { useTachesList } from '@/hooks/useTaches';
-import { TYPES_BIENS } from '@/constants/metier';
-import type { BienExpanded } from '@/hooks/useBiens';
import {
isTaskActive,
parsePbDateOnly,
@@ -57,136 +60,273 @@ export default function DashboardScreen() {
return (
<>
-
+
{biensError ? (
- {formatPocketBaseError(biensError)}
+
+
+ {formatPocketBaseError(biensError)}
+
+
) : null}
{tachesError ? (
- {formatPocketBaseError(tachesError)}
+
+
+ {formatPocketBaseError(tachesError)}
+
+
) : null}
{loading ? (
-
-
-
- ) : null}
-
- Alertes urgentes
- {urgent.length === 0 ? (
- Aucune alerte.
+
) : (
-
- {urgent.slice(0, 6).map((t) => (
-
-
- {t.titre}
- {t.date_echeance ? (
- {t.date_echeance}
- ) : null}
-
-
- ))}
-
- )}
-
- Indicateurs
-
-
- {biens.length}
- Biens
-
-
- {actifs.length}
- Actifs
-
-
-
- {taches.filter(isTaskActive).length}
+ <>
+
+ Alertes urgentes
- Tâches ouvertes
-
-
+ {urgent.length === 0 ? (
+
+ ) : (
+
+ {urgent.slice(0, 6).map((t) => (
+
+
+
+ {t.titre}
+
+ {t.date_echeance ? (
+
+ {t.date_echeance}
+
+ ) : null}
+
+
+ ))}
+
+ )}
- Pipeline
-
-
- {etapes.map((e) => {
- const n = etapeCounts.get(e.id) ?? 0;
- return (
-
-
-
- {e.nom}
-
- {n}
-
- );
- })}
- {(etapeCounts.get('') ?? 0) > 0 ? (
-
- Sans étape
-
- {etapeCounts.get('') ?? 0}
+
+ Indicateurs
+
+
+
+
+ {biens.length}
+
+
+ Biens
- ) : null}
-
-
+
+
+ {actifs.length}
+
+
+ Actifs
+
+
+
+
+ {taches.filter(isTaskActive).length}
+
+
+ Tâches ouvertes
+
+
+
-
- Derniers biens
-
- Voir tout
-
-
-
- {derniers.length === 0 ? (
- Aucun bien.
- ) : (
- derniers.map((b) => (
-
-
- {bienTitre(b)}
-
- {b.expand?.etape?.nom ?? '—'} · {b.ville ?? ''}
+
+
+ Pipeline
+
+
+
+
+ {etapes.map((e) => {
+ const n = etapeCounts.get(e.id) ?? 0;
+ return (
+
+
+
+ {e.nom}
+
+
+ {n}
+
+
+ );
+ })}
+ {(etapeCounts.get('') ?? 0) > 0 ? (
+
+
+ Sans étape
+
+
+ {etapeCounts.get('') ?? 0}
+
+
+ ) : null}
+
+
+
+
+
+ Derniers biens
+
+
+
+
+ Tout voir
- ))
- )}
-
-
-
- Tâches du jour
-
- Agenda
-
-
-
- {part.today.length === 0 ? (
- Rien de prévu aujourd’hui.
- ) : (
- part.today.map((t) => (
-
- {t.titre}
+
+ {derniers.length === 0 ? (
+
+
- ))
- )}
-
+ ) : (
+
+ {derniers.map((b) => (
+
+
+
+ {bienTitre(b)}
+
+
+ {b.expand?.etape?.nom ?? '—'} · {b.ville ?? ''}
+
+
+
+ ))}
+
+ )}
- Raccourcis
-
- Nouveau bien
-
-
- Contacts
-
-
- Visites
-
+
+
+ Tâches du jour
+
+
+
+
+ Agenda
+
+
+
+
+ {part.today.length === 0 ? (
+
+
+
+ ) : (
+
+ {part.today.map((t) => (
+
+
+ {t.titre}
+
+
+ ))}
+
+ )}
+
+
+ Raccourcis
+
+
+
+
+ Nouveau bien
+
+
+
+
+
+ Contacts
+
+
+
+
+
+
+ Visites
+
+
+
+
+ >
+ )}
>
);
diff --git a/app/app/(tabs)/recherche.tsx b/app/app/(tabs)/recherche.tsx
new file mode 100644
index 0000000..6230020
--- /dev/null
+++ b/app/app/(tabs)/recherche.tsx
@@ -0,0 +1,44 @@
+import { Stack } from 'expo-router';
+import { useState } from 'react';
+import { Pressable, Text, View } from 'react-native';
+
+import { GrillePrixTab } from '@/components/recherche/GrillePrixTab';
+import { OpportunitesTab } from '@/components/recherche/OpportunitesTab';
+import { SecteurTab } from '@/components/recherche/SecteurTab';
+import { UI } from '@/constants/uiTheme';
+
+const TABS = ['Secteur', 'Opportunités', 'Grille de prix'] as const;
+
+export default function RechercheTab() {
+ const [sub, setSub] = useState(0);
+
+ return (
+ <>
+
+
+
+ {TABS.map((label, i) => (
+ setSub(i)}
+ className="min-h-[52px] flex-1 items-center justify-center border-b-4 py-3"
+ style={{ borderBottomColor: sub === i ? UI.primary : 'transparent' }}
+ >
+
+ {label}
+
+
+ ))}
+
+ {sub === 0 ? : null}
+ {sub === 1 ? : null}
+ {sub === 2 ? : null}
+
+ >
+ );
+}
diff --git a/app/app/(tabs)/visites.tsx b/app/app/(tabs)/visites.tsx
index 042af93..f287544 100644
--- a/app/app/(tabs)/visites.tsx
+++ b/app/app/(tabs)/visites.tsx
@@ -1,7 +1,10 @@
import { Stack, useRouter } from 'expo-router';
-import { ActivityIndicator, Pressable, ScrollView, Text, View } from 'react-native';
+import { Pressable, ScrollView, Text, View } from 'react-native';
+import { EmptyState } from '@/components/ui/EmptyState';
+import { ListSkeleton } from '@/components/ui/ListSkeleton';
import { AVIS_VISITE } from '@/constants/metier';
+import { UI } from '@/constants/uiTheme';
import { useVisitesList } from '@/hooks/useVisites';
import { formatPocketBaseError } from '@/utils/pocketbaseErrors';
@@ -12,31 +15,46 @@ export default function VisitesTab() {
return (
<>
-
+
{q.error ? (
- {formatPocketBaseError(q.error)}
+
+
+ {formatPocketBaseError(q.error)}
+
+
) : null}
{q.isPending ? (
-
-
-
+
) : (
-
+
{q.data?.length === 0 ? (
- Aucune visite.
- ) : null}
- {q.data?.map((v) => (
- router.push(`/visite/${v.id}`)}
- >
- {v.date_visite?.slice(0, 10) ?? '—'}
-
- {v.avis_global ? AVIS_VISITE[v.avis_global]?.label ?? v.avis_global : '—'}
-
-
- ))}
+
+ ) : (
+ q.data?.map((v) => (
+ router.push(`/visite/${v.id}`)}
+ >
+
+ {v.date_visite?.slice(0, 10) ?? '—'}
+
+
+ {v.avis_global ? AVIS_VISITE[v.avis_global]?.label ?? v.avis_global : 'Avis non renseigné'}
+
+
+ ))
+ )}
)}
diff --git a/app/app/bien/[id].tsx b/app/app/bien/[id].tsx
index f5c7d2c..9582955 100644
--- a/app/app/bien/[id].tsx
+++ b/app/app/bien/[id].tsx
@@ -1,8 +1,11 @@
import { Link, Stack, useLocalSearchParams, useRouter } from 'expo-router';
import type { ReactNode } from 'react';
+import { useEffect, useState } from 'react';
import {
ActivityIndicator,
+ Alert,
FlatList,
+ Linking,
Pressable,
ScrollView,
Text,
@@ -11,9 +14,10 @@ import {
} from 'react-native';
import { AVIS_VISITE, TYPES_BIENS } from '@/constants/metier';
+import { dvfSearchUrl, geoportailBienUrl } from '@/constants/rechercheMarche';
import { useBienDetail } from '@/hooks/useBiens';
import { useNoteLibre } from '@/hooks/useNoteLibre';
-import { calculateResults, type AnalyseFormInput } from '@/hooks/useAnalyse';
+import { calculateResults, useAnalyse, type AnalyseFormInput } from '@/hooks/useAnalyse';
import { formatEUR } from '@/utils/format';
function routeParamId(raw: string | string[] | undefined): string | undefined {
@@ -27,6 +31,8 @@ export default function BienDetailScreen() {
const router = useRouter();
const { bundle, isLoading, error } = useBienDetail(id);
const { draft, setDraft, hydrated } = useNoteLibre(id, bundle?.notes);
+ const { patchAnalyse, isPatching } = useAnalyse(id);
+ const [prixReventeM2Draft, setPrixReventeM2Draft] = useState('');
if (!id) {
return (
@@ -73,6 +79,24 @@ export default function BienDetailScreen() {
const { bien, visites, notes, documents, analyse } = bundle;
const etape = bien.expand?.etape;
+ useEffect(() => {
+ setPrixReventeM2Draft(analyse?.prix_revente_m2 != null ? String(analyse.prix_revente_m2) : '');
+ }, [analyse?.id, analyse?.prix_revente_m2]);
+
+ const savePrixReventeM2 = async () => {
+ const raw = prixReventeM2Draft.trim().replace(',', '.');
+ if (raw === '') {
+ await patchAnalyse({ prix_revente_m2: null });
+ return;
+ }
+ const n = Number(raw);
+ if (!Number.isFinite(n) || n < 0) {
+ Alert.alert('Prix au m²', 'Saisis un nombre positif ou laisse vide.');
+ return;
+ }
+ await patchAnalyse({ prix_revente_m2: n });
+ };
+
const analyseInput: AnalyseFormInput = {
prix_achat: analyse?.prix_achat,
type_bien_fiscal: analyse?.type_bien_fiscal,
@@ -85,6 +109,7 @@ export default function BienDetailScreen() {
taxe_fonciere_annuelle: analyse?.taxe_fonciere_annuelle,
charges_copropriete_mensuelle: analyse?.charges_copropriete_mensuelle,
prix_revente_cible: analyse?.prix_revente_cible,
+ prix_revente_m2: analyse?.prix_revente_m2,
frais_agence_vente_pct: analyse?.frais_agence_vente_pct,
taux_impot: analyse?.taux_impot,
};
@@ -128,6 +153,52 @@ export default function BienDetailScreen() {
+
+
+ void Linking.openURL(dvfSearchUrl(bien.ville ?? ''))}
+ >
+ Prix secteur (DVF)
+
+ {
+ const la = bien.latitude;
+ const lo = bien.longitude;
+ if (la == null || lo == null || Number.isNaN(Number(la)) || Number.isNaN(Number(lo))) {
+ Alert.alert(
+ 'Carte',
+ 'Ajoute latitude et longitude sur la fiche bien pour ouvrir le Géoportail centré.',
+ );
+ return;
+ }
+ void Linking.openURL(geoportailBienUrl(Number(la), Number(lo)));
+ }}
+ >
+ Voir sur la carte
+
+
+
+ Prix estimé revente (€/m²) — enregistré dans l'analyse financière
+
+ void savePrixReventeM2()}
+ />
+ {isPatching ? (
+
+ ) : (
+ Sauvegarde à la sortie du champ.
+ )}
+
+
{!analyse ? (
Aucune analyse enregistrée. Utilisez le calculateur.
@@ -139,6 +210,9 @@ export default function BienDetailScreen() {
+ {analyse.prix_revente_m2 != null ? (
+
+ ) : null}
>
diff --git a/app/components/recherche/GrillePrixTab.tsx b/app/components/recherche/GrillePrixTab.tsx
new file mode 100644
index 0000000..893e17c
--- /dev/null
+++ b/app/components/recherche/GrillePrixTab.tsx
@@ -0,0 +1,329 @@
+import { useMemo, useState } from 'react';
+import {
+ ActivityIndicator,
+ Alert,
+ Modal,
+ Pressable,
+ ScrollView,
+ Text,
+ TextInput,
+ View,
+} from 'react-native';
+
+import { UI } from '@/constants/uiTheme';
+import { useGrillePrix } from '@/hooks/useGrillePrix';
+import type { GrillePrixEtat, GrillePrixRecord, GrillePrixTypeBien } from '@/types/collections';
+import { formatPocketBaseError } from '@/utils/pocketbaseErrors';
+
+const TYPES: GrillePrixTypeBien[] = ['appartement', 'maison', 'immeuble'];
+const ETATS: GrillePrixEtat[] = ['bon_etat', 'a_renover', 'travaux_lourds'];
+
+const TYPE_LABEL: Record = {
+ appartement: 'Appartement',
+ maison: 'Maison',
+ immeuble: 'Immeuble',
+};
+
+const ETAT_LABEL: Record = {
+ bon_etat: 'Bon état',
+ a_renover: 'À rénover',
+ travaux_lourds: 'Travaux lourds',
+};
+
+type EditorState =
+ | { mode: 'create' }
+ | { mode: 'edit'; row: GrillePrixRecord };
+
+function parseNum(raw: string): number | null {
+ const n = Number(String(raw).replace(',', '.').trim());
+ return Number.isFinite(n) && n >= 0 ? n : null;
+}
+
+export function GrillePrixTab() {
+ const { rows, isLoading, error, createRow, updateRow, deleteRow, isMutating } = useGrillePrix();
+ const [editor, setEditor] = useState(null);
+ const [typeBien, setTypeBien] = useState('appartement');
+ const [etat, setEtat] = useState('bon_etat');
+ const [pa, setPa] = useState('');
+ const [pr, setPr] = useState('');
+ const [ville, setVille] = useState('');
+
+ const moyenneLabel = useMemo(() => {
+ const vals = rows
+ .map((r) => r.marge_estimee_pct)
+ .filter((x): x is number => typeof x === 'number' && !Number.isNaN(x));
+ if (vals.length === 0) return '—';
+ const m = vals.reduce((a, b) => a + b, 0) / vals.length;
+ return `${m.toFixed(1)} %`;
+ }, [rows]);
+
+ const openCreate = () => {
+ setTypeBien('appartement');
+ setEtat('bon_etat');
+ setPa('');
+ setPr('');
+ setVille('');
+ setEditor({ mode: 'create' });
+ };
+
+ const openEdit = (row: GrillePrixRecord) => {
+ setTypeBien(row.type_bien);
+ setEtat(row.etat);
+ setPa(String(row.prix_achat_m2));
+ setPr(String(row.prix_revente_m2));
+ setVille(row.ville ?? '');
+ setEditor({ mode: 'edit', row });
+ };
+
+ const closeEditor = () => setEditor(null);
+
+ const submitEditor = async () => {
+ const achat = parseNum(pa);
+ const revente = parseNum(pr);
+ if (achat == null || revente == null) {
+ Alert.alert('Saisie', 'Indique des prix au m² valides (≥ 0).');
+ return;
+ }
+ try {
+ const payload = {
+ type_bien: typeBien,
+ etat,
+ prix_achat_m2: achat,
+ prix_revente_m2: revente,
+ ville: ville.trim() || undefined,
+ };
+ if (editor?.mode === 'create') {
+ await createRow(payload);
+ } else if (editor?.mode === 'edit') {
+ await updateRow({ id: editor.row.id, input: payload });
+ }
+ closeEditor();
+ } catch (e) {
+ Alert.alert('Erreur', formatPocketBaseError(e));
+ }
+ };
+
+ const confirmDelete = (row: GrillePrixRecord) => {
+ Alert.alert('Supprimer cette ligne ?', `${TYPE_LABEL[row.type_bien]} · ${ETAT_LABEL[row.etat]}`, [
+ { text: 'Annuler', style: 'cancel' },
+ {
+ text: 'Supprimer',
+ style: 'destructive',
+ onPress: () =>
+ void deleteRow(row.id).catch((e) => Alert.alert('Erreur', formatPocketBaseError(e))),
+ },
+ ]);
+ };
+
+ if (isLoading) {
+ return (
+
+
+
+ );
+ }
+
+ return (
+
+ {error ? (
+ {formatPocketBaseError(error)}
+ ) : null}
+
+
+
+
+ Type
+
+
+ État
+
+
+ Achat €/m²
+
+
+ Revente €/m²
+
+
+ Marge %
+
+
+ Ville
+
+
+
+ {rows.length === 0 ? (
+
+ Aucune ligne. Appuie sur + pour créer ton référentiel.
+
+ ) : (
+ rows.map((r) => (
+
+ openEdit(r)}
+ className="min-w-0 flex-1 flex-row active:bg-slate-100"
+ >
+
+ {TYPE_LABEL[r.type_bien]}
+
+
+ {ETAT_LABEL[r.etat]}
+
+
+ {r.prix_achat_m2}
+
+
+ {r.prix_revente_m2}
+
+
+ {r.marge_estimee_pct != null ? `${r.marge_estimee_pct.toFixed(1)} %` : '—'}
+
+
+ {r.ville ?? '—'}
+
+
+ confirmDelete(r)} className="w-20 items-center justify-center">
+
+ ✕
+
+
+
+ ))
+ )}
+
+
+
+
+
+ Marge moyenne du référentiel
+
+
+ {moyenneLabel}
+
+
+
+
+ +
+
+
+
+
+
+
+ {editor?.mode === 'edit' ? 'Modifier la ligne' : 'Nouvelle ligne'}
+
+
+ Type de bien
+
+
+ {TYPES.map((t) => (
+ setTypeBien(t)}
+ className="min-h-[48px] rounded-xl border-2 px-4 py-2"
+ style={{
+ borderColor: typeBien === t ? UI.primary : UI.border,
+ backgroundColor: typeBien === t ? '#EFF6FF' : UI.card,
+ }}
+ >
+
+ {TYPE_LABEL[t]}
+
+
+ ))}
+
+
+ État
+
+
+ {ETATS.map((t) => (
+ setEtat(t)}
+ className="min-h-[48px] rounded-xl border-2 px-4 py-2"
+ style={{
+ borderColor: etat === t ? UI.primary : UI.border,
+ backgroundColor: etat === t ? '#EFF6FF' : UI.card,
+ }}
+ >
+
+ {ETAT_LABEL[t]}
+
+
+ ))}
+
+
+ Prix achat (€/m²)
+
+
+
+ Prix revente (€/m²)
+
+
+
+ Ville (optionnel)
+
+
+
+
+
+ Annuler
+
+
+ void submitEditor()}
+ disabled={isMutating}
+ className="min-h-[52px] flex-1 items-center justify-center rounded-2xl"
+ style={{ backgroundColor: UI.primary }}
+ >
+ {isMutating ? (
+
+ ) : (
+ Enregistrer
+ )}
+
+
+
+
+
+
+ );
+}
diff --git a/app/components/recherche/OpportunitesTab.tsx b/app/components/recherche/OpportunitesTab.tsx
new file mode 100644
index 0000000..773421d
--- /dev/null
+++ b/app/components/recherche/OpportunitesTab.tsx
@@ -0,0 +1,284 @@
+import { Ionicons } from '@expo/vector-icons';
+import * as Clipboard from 'expo-clipboard';
+import type { ReactNode } from 'react';
+import { useCallback, useState } from 'react';
+import {
+ ActivityIndicator,
+ Alert,
+ Linking,
+ Pressable,
+ ScrollView,
+ Text,
+ TextInput,
+ View,
+} from 'react-native';
+
+import {
+ LEGI_L151_36,
+ LEGI_L152_6,
+ OFF_MARKET_KEYWORDS,
+ PROSPECTION_CHECKLIST,
+} from '@/constants/rechercheMarche';
+import { UI } from '@/constants/uiTheme';
+import { useNotesProspectionRecherche } from '@/hooks/useNotesProspectionRecherche';
+import { formatPocketBaseError } from '@/utils/pocketbaseErrors';
+
+const LBC = 'https://www.leboncoin.fr/';
+const MOTEUR = 'https://www.moteurimmo.fr/';
+
+function Collapsible({
+ title,
+ children,
+ defaultOpen,
+}: {
+ title: string;
+ children: ReactNode;
+ defaultOpen?: boolean;
+}) {
+ const [open, setOpen] = useState(Boolean(defaultOpen));
+ return (
+
+ setOpen((o) => !o)}
+ className="min-h-[52px] flex-row items-center justify-between px-4 py-3"
+ >
+
+ {title}
+
+
+
+ {open ? {children} : null}
+
+ );
+}
+
+export function OpportunitesTab() {
+ const { getState, saveChecklistItem, isLoading, isSaving } = useNotesProspectionRecherche();
+ const [expandedNoteId, setExpandedNoteId] = useState(null);
+ const [noteDraft, setNoteDraft] = useState('');
+
+ const persistItem = useCallback(
+ async (questionId: string, next: { done: boolean; note: string }) => {
+ try {
+ await saveChecklistItem({ questionId, data: next });
+ } catch (e) {
+ Alert.alert('Sauvegarde', formatPocketBaseError(e));
+ }
+ },
+ [saveChecklistItem],
+ );
+
+ const openNoteEditor = (questionId: string) => {
+ const st = getState(questionId);
+ setExpandedNoteId(questionId);
+ setNoteDraft(st.note);
+ };
+
+ const saveNoteFor = async (questionId: string) => {
+ const st = getState(questionId);
+ await persistItem(questionId, { done: st.done, note: noteDraft.trim() });
+ setExpandedNoteId(null);
+ };
+
+ const copyText = async (text: string) => {
+ try {
+ await Clipboard.setStringAsync(text);
+ Alert.alert('Copié', 'Collage dans Leboncoin ou Moteur Immo.');
+ } catch {
+ Alert.alert('Presse-papiers', 'Copie impossible sur cet appareil.');
+ }
+ };
+
+ if (isLoading) {
+ return (
+
+
+
+ );
+ }
+
+ return (
+
+
+ {OFF_MARKET_KEYWORDS.map((k) => (
+
+
+ {k.label}
+
+
+ {k.text}
+
+ void copyText(k.text)}
+ className="mt-3 min-h-[48px] items-center justify-center rounded-xl"
+ style={{ backgroundColor: UI.primary }}
+ >
+ Copier
+
+
+ ))}
+
+ void Linking.openURL(LBC)}
+ className="min-h-[52px] flex-1 min-w-[140px] items-center justify-center rounded-2xl border-2"
+ style={{ borderColor: UI.warning, backgroundColor: '#FFFBEB' }}
+ >
+
+ Leboncoin
+
+
+ void Linking.openURL(MOTEUR)}
+ className="min-h-[52px] flex-1 min-w-[140px] items-center justify-center rounded-2xl border-2"
+ style={{ borderColor: UI.warning, backgroundColor: '#FFFBEB' }}
+ >
+
+ Moteur Immo
+
+
+
+
+
+ Astuce maison de ville
+
+
+ Surface terrain max 100 m² + surface habitable min 150 m² → maisons sans jardin, moins de concurrence,
+ idéal division.
+
+
+
+
+
+
+ Trier par ancienneté sur Moteur Immo. 40+ mois en ligne + baisses répétées = vendeur motivé. Décote possible
+ sous prix marché.
+
+
+
+ −10 % à −24 %
+
+
+ void Linking.openURL(MOTEUR)}
+ className="mt-4 min-h-[52px] items-center justify-center rounded-2xl"
+ style={{ backgroundColor: UI.danger }}
+ >
+ Moteur Immo — tri par ancienneté
+
+
+
+
+
+
+ Article L151-36
+
+
+ 1 place de parking max par logement créé en zone bien desservie.
+
+ void Linking.openURL(LEGI_L151_36)}
+ className="mt-3 min-h-[48px] items-center justify-center rounded-xl bg-white"
+ >
+
+ Ouvrir sur Légifrance →
+
+
+
+
+
+ Article L152-6
+
+
+ Dans 500 m d'une gare ou métro → division sans obligation parking.
+
+ void Linking.openURL(LEGI_L152_6)}
+ className="mt-3 min-h-[48px] items-center justify-center rounded-xl bg-white"
+ >
+
+ Ouvrir sur Légifrance →
+
+
+
+
+
+
+
+ Coche après échange ; note la réponse pour ton dossier.
+
+ {PROSPECTION_CHECKLIST.map((item) => {
+ const st = getState(item.id);
+ const expanded = expandedNoteId === item.id;
+ return (
+
+ void persistItem(item.id, { done: !st.done, note: st.note })}
+ className="min-h-[48px] flex-row items-start gap-3"
+ >
+
+ {st.done ? ✓ : null}
+
+
+
+ {item.label}
+
+
+ {item.question}
+
+
+
+ (expanded ? setExpandedNoteId(null) : openNoteEditor(item.id))}
+ className="mt-3 min-h-[44px] justify-center rounded-xl px-3"
+ style={{ backgroundColor: '#F1F5F9' }}
+ >
+
+ {expanded ? 'Fermer la note' : st.note ? 'Modifier la note' : 'Ajouter une note'}
+
+
+ {expanded ? (
+
+
+ void saveNoteFor(item.id)}
+ disabled={isSaving}
+ className="mt-2 min-h-[48px] items-center justify-center rounded-xl"
+ style={{ backgroundColor: UI.primary }}
+ >
+ {isSaving ? (
+
+ ) : (
+ Enregistrer la note
+ )}
+
+
+ ) : st.note ? (
+
+ {st.note}
+
+ ) : null}
+
+ );
+ })}
+
+
+ );
+}
diff --git a/app/components/recherche/SecteurTab.tsx b/app/components/recherche/SecteurTab.tsx
new file mode 100644
index 0000000..a6b23c3
--- /dev/null
+++ b/app/components/recherche/SecteurTab.tsx
@@ -0,0 +1,168 @@
+import { Ionicons } from '@expo/vector-icons';
+import type { ComponentProps } from 'react';
+import { useEffect, useState } from 'react';
+import {
+ ActivityIndicator,
+ Alert,
+ Keyboard,
+ Linking,
+ Pressable,
+ ScrollView,
+ Text,
+ TextInput,
+ View,
+} from 'react-native';
+
+import {
+ dvfSearchUrl,
+ meilleursAgentsUrlForVille,
+ SECTOR_TOOLS,
+ type SectorTool,
+} from '@/constants/rechercheMarche';
+import { UI } from '@/constants/uiTheme';
+import { useAnalyseSecteurForVille, useSaveAnalyseSecteur } from '@/hooks/useAnalysesSecteur';
+import { formatPocketBaseError } from '@/utils/pocketbaseErrors';
+
+type IonName = ComponentProps['name'];
+
+function resolveToolUrl(tool: SectorTool, ville: string): string {
+ if (tool.id === 'ma') return meilleursAgentsUrlForVille(ville);
+ if (tool.id === 'dvf') return dvfSearchUrl(ville);
+ return tool.url;
+}
+
+export function SecteurTab() {
+ const [ville, setVille] = useState('');
+ const [notes, setNotes] = useState('');
+ const secteurQ = useAnalyseSecteurForVille(ville);
+ const saveMut = useSaveAnalyseSecteur();
+
+ useEffect(() => {
+ if (secteurQ.data?.notes != null) setNotes(secteurQ.data.notes);
+ }, [secteurQ.data?.id, secteurQ.data?.notes]);
+
+ const onOpenTool = async (tool: SectorTool) => {
+ const url = resolveToolUrl(tool, ville);
+ const ok = await Linking.canOpenURL(url);
+ if (!ok) {
+ Alert.alert('Lien', 'Impossible d’ouvrir ce lien sur cet appareil.');
+ return;
+ }
+ void Linking.openURL(url);
+ };
+
+ const onAnalyser = () => {
+ const v = ville.trim();
+ if (!v) {
+ Alert.alert('Ville', 'Indique une ville ou une commune pour cadrer l’analyse.');
+ return;
+ }
+ Keyboard.dismiss();
+ Alert.alert(
+ 'Secteur',
+ `Analyse pour « ${v} » : utilise les outils ci-dessous (données externes), puis consigne tes notes en bas de page.`,
+ );
+ };
+
+ const onSaveNotes = async () => {
+ const v = ville.trim();
+ if (!v) {
+ Alert.alert('Ville', 'Renseigne la ville avant de sauvegarder les notes.');
+ return;
+ }
+ try {
+ await saveMut.mutateAsync({ ville: v, notes });
+ } catch (e) {
+ Alert.alert('Erreur', formatPocketBaseError(e));
+ }
+ };
+
+ return (
+
+
+ Ville / commune
+
+
+
+ Analyser
+
+
+
+ Outils marché
+
+
+ Données externes — ouverture dans le navigateur.
+
+ {SECTOR_TOOLS.map((tool) => (
+ void onOpenTool(tool)}
+ className="mb-3 flex-row items-center rounded-2xl border-2 bg-white p-4 active:opacity-90"
+ style={{ borderColor: UI.border }}
+ >
+
+
+
+
+
+ {tool.title}
+
+
+ {tool.description}
+
+
+
+ Externe →
+
+
+
+
+ ))}
+
+
+ Notes secteur
+
+ {secteurQ.isFetching ? : null}
+
+ void onSaveNotes()}
+ disabled={saveMut.isPending}
+ className="mt-3 min-h-[52px] items-center justify-center rounded-2xl active:opacity-90"
+ style={{ backgroundColor: UI.success }}
+ >
+ {saveMut.isPending ? (
+
+ ) : (
+ Sauvegarder
+ )}
+
+
+ );
+}
diff --git a/app/components/ui/DashboardSkeleton.tsx b/app/components/ui/DashboardSkeleton.tsx
new file mode 100644
index 0000000..f0b01aa
--- /dev/null
+++ b/app/components/ui/DashboardSkeleton.tsx
@@ -0,0 +1,34 @@
+import { View } from 'react-native';
+
+import { UI } from '@/constants/uiTheme';
+
+export function DashboardSkeleton() {
+ return (
+
+
+
+
+
+ {[1, 2, 3].map((k) => (
+
+ ))}
+
+
+
+
+
+ {[1, 2, 3].map((k) => (
+
+ ))}
+
+
+ );
+}
diff --git a/app/components/ui/EmptyState.tsx b/app/components/ui/EmptyState.tsx
new file mode 100644
index 0000000..73c4c29
--- /dev/null
+++ b/app/components/ui/EmptyState.tsx
@@ -0,0 +1,57 @@
+import { Link, type Href } from 'expo-router';
+import { Pressable, Text, View } from 'react-native';
+
+import { UI } from '@/constants/uiTheme';
+
+export type EmptyStateProps = {
+ title: string;
+ description?: string;
+ actionLabel?: string;
+ actionHref?: Href;
+ onAction?: () => void;
+};
+
+export function EmptyState({ title, description, actionLabel, actionHref, onAction }: EmptyStateProps) {
+ const actionNode =
+ actionLabel && actionHref != null ? (
+
+
+ {actionLabel}
+
+
+ ) : actionLabel && onAction ? (
+
+ {actionLabel}
+
+ ) : null;
+
+ return (
+
+
+ {title}
+
+ {description ? (
+
+ {description}
+
+ ) : null}
+ {actionNode}
+
+ );
+}
diff --git a/app/components/ui/ListSkeleton.tsx b/app/components/ui/ListSkeleton.tsx
new file mode 100644
index 0000000..6f878fc
--- /dev/null
+++ b/app/components/ui/ListSkeleton.tsx
@@ -0,0 +1,39 @@
+import { View } from 'react-native';
+
+import { UI } from '@/constants/uiTheme';
+
+type Props = {
+ rows?: number;
+ /** Hauteur des cartes (liste verticale). */
+ variant?: 'card' | 'compact';
+};
+
+export function ListSkeleton({ rows = 6, variant = 'card' }: Props) {
+ const gap = variant === 'compact' ? 10 : 14;
+ const pad = variant === 'compact' ? 12 : 16;
+ return (
+
+ {Array.from({ length: rows }).map((_, i) => (
+
+
+
+
+ {variant === 'card' ? (
+
+ ) : null}
+
+ ))}
+
+ );
+}
diff --git a/app/components/ui/PipelineSkeleton.tsx b/app/components/ui/PipelineSkeleton.tsx
new file mode 100644
index 0000000..02ba947
--- /dev/null
+++ b/app/components/ui/PipelineSkeleton.tsx
@@ -0,0 +1,37 @@
+import { ScrollView, View } from 'react-native';
+
+import { UI } from '@/constants/uiTheme';
+
+const COL_W = 216;
+
+export function PipelineSkeleton() {
+ return (
+
+ {Array.from({ length: 4 }).map((_, col) => (
+
+
+
+ {Array.from({ length: 3 }).map((__, row) => (
+
+
+
+
+ ))}
+
+ ))}
+
+ );
+}
diff --git a/app/constants/rechercheMarche.ts b/app/constants/rechercheMarche.ts
new file mode 100644
index 0000000..1de268e
--- /dev/null
+++ b/app/constants/rechercheMarche.ts
@@ -0,0 +1,103 @@
+export type SectorTool = {
+ id: string;
+ title: string;
+ description: string;
+ url: string;
+ /** Nom Ionicons (outline). */
+ icon: string;
+};
+
+export const SECTOR_TOOLS: SectorTool[] = [
+ {
+ id: 'ma',
+ title: 'Prix au m² — Meilleurs Agents',
+ description: 'Prix moyen, évolution, comparaison communes',
+ url: 'https://www.meilleursagents.com/prix-immobilier/',
+ icon: 'stats-chart-outline',
+ },
+ {
+ id: 'dvf',
+ title: 'Transactions réelles — DVF',
+ description: 'Prix de vente réels, toutes transactions',
+ url: 'https://dvf.etalab.gouv.fr/',
+ icon: 'document-text-outline',
+ },
+ {
+ id: 'moteur',
+ title: 'Annonces actives — Moteur Immo',
+ description: 'Agrège tous les sites, historique baisses de prix',
+ url: 'https://www.moteurimmo.fr/',
+ icon: 'search-outline',
+ },
+ {
+ id: 'insee',
+ title: 'Données INSEE',
+ description: 'Revenus médians, démographie, vacance logements',
+ url: 'https://www.insee.fr/fr/statistiques/zones/1405599',
+ icon: 'business-outline',
+ },
+ {
+ id: 'geo',
+ title: 'Carte & PLU — Géoportail',
+ description: 'Transports, écoles, zones PLU, foncier',
+ url: 'https://www.geoportail.gouv.fr/',
+ icon: 'map-outline',
+ },
+ {
+ id: 'pappers',
+ title: 'Concurrence MDB — Pappers Immo',
+ description: 'Historique transactions, propriétaires, sociétés actives',
+ url: 'https://immobilier.pappers.fr/',
+ icon: 'people-outline',
+ },
+];
+
+export const OFF_MARKET_KEYWORDS: { id: string; label: string; text: string }[] = [
+ {
+ id: 'k1',
+ label: 'Pack division studios',
+ text: '"studio" + "lots réunis" + "vendu libre" + "deux studios"',
+ },
+ {
+ id: 'k2',
+ label: 'Multi-lots / réunion',
+ text: '"appartements réunis" + "configuration possible" + "immeuble" + "multi-lots"',
+ },
+];
+
+export const PROSPECTION_CHECKLIST: { id: string; label: string; question: string }[] = [
+ { id: 'agent_immo', label: 'Agents immo', question: 'À combien ça part vraiment ? C’est rapide à vendre ?' },
+ { id: 'notaire', label: 'Notaires', question: 'Quelles tendances dans vos actes récents ?' },
+ { id: 'geometre', label: 'Géomètres', question: 'Divisions fréquentes sur ce secteur ?' },
+ { id: 'banquier', label: 'Banquiers', question: 'Vous financez souvent des projets ici ?' },
+ { id: 'mdb', label: 'Autres MDB', question: 'Tu trouves facilement sur ce secteur ?' },
+ { id: 'artisan', label: 'Artisans', question: 'Vous travaillez beaucoup dans ce quartier ?' },
+];
+
+export const CATEGORIE_NOTES_PROSPECTION = 'recherche_opportunites';
+
+export const LEGI_L151_36 =
+ 'https://www.legifrance.gouv.fr/codes/article_lc/LEGIARTI000031211239';
+export const LEGI_L152_6 =
+ 'https://www.legifrance.gouv.fr/codes/article_lc/LEGIARTI000043978020/2021-08-25';
+
+export function meilleursAgentsUrlForVille(ville: string): string {
+ const v = ville.trim();
+ if (!v) return SECTOR_TOOLS[0].url;
+ return `https://www.meilleursagents.com/prix-immobilier/${encodeURIComponent(v)}/`;
+}
+
+export function dvfSearchUrl(ville: string): string {
+ const v = ville.trim();
+ if (!v) return 'https://dvf.etalab.gouv.fr/';
+ return `https://dvf.etalab.gouv.fr/?q=${encodeURIComponent(v)}`;
+}
+
+export function geoportailBienUrl(lat: number, lon: number): string {
+ return `https://www.geoportail.gouv.fr/?lon=${lon}&lat=${lat}&z=17`;
+}
+
+export function marginPctFromPrices(achat: number, revente: number): number | null {
+ if (!Number.isFinite(achat) || !Number.isFinite(revente) || achat <= 0) return null;
+ return ((revente - achat) / achat) * 100;
+}
diff --git a/app/constants/uiTheme.ts b/app/constants/uiTheme.ts
new file mode 100644
index 0000000..51900e6
--- /dev/null
+++ b/app/constants/uiTheme.ts
@@ -0,0 +1,12 @@
+/** Palette terrain / extérieur — contraste élevé, actions distinctes. */
+export const UI = {
+ primary: '#1D4ED8',
+ success: '#16A34A',
+ warning: '#D97706',
+ danger: '#DC2626',
+ screen: '#F1F5F9',
+ card: '#FFFFFF',
+ text: '#0F172A',
+ textMuted: '#475569',
+ border: '#CBD5E1',
+} as const;
diff --git a/app/hooks/useAnalyse.ts b/app/hooks/useAnalyse.ts
index 6e1e9bb..e7bc76c 100644
--- a/app/hooks/useAnalyse.ts
+++ b/app/hooks/useAnalyse.ts
@@ -16,6 +16,8 @@ export type AnalyseFormInput = {
taxe_fonciere_annuelle?: number;
charges_copropriete_mensuelle?: number;
prix_revente_cible?: number;
+ /** Prix de revente estimé au m² (marché). */
+ prix_revente_m2?: number;
frais_agence_vente_pct?: number;
taux_impot?: number;
};
@@ -98,6 +100,9 @@ function formToRecord(form: AnalyseFormInput, calc: AnalyseCalculated): Record {
+ if (!bienId || !uid) throw new Error('Données manquantes');
+ const res = await pb.collection('analyses_financieres').getList(1, 1, {
+ filter: `bien="${bienId}" && user="${uid}"`,
+ sort: '-id',
+ });
+ const existing = res.items[0];
+ if (existing) {
+ return pb.collection('analyses_financieres').update(existing.id, patch);
+ }
+ return pb.collection('analyses_financieres').create({
+ user: uid,
+ bien: bienId,
+ type_bien_fiscal: 'ancien',
+ ...patch,
+ });
+ },
+ onSuccess: () => {
+ void queryClient.invalidateQueries({ queryKey: ['analyse_financiere', bienId, uid] });
+ void queryClient.invalidateQueries({ queryKey: ['bien_detail', bienId] });
+ },
+ });
+
return {
analyse: query.data ?? null,
isLoading: query.isPending,
@@ -161,6 +190,8 @@ export function useAnalyse(bienId: string | undefined) {
fetchAnalyse: query.refetch,
saveAnalyse: saveMutation.mutateAsync,
isSaving: saveMutation.isPending,
+ patchAnalyse: patchMutation.mutateAsync,
+ isPatching: patchMutation.isPending,
calculateResults,
};
}
diff --git a/app/hooks/useAnalysesSecteur.ts b/app/hooks/useAnalysesSecteur.ts
new file mode 100644
index 0000000..83599dc
--- /dev/null
+++ b/app/hooks/useAnalysesSecteur.ts
@@ -0,0 +1,60 @@
+import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
+
+import { getCurrentUserId, pb } from '@/services/pocketbase';
+import type { AnalyseSecteurRecord } from '@/types/collections';
+
+function escapeFilterValue(s: string): string {
+ return s.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
+}
+
+export function useAnalyseSecteurForVille(ville: string) {
+ const uid = getCurrentUserId();
+ const key = ville.trim().toLowerCase();
+
+ return useQuery({
+ queryKey: ['analyse_secteur', uid, key],
+ queryFn: async (): Promise => {
+ if (!uid || !key) return null;
+ const esc = escapeFilterValue(ville.trim());
+ const list = await pb.collection('analyses_secteur').getFullList({
+ filter: `user="${uid}" && ville="${esc}"`,
+ sort: '-updated',
+ });
+ return list[0] ?? null;
+ },
+ enabled: Boolean(uid && key),
+ });
+}
+
+export function useSaveAnalyseSecteur() {
+ const uid = getCurrentUserId();
+ const queryClient = useQueryClient();
+
+ return useMutation({
+ mutationFn: async (payload: { ville: string; notes: string }) => {
+ if (!uid) throw new Error('Non connecté');
+ const ville = payload.ville.trim();
+ if (!ville) throw new Error('Ville requise');
+ const esc = escapeFilterValue(ville);
+ const existing = await pb.collection('analyses_secteur').getFullList({
+ filter: `user="${uid}" && ville="${esc}"`,
+ sort: '-updated',
+ });
+ const row = existing[0];
+ if (row) {
+ return pb.collection('analyses_secteur').update(row.id, {
+ notes: payload.notes,
+ });
+ }
+ return pb.collection('analyses_secteur').create({
+ user: uid,
+ ville,
+ notes: payload.notes,
+ });
+ },
+ onSuccess: (_, v) => {
+ const key = v.ville.trim().toLowerCase();
+ void queryClient.invalidateQueries({ queryKey: ['analyse_secteur', uid, key] });
+ },
+ });
+}
diff --git a/app/hooks/useGrillePrix.ts b/app/hooks/useGrillePrix.ts
new file mode 100644
index 0000000..f7ce369
--- /dev/null
+++ b/app/hooks/useGrillePrix.ts
@@ -0,0 +1,79 @@
+import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
+
+import { marginPctFromPrices } from '@/constants/rechercheMarche';
+import { getCurrentUserId, pb } from '@/services/pocketbase';
+import type { GrillePrixEtat, GrillePrixRecord, GrillePrixTypeBien } from '@/types/collections';
+
+export type GrillePrixInput = {
+ type_bien: GrillePrixTypeBien;
+ etat: GrillePrixEtat;
+ prix_achat_m2: number;
+ prix_revente_m2: number;
+ ville?: string;
+};
+
+function withMargin(input: GrillePrixInput): Record {
+ const marge = marginPctFromPrices(input.prix_achat_m2, input.prix_revente_m2);
+ return {
+ type_bien: input.type_bien,
+ etat: input.etat,
+ prix_achat_m2: input.prix_achat_m2,
+ prix_revente_m2: input.prix_revente_m2,
+ ville: input.ville?.trim() || undefined,
+ marge_estimee_pct: marge != null ? Math.round(marge * 100) / 100 : undefined,
+ };
+}
+
+export function useGrillePrix() {
+ const uid = getCurrentUserId();
+ const queryClient = useQueryClient();
+
+ const query = useQuery({
+ queryKey: ['grille_prix', uid],
+ queryFn: async () => {
+ if (!uid) return [] as GrillePrixRecord[];
+ return pb.collection('grille_prix').getFullList({
+ filter: `user="${uid}"`,
+ sort: '-updated',
+ });
+ },
+ enabled: Boolean(uid),
+ });
+
+ const invalidate = () => {
+ void queryClient.invalidateQueries({ queryKey: ['grille_prix', uid] });
+ };
+
+ const createRow = useMutation({
+ mutationFn: async (input: GrillePrixInput) => {
+ if (!uid) throw new Error('Non connecté');
+ return pb.collection('grille_prix').create({
+ user: uid,
+ ...withMargin(input),
+ });
+ },
+ onSuccess: invalidate,
+ });
+
+ const updateRow = useMutation({
+ mutationFn: async ({ id, input }: { id: string; input: GrillePrixInput }) => {
+ return pb.collection('grille_prix').update(id, withMargin(input));
+ },
+ onSuccess: invalidate,
+ });
+
+ const deleteRow = useMutation({
+ mutationFn: async (id: string) => pb.collection('grille_prix').delete(id),
+ onSuccess: invalidate,
+ });
+
+ return {
+ rows: query.data ?? [],
+ isLoading: query.isPending,
+ error: query.error,
+ createRow: createRow.mutateAsync,
+ updateRow: updateRow.mutateAsync,
+ deleteRow: deleteRow.mutateAsync,
+ isMutating: createRow.isPending || updateRow.isPending || deleteRow.isPending,
+ };
+}
diff --git a/app/hooks/useNotesProspectionRecherche.ts b/app/hooks/useNotesProspectionRecherche.ts
new file mode 100644
index 0000000..e02c4be
--- /dev/null
+++ b/app/hooks/useNotesProspectionRecherche.ts
@@ -0,0 +1,90 @@
+import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
+
+import { CATEGORIE_NOTES_PROSPECTION } from '@/constants/rechercheMarche';
+import { getCurrentUserId, pb } from '@/services/pocketbase';
+import type { NoteProspectionRecord } from '@/types/collections';
+
+function escapePb(s: string): string {
+ return s.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
+}
+
+export type ChecklistPersist = { done: boolean; note: string };
+
+function encodeReponse(data: ChecklistPersist): string {
+ return JSON.stringify(data);
+}
+
+function decodeReponse(raw?: string | null): ChecklistPersist {
+ if (!raw?.trim()) return { done: false, note: '' };
+ try {
+ const v = JSON.parse(raw) as unknown;
+ if (v && typeof v === 'object' && 'done' in v) {
+ const o = v as { done?: boolean; note?: string };
+ return { done: Boolean(o.done), note: typeof o.note === 'string' ? o.note : '' };
+ }
+ } catch {
+ return { done: false, note: raw };
+ }
+ return { done: false, note: '' };
+}
+
+export function useNotesProspectionRecherche() {
+ const uid = getCurrentUserId();
+ const queryClient = useQueryClient();
+
+ const query = useQuery({
+ queryKey: ['notes_prospection', uid, CATEGORIE_NOTES_PROSPECTION],
+ queryFn: async () => {
+ if (!uid) return [] as NoteProspectionRecord[];
+ return pb.collection('notes_prospection').getFullList({
+ filter: `user="${uid}" && categorie="${CATEGORIE_NOTES_PROSPECTION}"`,
+ sort: '-id',
+ });
+ },
+ enabled: Boolean(uid),
+ });
+
+ const byQuestionId = (questionId: string): NoteProspectionRecord | undefined =>
+ query.data?.find((r) => r.question === questionId);
+
+ const upsert = useMutation({
+ mutationFn: async (payload: { questionId: string; data: ChecklistPersist }) => {
+ if (!uid) throw new Error('Non connecté');
+ const reponse = encodeReponse(payload.data);
+ const qe = escapePb(payload.questionId);
+ const existing = await pb.collection('notes_prospection').getFullList({
+ filter: `user="${uid}" && categorie="${escapePb(CATEGORIE_NOTES_PROSPECTION)}" && question="${qe}"`,
+ });
+ const row = existing[0];
+ if (row) {
+ return pb.collection('notes_prospection').update(row.id, {
+ reponse,
+ categorie: CATEGORIE_NOTES_PROSPECTION,
+ });
+ }
+ return pb.collection('notes_prospection').create({
+ user: uid,
+ question: payload.questionId,
+ reponse,
+ categorie: CATEGORIE_NOTES_PROSPECTION,
+ });
+ },
+ onSuccess: () => {
+ void queryClient.invalidateQueries({
+ queryKey: ['notes_prospection', uid, CATEGORIE_NOTES_PROSPECTION],
+ });
+ },
+ });
+
+ return {
+ rows: query.data ?? [],
+ isLoading: query.isPending,
+ error: query.error,
+ getState(questionId: string): ChecklistPersist {
+ const row = byQuestionId(questionId);
+ return decodeReponse(row?.reponse);
+ },
+ saveChecklistItem: upsert.mutateAsync,
+ isSaving: upsert.isPending,
+ };
+}
diff --git a/app/package-lock.json b/app/package-lock.json
index e1426dc..f0a5b17 100644
--- a/app/package-lock.json
+++ b/app/package-lock.json
@@ -14,6 +14,7 @@
"@react-navigation/native": "^7.1.8",
"@tanstack/react-query": "^5.90.0",
"expo": "~54.0.0",
+ "expo-clipboard": "~8.0.0",
"expo-constants": "~18.0.13",
"expo-document-picker": "~14.0.8",
"expo-font": "~14.0.11",
@@ -4404,6 +4405,17 @@
"react-native": "*"
}
},
+ "node_modules/expo-clipboard": {
+ "version": "8.0.8",
+ "resolved": "https://registry.npmjs.org/expo-clipboard/-/expo-clipboard-8.0.8.tgz",
+ "integrity": "sha512-VKoBkHIpZZDJTB0jRO4/PZskHdMNOEz3P/41tmM6fDuODMpqhvyWK053X0ebspkxiawJX9lX33JXHBCvVsTTOA==",
+ "license": "MIT",
+ "peerDependencies": {
+ "expo": "*",
+ "react": "*",
+ "react-native": "*"
+ }
+ },
"node_modules/expo-constants": {
"version": "18.0.13",
"resolved": "https://registry.npmjs.org/expo-constants/-/expo-constants-18.0.13.tgz",
diff --git a/app/package.json b/app/package.json
index 2c6be3f..3902d6a 100644
--- a/app/package.json
+++ b/app/package.json
@@ -16,6 +16,7 @@
"@react-navigation/native": "^7.1.8",
"@tanstack/react-query": "^5.90.0",
"expo": "~54.0.0",
+ "expo-clipboard": "~8.0.0",
"expo-constants": "~18.0.13",
"expo-document-picker": "~14.0.8",
"expo-font": "~14.0.11",
diff --git a/app/types/collections.ts b/app/types/collections.ts
index cb48f0c..2b16b58 100644
--- a/app/types/collections.ts
+++ b/app/types/collections.ts
@@ -116,6 +116,34 @@ export type AnalyseFinanciereRecord = RecordModel & {
marge_nette?: number;
marge_nette_pct?: number;
notes?: string;
+ /** Prix de revente estimé au m² (référence marché / grille perso). */
+ prix_revente_m2?: number;
+};
+
+export type AnalyseSecteurRecord = RecordModel & {
+ user: string;
+ ville: string;
+ notes?: string;
+};
+
+export type NoteProspectionRecord = RecordModel & {
+ user: string;
+ question: string;
+ reponse?: string;
+ categorie?: string;
+};
+
+export type GrillePrixTypeBien = 'appartement' | 'maison' | 'immeuble';
+export type GrillePrixEtat = 'bon_etat' | 'a_renover' | 'travaux_lourds';
+
+export type GrillePrixRecord = RecordModel & {
+ user: string;
+ type_bien: GrillePrixTypeBien;
+ etat: GrillePrixEtat;
+ prix_achat_m2: number;
+ prix_revente_m2: number;
+ marge_estimee_pct?: number;
+ ville?: string;
};
export type VisiteRecord = RecordModel & {
diff --git a/docker/docker-compose.dev.yml b/docker/docker-compose.dev.yml
index cc61dec..ae6c3cc 100644
--- a/docker/docker-compose.dev.yml
+++ b/docker/docker-compose.dev.yml
@@ -1,5 +1,3 @@
-version: '3.8'
-
services:
pocketbase:
image: ghcr.io/muchobien/pocketbase:latest
diff --git a/pocketbase/pb_migrations/1746000000_init_collections.js b/pocketbase/pb_migrations/1746000000_init_collections.js
index fa29887..9faf3f5 100644
--- a/pocketbase/pb_migrations/1746000000_init_collections.js
+++ b/pocketbase/pb_migrations/1746000000_init_collections.js
@@ -8,13 +8,42 @@ migrate(
(app) => {
const usersId = app.findCollectionByNameOrId("users").id;
- function loadOrCreate(name, factory) {
+ /** Retrouve une collection même si findCollectionByNameOrId échoue (casse, cache, image Docker). */
+ function findExistingCollection(name) {
try {
return app.findCollectionByNameOrId(name);
- } catch {
+ } catch (_) {}
+ try {
+ const all = app.findAllCollections();
+ const want = String(name).toLowerCase();
+ for (let i = 0; i < all.length; i++) {
+ const c = all[i];
+ if (c && c.name && String(c.name).toLowerCase() === want) {
+ return c;
+ }
+ }
+ } catch (_) {}
+ return null;
+ }
+
+ function loadOrCreate(name, factory) {
+ const existing = findExistingCollection(name);
+ if (existing != null) {
+ return existing;
+ }
+ try {
const col = factory();
app.save(col);
return col;
+ } catch (err) {
+ const msg = String(err && err.value ? err.value : err && err.message ? err.message : err);
+ if (msg.includes("unique") || msg.includes("Unique")) {
+ const again = findExistingCollection(name);
+ if (again != null) {
+ return again;
+ }
+ }
+ throw err;
}
}
diff --git a/pocketbase/pb_migrations/1746100000_module_recherche.js b/pocketbase/pb_migrations/1746100000_module_recherche.js
new file mode 100644
index 0000000..f4ca153
--- /dev/null
+++ b/pocketbase/pb_migrations/1746100000_module_recherche.js
@@ -0,0 +1,176 @@
+///
+/**
+ * Module Recherche & Analyse marché + champ prix revente au m² sur l'analyse financière.
+ *
+ * Les règles API doivent être assignées APRÈS fields.add(...): sinon le validateur ne voit
+ * pas les champs (erreurs "unknown field user" / "failed to resolve field owner").
+ */
+migrate(
+ (app) => {
+ const usersCol = app.findCollectionByNameOrId("users");
+ let usersId = "";
+ if (usersCol) {
+ const a = usersCol.id != null && String(usersCol.id) !== "" ? usersCol.id : null;
+ const b = usersCol.Id != null && String(usersCol.Id) !== "" ? usersCol.Id : null;
+ usersId = String(a != null ? a : b != null ? b : "").trim();
+ }
+ if (!usersId) {
+ throw new Error("migration 1746100000: collection users introuvable ou id vide");
+ }
+
+ function findExistingCollection(name) {
+ try {
+ return app.findCollectionByNameOrId(name);
+ } catch (_) {}
+ try {
+ const all = app.findAllCollections();
+ const want = String(name).toLowerCase();
+ for (let i = 0; i < all.length; i++) {
+ const c = all[i];
+ if (c && c.name && String(c.name).toLowerCase() === want) {
+ return c;
+ }
+ }
+ } catch (_) {}
+ return null;
+ }
+
+ function loadOrCreate(name, factory) {
+ const existing = findExistingCollection(name);
+ if (existing != null) {
+ return existing;
+ }
+ try {
+ const col = factory();
+ app.save(col);
+ return col;
+ } catch (err) {
+ const msg = String(err && err.value ? err.value : err && err.message ? err.message : err);
+ if (msg.includes("unique") || msg.includes("Unique")) {
+ const again = findExistingCollection(name);
+ if (again != null) {
+ return again;
+ }
+ }
+ throw err;
+ }
+ }
+
+ const ownRecords = '@request.auth.id != "" && user.id = @request.auth.id';
+ const authOnly = '@request.auth.id != ""';
+
+ function makeAnalysesSecteur() {
+ const col = new Collection({ name: "analyses_secteur", type: "base" });
+ col.fields.add(
+ new RelationField({
+ name: "user",
+ required: true,
+ collectionId: usersId,
+ maxSelect: 1,
+ cascadeDelete: true,
+ }),
+ );
+ col.fields.add(new TextField({ name: "ville", required: true }));
+ col.fields.add(new TextField({ name: "notes", required: false }));
+ col.listRule = ownRecords;
+ col.viewRule = ownRecords;
+ col.createRule = authOnly;
+ col.updateRule = ownRecords;
+ col.deleteRule = ownRecords;
+ return col;
+ }
+
+ function makeNotesProspection() {
+ const col = new Collection({ name: "notes_prospection", type: "base" });
+ col.fields.add(
+ new RelationField({
+ name: "user",
+ required: true,
+ collectionId: usersId,
+ maxSelect: 1,
+ cascadeDelete: true,
+ }),
+ );
+ col.fields.add(new TextField({ name: "question", required: true }));
+ col.fields.add(new TextField({ name: "reponse", required: false }));
+ col.fields.add(new TextField({ name: "categorie", required: false }));
+ col.listRule = ownRecords;
+ col.viewRule = ownRecords;
+ col.createRule = authOnly;
+ col.updateRule = ownRecords;
+ col.deleteRule = ownRecords;
+ return col;
+ }
+
+ function makeGrillePrix() {
+ const col = new Collection({ name: "grille_prix", type: "base" });
+ col.fields.add(
+ new RelationField({
+ name: "user",
+ required: true,
+ collectionId: usersId,
+ maxSelect: 1,
+ cascadeDelete: true,
+ }),
+ );
+ col.fields.add(
+ new SelectField({
+ name: "type_bien",
+ required: true,
+ maxSelect: 1,
+ values: ["appartement", "maison", "immeuble"],
+ }),
+ );
+ col.fields.add(
+ new SelectField({
+ name: "etat",
+ required: true,
+ maxSelect: 1,
+ values: ["bon_etat", "a_renover", "travaux_lourds"],
+ }),
+ );
+ col.fields.add(new NumberField({ name: "prix_achat_m2", required: true }));
+ col.fields.add(new NumberField({ name: "prix_revente_m2", required: true }));
+ col.fields.add(new NumberField({ name: "marge_estimee_pct", required: false }));
+ col.fields.add(new TextField({ name: "ville", required: false }));
+ col.listRule = ownRecords;
+ col.viewRule = ownRecords;
+ col.createRule = authOnly;
+ col.updateRule = ownRecords;
+ col.deleteRule = ownRecords;
+ return col;
+ }
+
+ loadOrCreate("analyses_secteur", makeAnalysesSecteur);
+ loadOrCreate("notes_prospection", makeNotesProspection);
+ loadOrCreate("grille_prix", makeGrillePrix);
+
+ try {
+ const af = findExistingCollection("analyses_financieres");
+ if (af == null) {
+ /* rien à faire */
+ } else {
+ let has = false;
+ for (let i = 0; i < af.fields.length; i++) {
+ if (af.fields.get(i).name === "prix_revente_m2") {
+ has = true;
+ break;
+ }
+ }
+ if (!has && typeof NumberField !== "undefined") {
+ af.fields.add(new NumberField({ name: "prix_revente_m2", min: 0 }));
+ app.save(af);
+ }
+ }
+ } catch (_) {
+ /* schéma déjà à jour ou API différente */
+ }
+ },
+ (app) => {
+ for (const name of ["grille_prix", "notes_prospection", "analyses_secteur"]) {
+ try {
+ app.delete(app.findCollectionByNameOrId(name));
+ } catch (_) {}
+ }
+ },
+);
diff --git a/pocketbase/pb_migrations/1752000000_fix_rules_empty_string_quotes.js b/pocketbase/pb_migrations/1752000000_fix_rules_empty_string_quotes.js
index 647dfc0..bd1d5d0 100644
--- a/pocketbase/pb_migrations/1752000000_fix_rules_empty_string_quotes.js
+++ b/pocketbase/pb_migrations/1752000000_fix_rules_empty_string_quotes.js
@@ -14,6 +14,9 @@ migrate(
"notes_biens",
"documents_biens",
"devis_travaux",
+ "analyses_secteur",
+ "notes_prospection",
+ "grille_prix",
];
const ruleKeys = ["listRule", "viewRule", "createRule", "updateRule", "deleteRule"];
for (const name of names) {