From 2b8741de08eba8686a2f79d20d6bf75ebef88336 Mon Sep 17 00:00:00 2001 From: Bastien COIGNOUX Date: Mon, 4 May 2026 21:52:51 +0200 Subject: [PATCH] recherche --- .cursorrules | 3 +- AGENTS.md | 25 ++ app/app/(tabs)/_layout.tsx | 7 + app/app/(tabs)/agenda.tsx | 173 +++++--- app/app/(tabs)/biens.tsx | 117 ++++-- app/app/(tabs)/contacts.tsx | 108 +++-- app/app/(tabs)/index.tsx | 372 ++++++++++++------ app/app/(tabs)/recherche.tsx | 44 +++ app/app/(tabs)/visites.tsx | 60 ++- app/app/bien/[id].tsx | 76 +++- app/components/recherche/GrillePrixTab.tsx | 329 ++++++++++++++++ app/components/recherche/OpportunitesTab.tsx | 284 +++++++++++++ app/components/recherche/SecteurTab.tsx | 168 ++++++++ app/components/ui/DashboardSkeleton.tsx | 34 ++ app/components/ui/EmptyState.tsx | 57 +++ app/components/ui/ListSkeleton.tsx | 39 ++ app/components/ui/PipelineSkeleton.tsx | 37 ++ app/constants/rechercheMarche.ts | 103 +++++ app/constants/uiTheme.ts | 12 + app/hooks/useAnalyse.ts | 31 ++ app/hooks/useAnalysesSecteur.ts | 60 +++ app/hooks/useGrillePrix.ts | 79 ++++ app/hooks/useNotesProspectionRecherche.ts | 90 +++++ app/package-lock.json | 12 + app/package.json | 1 + app/types/collections.ts | 28 ++ docker/docker-compose.dev.yml | 2 - .../1746000000_init_collections.js | 33 +- .../1746100000_module_recherche.js | 176 +++++++++ ...752000000_fix_rules_empty_string_quotes.js | 3 + 30 files changed, 2317 insertions(+), 246 deletions(-) create mode 100644 app/app/(tabs)/recherche.tsx create mode 100644 app/components/recherche/GrillePrixTab.tsx create mode 100644 app/components/recherche/OpportunitesTab.tsx create mode 100644 app/components/recherche/SecteurTab.tsx create mode 100644 app/components/ui/DashboardSkeleton.tsx create mode 100644 app/components/ui/EmptyState.tsx create mode 100644 app/components/ui/ListSkeleton.tsx create mode 100644 app/components/ui/PipelineSkeleton.tsx create mode 100644 app/constants/rechercheMarche.ts create mode 100644 app/constants/uiTheme.ts create mode 100644 app/hooks/useAnalysesSecteur.ts create mode 100644 app/hooks/useGrillePrix.ts create mode 100644 app/hooks/useNotesProspectionRecherche.ts create mode 100644 pocketbase/pb_migrations/1746100000_module_recherche.js 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) {