recherche

This commit is contained in:
Bastien COIGNOUX
2026-05-04 21:52:51 +02:00
parent 432f8ce176
commit 2b8741de08
30 changed files with 2317 additions and 246 deletions

View File

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

View File

@ -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 dingestion de flux externes ni de moteur dalertes ; 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 dingestion (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

View File

@ -45,6 +45,13 @@ export default function TabsLayout() {
tabBarIcon: ({ color, size }) => <Ionicons name="list-outline" size={size} color={color} />,
}}
/>
<Tabs.Screen
name="recherche"
options={{
title: 'Recherche',
tabBarIcon: ({ color, size }) => <Ionicons name="search-outline" size={size} color={color} />,
}}
/>
</Tabs>
);
}

View File

@ -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 ? (
<EmptyState
title="Agenda vide"
description="Ajoute des rappels (relances, visites, banque) pour piloter ta semaine."
actionLabel="+ Nouvelle tâche"
onAction={openCreate}
/>
) : null;
return (
<>
<Stack.Screen options={{ title: 'Agenda', headerShown: true }} />
<View className="flex-1 bg-slate-50">
<View className="flex-1" style={{ backgroundColor: UI.screen }}>
{error ? (
<Text className="p-4 text-red-700">{formatPocketBaseError(error)}</Text>
<View
className="mx-3 mt-3 rounded-2xl border-2 px-4 py-3"
style={{ borderColor: UI.danger, backgroundColor: '#FEE2E2' }}
>
<Text className="text-base font-medium" style={{ color: '#991B1B' }}>
{formatPocketBaseError(error)}
</Text>
</View>
) : null}
{isLoading ? (
<View className="flex-1 items-center justify-center">
<ActivityIndicator color="#1D4ED8" />
</View>
<ListSkeleton rows={6} />
) : (
<SectionList
sections={sections}
keyExtractor={(item) => item.id}
contentContainerStyle={{ padding: 12, paddingBottom: 96 }}
contentContainerStyle={
sections.length === 0 ? { flexGrow: 1, padding: 12, paddingBottom: 112 } : { padding: 12, paddingBottom: 112 }
}
renderSectionHeader={({ section }) => (
<Text
className={`pb-2 pt-3 text-xs font-semibold uppercase ${section.tint === 'red' ? 'text-red-600' : 'text-slate-500'}`}
className="pb-2 pt-4 text-sm font-bold uppercase tracking-wide"
style={{ color: section.tint === 'red' ? UI.danger : UI.textMuted }}
>
{section.title}
</Text>
@ -118,85 +139,138 @@ export default function AgendaTab() {
const done = t.statut === 'fait';
const badge = bienLabel(t);
return (
<View className="mb-2 rounded-xl border border-slate-200 bg-white p-3">
<View className="flex-row items-start gap-3">
<View
className="mb-3 rounded-2xl border-2 bg-white p-4"
style={{ borderColor: UI.border }}
>
<View className="flex-row items-start gap-4">
<Pressable
accessibilityRole="checkbox"
accessibilityState={{ checked: done }}
onPress={() => void toggleDone(t)}
className={`mt-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 ? <Text className="text-xs font-bold text-white"></Text> : null}
{done ? (
<Text className="text-xl font-bold text-white"></Text>
) : null}
</Pressable>
<View className="min-w-0 flex-1">
<Text
className={`font-semibold text-slate-900 ${done ? 'text-slate-400 line-through' : ''}`}
className={`text-lg font-bold leading-6 ${done ? 'text-slate-400 line-through' : ''}`}
style={!done ? { color: UI.text } : undefined}
>
{t.titre}
</Text>
{badge ? (
<Text className="mt-1 text-xs font-medium text-blue-700">{badge}</Text>
<Text className="mt-2 text-base font-semibold" style={{ color: UI.primary }}>
{badge}
</Text>
) : null}
{t.date_echeance ? (
<Text className="mt-1 text-xs text-slate-500">{t.date_echeance}</Text>
<Text className="mt-1 text-base" style={{ color: UI.textMuted }}>
{t.date_echeance}
</Text>
) : null}
</View>
</View>
<View className="mt-3 flex-row flex-wrap gap-2">
<View className="mt-4 flex-row flex-wrap gap-3">
<Pressable
accessibilityRole="button"
onPress={() => 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' }}
>
<Text className="text-sm font-medium text-slate-800">Snooze +1 j</Text>
<Text className="text-lg font-bold" style={{ color: '#B45309' }}>
+1 jour
</Text>
</Pressable>
<Pressable
accessibilityRole="button"
onPress={() => 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' }}
>
<Text className="text-sm font-medium text-red-700">Supprimer</Text>
<Text className="text-lg font-bold" style={{ color: UI.danger }}>
Supprimer
</Text>
</Pressable>
</View>
</View>
);
}}
ListEmptyComponent={
<Text className="py-8 text-center text-slate-600">Aucune tâche à afficher.</Text>
}
ListEmptyComponent={emptyList}
/>
)}
<Pressable
accessibilityRole="button"
accessibilityLabel="Nouvelle tâche"
onPress={openCreate}
className="absolute bottom-6 right-5 rounded-full bg-blue-700 px-4 py-3"
className="absolute bottom-6 right-5 min-h-[56px] justify-center rounded-2xl px-6 py-4 active:opacity-90"
style={{ backgroundColor: UI.primary, elevation: 8 }}
>
<Text className="font-semibold text-white">+ Tâche</Text>
<Text className="text-lg font-bold text-white">+ Tâche</Text>
</Pressable>
<Modal visible={modalOpen} animationType="slide" transparent>
<View className="flex-1 justify-end bg-black/40">
<View className="max-h-[85%] rounded-t-2xl bg-white p-4">
<Text className="text-lg font-bold text-slate-900">Nouvelle tâche</Text>
<Text className="mt-3 text-sm text-slate-500">Titre</Text>
<View className="flex-1 justify-end bg-black/50">
<View className="max-h-[88%] rounded-t-3xl bg-white p-5">
<Text className="text-2xl font-bold" style={{ color: UI.text }}>
Nouvelle tâche
</Text>
<Text className="mt-4 text-base font-semibold" style={{ color: UI.textMuted }}>
Titre
</Text>
<TextInput
className="mt-1 rounded-xl border border-slate-200 px-3 py-2 text-base text-slate-900"
className="mt-2 rounded-2xl border-2 px-4 text-lg"
style={{
borderColor: UI.border,
color: UI.text,
minHeight: 52,
backgroundColor: UI.card,
}}
value={newTitre}
onChangeText={setNewTitre}
placeholder="Appeler le notaire…"
placeholderTextColor="#94a3b8"
placeholderTextColor={UI.textMuted}
/>
<Text className="mt-3 text-sm text-slate-500">Échéance (AAAA-MM-JJ)</Text>
<Text className="mt-4 text-base font-semibold" style={{ color: UI.textMuted }}>
Échéance (AAAA-MM-JJ)
</Text>
<TextInput
className="mt-1 rounded-xl border border-slate-200 px-3 py-2 text-base text-slate-900"
className="mt-2 rounded-2xl border-2 px-4 text-lg"
style={{
borderColor: UI.border,
color: UI.text,
minHeight: 52,
backgroundColor: UI.card,
}}
value={newDate}
onChangeText={setNewDate}
placeholder="2026-04-29"
placeholderTextColor="#94a3b8"
placeholderTextColor={UI.textMuted}
/>
<Text className="mt-4 text-sm text-slate-500">Bien (optionnel)</Text>
<ScrollView horizontal className="mt-2" keyboardShouldPersistTaps="handled">
<Text className="mt-4 text-base font-semibold" style={{ color: UI.textMuted }}>
Bien (optionnel)
</Text>
<ScrollView horizontal className="mt-2 max-h-28" keyboardShouldPersistTaps="handled">
<Pressable
onPress={() => setNewBienId(undefined)}
className={`mr-2 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 }
}
>
<Text className={`text-sm ${newBienId == null ? 'text-white' : 'text-slate-800'}`}>
<Text
className={`text-base font-bold ${newBienId == null ? 'text-white' : ''}`}
style={newBienId == null ? undefined : { color: UI.text }}
>
Aucun
</Text>
</Pressable>
@ -204,33 +278,38 @@ export default function AgendaTab() {
<Pressable
key={b.id}
onPress={() => 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,
}}
>
<Text
numberOfLines={1}
className={`text-sm ${newBienId === b.id ? 'text-white' : 'text-slate-800'}`}
>
<Text numberOfLines={1} className="text-base font-bold" style={{ color: UI.text }}>
{b.titre?.trim() || b.ville || b.id}
</Text>
</Pressable>
))}
</ScrollView>
<View className="mt-6 flex-row gap-3">
<View className="mt-8 flex-row gap-3">
<Pressable
onPress={() => 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 }}
>
<Text className="font-semibold text-slate-800">Annuler</Text>
<Text className="text-lg font-bold" style={{ color: UI.text }}>
Annuler
</Text>
</Pressable>
<Pressable
onPress={() => 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 ? (
<ActivityIndicator color="#fff" />
) : (
<Text className="font-semibold text-white">Créer</Text>
<Text className="text-lg font-bold text-white">Créer</Text>
)}
</Pressable>
</View>

View File

@ -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 (
<>
<Stack.Screen options={{ title: 'Biens', headerShown: true }} />
<View className="flex-1 bg-slate-50">
<View className="flex-1" style={{ backgroundColor: UI.screen }}>
{banner ? (
<View className="border-b border-red-200 bg-red-50 px-3 py-2">
<Text className="text-sm text-red-900">{banner}</Text>
<View
className="border-b-2 px-4 py-3"
style={{ borderColor: UI.danger, backgroundColor: '#FEE2E2' }}
>
<Text className="text-base font-medium" style={{ color: '#991B1B' }}>
{banner}
</Text>
</View>
) : null}
{isLoading || etapesLoading ? (
<View className="flex-1 items-center justify-center">
<ActivityIndicator size="large" color="#1D4ED8" />
{loading ? (
<PipelineSkeleton />
) : biens.length === 0 ? (
<View className="flex-1 justify-center px-2">
<EmptyState
title="Pipeline vide"
description="Crée un bien pour suivre tes pistes du premier contact jusquà lacte."
actionLabel="Ajouter un bien"
actionHref="/bien/nouveau"
/>
</View>
) : (
<ScrollView horizontal className="flex-1" contentContainerStyle={{ padding: 12, paddingBottom: 96 }}>
<ScrollView
horizontal
className="flex-1"
contentContainerStyle={{ padding: 12, paddingBottom: 112 }}
>
{etapes.map((e) => {
const list = grouped.m.get(e.id) ?? [];
return (
<View key={e.id} className="mr-3 w-56 rounded-xl border border-slate-200 bg-white p-2">
<View className="mb-2 flex-row items-center justify-between border-b border-slate-100 pb-2">
<Text className="flex-1 font-bold text-slate-900" numberOfLines={2}>
<View
key={e.id}
className="mr-3 w-56 rounded-2xl border-2 bg-white p-3"
style={{ borderColor: UI.border }}
>
<View
className="mb-3 flex-row items-center justify-between border-b-2 pb-3"
style={{ borderColor: '#E2E8F0' }}
>
<Text
className="flex-1 text-lg font-bold leading-6"
style={{ color: UI.text }}
numberOfLines={2}
>
{e.nom}
</Text>
<Text className="text-xs text-slate-500">{list.length}</Text>
<View
className="ml-2 min-w-[36px] items-center rounded-full px-2 py-1"
style={{ backgroundColor: UI.primary }}
>
<Text className="text-base font-bold text-white">{list.length}</Text>
</View>
</View>
<Text className="mb-2 text-xs text-slate-500">{list.length} bien(s)</Text>
<ScrollView nestedScrollEnabled>
<ScrollView nestedScrollEnabled showsVerticalScrollIndicator={false}>
{list.map((b) => (
<Link key={b.id} href={`/bien/${b.id}`} asChild>
<Pressable className="mb-2 rounded-lg border border-slate-100 bg-slate-50 p-2">
<Text className="font-medium text-slate-900" numberOfLines={2}>
<Pressable
accessibilityRole="button"
className="mb-3 min-h-[72px] justify-center rounded-xl border-2 px-3 py-3 active:opacity-90"
style={{ borderColor: '#E2E8F0', backgroundColor: '#F8FAFC' }}
>
<Text
className="text-base font-bold leading-5"
style={{ color: UI.text }}
numberOfLines={2}
>
{b.titre ?? 'Sans titre'}
</Text>
<Text className="text-xs text-slate-500" numberOfLines={1}>
<Text
className="mt-1 text-base"
style={{ color: UI.textMuted }}
numberOfLines={1}
>
{[b.code_postal, b.ville].filter(Boolean).join(' ') || '—'}
</Text>
{prixByBien.has(b.id) ? (
<Text className="mt-1 text-xs font-semibold text-slate-700">
<Text className="mt-2 text-base font-bold" style={{ color: UI.success }}>
{formatEUR(prixByBien.get(b.id))}
</Text>
) : null}
@ -96,16 +144,29 @@ export default function BiensScreen() {
</View>
);
})}
<View className="mr-3 w-56 rounded-xl border border-dashed border-slate-300 bg-slate-100/80 p-2">
<Text className="mb-2 font-bold text-slate-700">Sans étape</Text>
<Text className="mb-2 text-xs text-slate-500">
<View
className="mr-3 w-56 rounded-2xl border-2 border-dashed p-3"
style={{ borderColor: UI.warning, backgroundColor: '#FFFBEB' }}
>
<Text className="mb-3 text-lg font-bold" style={{ color: UI.text }}>
Sans étape
</Text>
<Text className="mb-3 text-base" style={{ color: UI.textMuted }}>
{(grouped.m.get(grouped.none) ?? []).length} bien(s)
</Text>
<ScrollView nestedScrollEnabled>
<ScrollView nestedScrollEnabled showsVerticalScrollIndicator={false}>
{(grouped.m.get(grouped.none) ?? []).map((b) => (
<Link key={b.id} href={`/bien/${b.id}`} asChild>
<Pressable className="mb-2 rounded-lg border border-slate-200 bg-white p-2">
<Text className="font-medium text-slate-900" numberOfLines={2}>
<Pressable
accessibilityRole="button"
className="mb-3 min-h-[72px] justify-center rounded-xl border-2 bg-white px-3 py-3 active:opacity-90"
style={{ borderColor: UI.border }}
>
<Text
className="text-base font-bold leading-5"
style={{ color: UI.text }}
numberOfLines={2}
>
{b.titre ?? 'Sans titre'}
</Text>
</Pressable>
@ -117,10 +178,12 @@ export default function BiensScreen() {
)}
<Link href="/bien/nouveau" asChild>
<Pressable
className="absolute bottom-6 right-5 h-14 w-14 items-center justify-center rounded-full bg-blue-700 shadow-md"
style={{ elevation: 6 }}
accessibilityRole="button"
accessibilityLabel="Ajouter un bien"
className="absolute bottom-6 right-5 h-16 w-16 items-center justify-center rounded-2xl shadow-lg active:opacity-90"
style={{ backgroundColor: UI.primary, elevation: 8 }}
>
<Text className="text-3xl leading-8 font-light text-white">+</Text>
<Text className="text-4xl font-light leading-none text-white">+</Text>
</Pressable>
</Link>
</View>

View File

@ -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() ? (
<EmptyState
title="Aucun résultat"
description="Essaie un autre mot-clé ou réinitialise la recherche."
actionLabel="Effacer la recherche"
onAction={() => setSearch('')}
/>
) : (
<EmptyState
title="Aucun contact"
description="Ajoute notaires, artisans et partenaires pour les retrouver vite."
actionLabel="Nouveau contact"
actionHref="/contact/nouveau"
/>
)
) : null;
return (
<>
<Stack.Screen options={{ title: 'Contacts', headerShown: true }} />
<View className="flex-1 bg-slate-50">
<View className="flex-1" style={{ backgroundColor: UI.screen }}>
{q.error ? (
<Text className="p-4 text-red-700">{formatPocketBaseError(q.error)}</Text>
<View
className="mx-3 mt-3 rounded-2xl border-2 px-4 py-3"
style={{ borderColor: UI.danger, backgroundColor: '#FEE2E2' }}
>
<Text className="text-base font-medium" style={{ color: '#991B1B' }}>
{formatPocketBaseError(q.error)}
</Text>
</View>
) : null}
<TextInput
className="mx-3 mt-2 rounded-xl border border-slate-200 bg-white px-3 py-2 text-base text-slate-900"
className="mx-3 mt-3 rounded-2xl border-2 bg-white px-4 text-lg"
style={{
borderColor: UI.border,
color: UI.text,
minHeight: 52,
paddingVertical: 12,
}}
placeholder="Rechercher…"
placeholderTextColor="#94a3b8"
placeholderTextColor={UI.textMuted}
value={search}
onChangeText={setSearch}
accessibilityLabel="Recherche contacts"
/>
{q.isPending ? (
<View className="flex-1 items-center justify-center">
<ActivityIndicator color="#1D4ED8" />
</View>
<ListSkeleton rows={7} />
) : (
<SectionList
className="flex-1 px-3 pt-2"
sections={sections}
keyExtractor={(item) => item.id}
contentContainerStyle={{ paddingBottom: 88 }}
contentContainerStyle={
sections.length === 0 ? { flexGrow: 1, paddingBottom: 100 } : { paddingBottom: 100 }
}
renderSectionHeader={({ section: { title } }) => (
<Text className="pb-1 pt-3 text-xs font-semibold uppercase text-slate-500">{title}</Text>
<Text
className="pb-2 pt-4 text-sm font-bold uppercase tracking-wide"
style={{ color: UI.textMuted }}
>
{title}
</Text>
)}
renderItem={({ item: c }) => (
<View className="mb-2 rounded-xl border border-slate-200 bg-white px-3 py-3">
<View
className="mb-3 rounded-2xl border-2 bg-white px-4 py-4"
style={{ borderColor: UI.border }}
>
<Link href={`/contact/${c.id}`} asChild>
<Pressable>
<Text className="font-semibold text-slate-900">
<Pressable accessibilityRole="button" className="active:opacity-90">
<Text className="text-xl font-bold" style={{ color: UI.text }}>
{c.prenom ? `${c.prenom} ` : ''}
{c.nom}
</Text>
{c.societe ? <Text className="text-sm text-slate-500">{c.societe}</Text> : null}
{c.societe ? (
<Text className="mt-1 text-lg" style={{ color: UI.textMuted }}>
{c.societe}
</Text>
) : null}
</Pressable>
</Link>
{c.telephone ? (
<Pressable onPress={() => openTel(c.telephone)} className="mt-2 self-start">
<Text className="text-sm text-blue-700">{c.telephone}</Text>
<Pressable
accessibilityRole="button"
onPress={() => openTel(c.telephone)}
className="mt-3 min-h-[48px] justify-center self-start rounded-xl px-3 active:opacity-90"
style={{ backgroundColor: '#EFF6FF' }}
>
<Text className="text-lg font-bold" style={{ color: UI.primary }}>
{c.telephone}
</Text>
</Pressable>
) : null}
</View>
)}
ListEmptyComponent={
<Text className="py-6 text-center text-slate-600">Aucun contact.</Text>
}
ListEmptyComponent={listEmpty}
/>
)}
<Link href="/contact/nouveau" asChild>
<Pressable className="absolute bottom-6 right-5 rounded-full bg-blue-700 px-4 py-3">
<Text className="font-semibold text-white">+ Contact</Text>
<Pressable
accessibilityRole="button"
accessibilityLabel="Nouveau contact"
className="absolute bottom-6 right-5 min-h-[56px] justify-center rounded-2xl px-6 py-4 active:opacity-90"
style={{ backgroundColor: UI.primary, elevation: 6 }}
>
<Text className="text-lg font-bold text-white">+ Contact</Text>
</Pressable>
</Link>
</View>

View File

@ -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 (
<>
<Stack.Screen options={{ title: 'Dashboard', headerShown: true }} />
<ScrollView className="flex-1 bg-slate-50 p-4" contentContainerStyle={{ paddingBottom: 32 }}>
<ScrollView
className="flex-1 p-4"
style={{ backgroundColor: UI.screen }}
contentContainerStyle={{ paddingBottom: 40 }}
>
{biensError ? (
<Text className="mb-2 text-red-700">{formatPocketBaseError(biensError)}</Text>
<View
className="mb-3 rounded-2xl border-2 px-4 py-3"
style={{ borderColor: UI.danger, backgroundColor: '#FEE2E2' }}
>
<Text className="text-base font-medium" style={{ color: '#991B1B' }}>
{formatPocketBaseError(biensError)}
</Text>
</View>
) : null}
{tachesError ? (
<Text className="mb-2 text-red-700">{formatPocketBaseError(tachesError)}</Text>
<View
className="mb-3 rounded-2xl border-2 px-4 py-3"
style={{ borderColor: UI.danger, backgroundColor: '#FEE2E2' }}
>
<Text className="text-base font-medium" style={{ color: '#991B1B' }}>
{formatPocketBaseError(tachesError)}
</Text>
</View>
) : null}
{loading ? (
<View className="py-8">
<ActivityIndicator color="#1D4ED8" />
</View>
) : null}
<Text className="mb-2 text-lg font-bold text-slate-900">Alertes urgentes</Text>
{urgent.length === 0 ? (
<Text className="mb-6 text-slate-600">Aucune alerte.</Text>
<DashboardSkeleton />
) : (
<View className="mb-6 gap-2">
{urgent.slice(0, 6).map((t) => (
<Link key={t.id} href="/(tabs)/agenda" asChild>
<Pressable className="rounded-xl border border-red-200 bg-red-50 px-3 py-2">
<Text className="font-medium text-red-900">{t.titre}</Text>
{t.date_echeance ? (
<Text className="text-xs text-red-700">{t.date_echeance}</Text>
) : null}
</Pressable>
</Link>
))}
</View>
)}
<Text className="mb-2 text-lg font-bold text-slate-900">Indicateurs</Text>
<View className="mb-6 flex-row flex-wrap gap-3">
<View className="min-w-[100px] flex-1 rounded-xl border border-slate-200 bg-white p-3">
<Text className="text-2xl font-bold text-slate-900">{biens.length}</Text>
<Text className="text-xs text-slate-500">Biens</Text>
</View>
<View className="min-w-[100px] flex-1 rounded-xl border border-slate-200 bg-white p-3">
<Text className="text-2xl font-bold text-slate-900">{actifs.length}</Text>
<Text className="text-xs text-slate-500">Actifs</Text>
</View>
<View className="min-w-[100px] flex-1 rounded-xl border border-slate-200 bg-white p-3">
<Text className="text-2xl font-bold text-slate-900">
{taches.filter(isTaskActive).length}
<>
<Text className="mb-3 text-2xl font-bold" style={{ color: UI.text }}>
Alertes urgentes
</Text>
<Text className="text-xs text-slate-500">Tâches ouvertes</Text>
</View>
</View>
{urgent.length === 0 ? (
<EmptyState
title="Rien durgent"
description="Les tâches en retard ou marquées urgentes apparaîtront ici."
actionLabel="Voir lagenda"
actionHref="/(tabs)/agenda"
/>
) : (
<View className="mb-8 gap-3">
{urgent.slice(0, 6).map((t) => (
<Link key={t.id} href="/(tabs)/agenda" asChild>
<Pressable
accessibilityRole="button"
className="min-h-[56px] justify-center rounded-2xl border-2 px-4 py-3 active:opacity-90"
style={{
borderColor: UI.danger,
backgroundColor: '#FEF2F2',
}}
>
<Text className="text-lg font-semibold" style={{ color: '#991B1B' }}>
{t.titre}
</Text>
{t.date_echeance ? (
<Text className="mt-1 text-base" style={{ color: UI.danger }}>
{t.date_echeance}
</Text>
) : null}
</Pressable>
</Link>
))}
</View>
)}
<Text className="mb-2 text-lg font-bold text-slate-900">Pipeline</Text>
<ScrollView horizontal showsHorizontalScrollIndicator={false} className="mb-6">
<View className="flex-row gap-2 pb-1">
{etapes.map((e) => {
const n = etapeCounts.get(e.id) ?? 0;
return (
<View
key={e.id}
className="min-w-[120px] rounded-xl border border-slate-200 bg-white px-3 py-2"
>
<View className="mb-1 h-1 rounded-full" style={{ backgroundColor: e.couleur }} />
<Text numberOfLines={2} className="text-xs font-semibold text-slate-800">
{e.nom}
</Text>
<Text className="mt-1 text-lg font-bold text-slate-900">{n}</Text>
</View>
);
})}
{(etapeCounts.get('') ?? 0) > 0 ? (
<View className="min-w-[120px] rounded-xl border border-dashed border-slate-300 bg-white px-3 py-2">
<Text className="text-xs font-semibold text-slate-600">Sans étape</Text>
<Text className="mt-1 text-lg font-bold text-slate-900">
{etapeCounts.get('') ?? 0}
<Text className="mb-3 text-2xl font-bold" style={{ color: UI.text }}>
Indicateurs
</Text>
<View className="mb-8 flex-row flex-wrap gap-3">
<View
className="min-w-[108px] flex-1 rounded-2xl border bg-white p-4"
style={{ borderColor: UI.border, borderLeftWidth: 5, borderLeftColor: UI.primary }}
>
<Text className="text-3xl font-bold" style={{ color: UI.text }}>
{biens.length}
</Text>
<Text className="mt-1 text-base font-medium" style={{ color: UI.textMuted }}>
Biens
</Text>
</View>
) : null}
</View>
</ScrollView>
<View
className="min-w-[108px] flex-1 rounded-2xl border bg-white p-4"
style={{ borderColor: UI.border, borderLeftWidth: 5, borderLeftColor: UI.success }}
>
<Text className="text-3xl font-bold" style={{ color: UI.text }}>
{actifs.length}
</Text>
<Text className="mt-1 text-base font-medium" style={{ color: UI.textMuted }}>
Actifs
</Text>
</View>
<View
className="min-w-[108px] flex-1 rounded-2xl border bg-white p-4"
style={{ borderColor: UI.border, borderLeftWidth: 5, borderLeftColor: UI.warning }}
>
<Text className="text-3xl font-bold" style={{ color: UI.text }}>
{taches.filter(isTaskActive).length}
</Text>
<Text className="mt-1 text-base font-medium" style={{ color: UI.textMuted }}>
Tâches ouvertes
</Text>
</View>
</View>
<View className="mb-2 flex-row items-center justify-between">
<Text className="text-lg font-bold text-slate-900">Derniers biens</Text>
<Link href="/(tabs)/biens" className="text-sm font-semibold text-blue-700">
Voir tout
</Link>
</View>
<View className="mb-6 gap-2">
{derniers.length === 0 ? (
<Text className="text-slate-600">Aucun bien.</Text>
) : (
derniers.map((b) => (
<Link key={b.id} href={`/bien/${b.id}`} asChild>
<Pressable className="rounded-xl border border-slate-200 bg-white px-3 py-3">
<Text className="font-semibold text-slate-900">{bienTitre(b)}</Text>
<Text className="text-xs text-slate-500">
{b.expand?.etape?.nom ?? '—'} · {b.ville ?? ''}
<View className="mb-3 flex-row items-center justify-between">
<Text className="text-2xl font-bold" style={{ color: UI.text }}>
Pipeline
</Text>
</View>
<ScrollView horizontal showsHorizontalScrollIndicator={false} className="mb-8">
<View className="flex-row gap-3 pb-1">
{etapes.map((e) => {
const n = etapeCounts.get(e.id) ?? 0;
return (
<View
key={e.id}
className="min-w-[132px] rounded-2xl border-2 bg-white px-4 py-4"
style={{ borderColor: UI.border }}
>
<View className="mb-2 h-1.5 rounded-full" style={{ backgroundColor: e.couleur }} />
<Text numberOfLines={2} className="text-base font-bold" style={{ color: UI.text }}>
{e.nom}
</Text>
<Text className="mt-2 text-2xl font-bold" style={{ color: UI.primary }}>
{n}
</Text>
</View>
);
})}
{(etapeCounts.get('') ?? 0) > 0 ? (
<View
className="min-w-[132px] rounded-2xl border-2 border-dashed bg-white px-4 py-4"
style={{ borderColor: UI.border }}
>
<Text className="text-base font-bold" style={{ color: UI.textMuted }}>
Sans étape
</Text>
<Text className="mt-2 text-2xl font-bold" style={{ color: UI.warning }}>
{etapeCounts.get('') ?? 0}
</Text>
</View>
) : null}
</View>
</ScrollView>
<View className="mb-3 flex-row items-center justify-between">
<Text className="text-2xl font-bold" style={{ color: UI.text }}>
Derniers biens
</Text>
<Link href="/(tabs)/biens" asChild>
<Pressable
hitSlop={12}
className="min-h-[48px] justify-center px-2"
accessibilityRole="link"
>
<Text className="text-lg font-bold" style={{ color: UI.primary }}>
Tout voir
</Text>
</Pressable>
</Link>
))
)}
</View>
<View className="mb-2 flex-row items-center justify-between">
<Text className="text-lg font-bold text-slate-900">Tâches du jour</Text>
<Link href="/(tabs)/agenda" className="text-sm font-semibold text-blue-700">
Agenda
</Link>
</View>
<View className="gap-2">
{part.today.length === 0 ? (
<Text className="text-slate-600">Rien de prévu aujourdhui.</Text>
) : (
part.today.map((t) => (
<View key={t.id} className="rounded-xl border border-slate-200 bg-white px-3 py-2">
<Text className="font-medium text-slate-900">{t.titre}</Text>
</View>
{derniers.length === 0 ? (
<View className="mb-8">
<EmptyState
title="Aucun bien enregistré"
description="Ajoute ton premier prospect pour démarrer le pipeline."
actionLabel="Créer un bien"
actionHref="/bien/nouveau"
/>
</View>
))
)}
</View>
) : (
<View className="mb-8 gap-3">
{derniers.map((b) => (
<Link key={b.id} href={`/bien/${b.id}`} asChild>
<Pressable
accessibilityRole="button"
className="min-h-[64px] justify-center rounded-2xl border-2 bg-white px-4 py-4 active:opacity-90"
style={{ borderColor: UI.border }}
>
<Text className="text-lg font-bold" style={{ color: UI.text }}>
{bienTitre(b)}
</Text>
<Text className="mt-1 text-base" style={{ color: UI.textMuted }}>
{b.expand?.etape?.nom ?? '—'} · {b.ville ?? ''}
</Text>
</Pressable>
</Link>
))}
</View>
)}
<Text className="mb-2 mt-8 text-sm font-semibold uppercase text-slate-500">Raccourcis</Text>
<Link href="/bien/nouveau" className="mb-2 text-base font-semibold text-blue-700">
Nouveau bien
</Link>
<Link href="/(tabs)/contacts" className="mb-2 text-base font-semibold text-blue-700">
Contacts
</Link>
<Link href="/(tabs)/visites" className="mb-2 text-base font-semibold text-blue-700">
Visites
</Link>
<View className="mb-3 flex-row items-center justify-between">
<Text className="text-2xl font-bold" style={{ color: UI.text }}>
Tâches du jour
</Text>
<Link href="/(tabs)/agenda" asChild>
<Pressable hitSlop={12} className="min-h-[48px] justify-center px-2">
<Text className="text-lg font-bold" style={{ color: UI.primary }}>
Agenda
</Text>
</Pressable>
</Link>
</View>
{part.today.length === 0 ? (
<View className="mb-8">
<EmptyState
title="Journée libre"
description="Aucune échéance aujourdhui dans lagenda."
actionLabel="Planifier une tâche"
actionHref="/(tabs)/agenda"
/>
</View>
) : (
<View className="mb-8 gap-3">
{part.today.map((t) => (
<View
key={t.id}
className="min-h-[56px] justify-center rounded-2xl border-2 bg-white px-4 py-4"
style={{ borderColor: UI.border }}
>
<Text className="text-lg font-semibold" style={{ color: UI.text }}>
{t.titre}
</Text>
</View>
))}
</View>
)}
<Text className="mb-3 text-lg font-bold uppercase tracking-wide" style={{ color: UI.textMuted }}>
Raccourcis
</Text>
<View className="gap-3">
<Link href="/bien/nouveau" asChild>
<Pressable
className="min-h-[56px] items-center justify-center rounded-2xl active:opacity-90"
style={{ backgroundColor: UI.primary }}
accessibilityRole="button"
>
<Text className="text-lg font-bold text-white">Nouveau bien</Text>
</Pressable>
</Link>
<Link href="/(tabs)/contacts" asChild>
<Pressable
className="min-h-[56px] items-center justify-center rounded-2xl border-2 bg-white active:opacity-90"
style={{ borderColor: UI.primary }}
accessibilityRole="button"
>
<Text className="text-lg font-bold" style={{ color: UI.primary }}>
Contacts
</Text>
</Pressable>
</Link>
<Link href="/(tabs)/visites" asChild>
<Pressable
className="min-h-[56px] items-center justify-center rounded-2xl border-2 bg-white active:opacity-90"
style={{ borderColor: UI.success }}
accessibilityRole="button"
>
<Text className="text-lg font-bold" style={{ color: UI.success }}>
Visites
</Text>
</Pressable>
</Link>
</View>
</>
)}
</ScrollView>
</>
);

View File

@ -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 (
<>
<Stack.Screen options={{ title: 'Recherche', headerShown: true }} />
<View className="flex-1" style={{ backgroundColor: UI.screen }}>
<View className="flex-row border-b-2 px-1" style={{ borderColor: UI.border, backgroundColor: UI.card }}>
{TABS.map((label, i) => (
<Pressable
key={label}
accessibilityRole="tab"
accessibilityState={{ selected: sub === i }}
onPress={() => setSub(i)}
className="min-h-[52px] flex-1 items-center justify-center border-b-4 py-3"
style={{ borderBottomColor: sub === i ? UI.primary : 'transparent' }}
>
<Text
className="text-center text-base font-bold"
style={{ color: sub === i ? UI.primary : UI.textMuted }}
>
{label}
</Text>
</Pressable>
))}
</View>
{sub === 0 ? <SecteurTab /> : null}
{sub === 1 ? <OpportunitesTab /> : null}
{sub === 2 ? <GrillePrixTab /> : null}
</View>
</>
);
}

View File

@ -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 (
<>
<Stack.Screen options={{ title: 'Visites', headerShown: true }} />
<View className="flex-1 bg-slate-50">
<View className="flex-1" style={{ backgroundColor: UI.screen }}>
{q.error ? (
<Text className="p-4 text-red-700">{formatPocketBaseError(q.error)}</Text>
<View
className="mx-3 mt-3 rounded-2xl border-2 px-4 py-3"
style={{ borderColor: UI.danger, backgroundColor: '#FEE2E2' }}
>
<Text className="text-base font-medium" style={{ color: '#991B1B' }}>
{formatPocketBaseError(q.error)}
</Text>
</View>
) : null}
{q.isPending ? (
<View className="flex-1 items-center justify-center">
<ActivityIndicator color="#1D4ED8" />
</View>
<ListSkeleton rows={5} />
) : (
<ScrollView className="flex-1 p-3">
<ScrollView className="flex-1 px-3 pt-3" contentContainerStyle={{ paddingBottom: 112 }}>
{q.data?.length === 0 ? (
<Text className="text-slate-600">Aucune visite.</Text>
) : null}
{q.data?.map((v) => (
<Pressable
key={v.id}
className="mb-2 rounded-xl border border-slate-200 bg-white p-3"
onPress={() => router.push(`/visite/${v.id}`)}
>
<Text className="font-semibold text-slate-900">{v.date_visite?.slice(0, 10) ?? '—'}</Text>
<Text className="text-sm text-slate-600">
{v.avis_global ? AVIS_VISITE[v.avis_global]?.label ?? v.avis_global : '—'}
</Text>
</Pressable>
))}
<EmptyState
title="Aucune visite"
description="Les visites sont liées à un bien : ouvre un bien depuis le pipeline pour consigner une visite."
actionLabel="Ouvrir les biens"
actionHref="/(tabs)/biens"
/>
) : (
q.data?.map((v) => (
<Pressable
key={v.id}
accessibilityRole="button"
className="mb-3 min-h-[76px] justify-center rounded-2xl border-2 bg-white px-4 py-4 active:opacity-90"
style={{ borderColor: UI.border }}
onPress={() => router.push(`/visite/${v.id}`)}
>
<Text className="text-xl font-bold" style={{ color: UI.text }}>
{v.date_visite?.slice(0, 10) ?? '—'}
</Text>
<Text className="mt-1 text-lg" style={{ color: UI.textMuted }}>
{v.avis_global ? AVIS_VISITE[v.avis_global]?.label ?? v.avis_global : 'Avis non renseigné'}
</Text>
</Pressable>
))
)}
</ScrollView>
)}
</View>

View File

@ -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() {
<InfoLine label="Priorité" value={bien.priorite != null ? String(bien.priorite) : '—'} />
</Section>
<Section title="Analyse marché">
<View className="flex-row flex-wrap gap-3">
<Pressable
className="min-h-[48px] justify-center rounded-xl px-4 py-3"
style={{ backgroundColor: '#1D4ED8' }}
onPress={() => void Linking.openURL(dvfSearchUrl(bien.ville ?? ''))}
>
<Text className="text-center text-base font-semibold text-white">Prix secteur (DVF)</Text>
</Pressable>
<Pressable
className="min-h-[48px] justify-center rounded-xl border-2 border-slate-300 bg-white px-4 py-3"
onPress={() => {
const la = bien.latitude;
const lo = bien.longitude;
if (la == null || lo == null || Number.isNaN(Number(la)) || Number.isNaN(Number(lo))) {
Alert.alert(
'Carte',
'Ajoute latitude et longitude sur la fiche bien pour ouvrir le Géoportail centré.',
);
return;
}
void Linking.openURL(geoportailBienUrl(Number(la), Number(lo)));
}}
>
<Text className="text-center text-base font-semibold text-slate-900">Voir sur la carte</Text>
</Pressable>
</View>
<Text className="mt-4 text-sm font-medium text-slate-500">
Prix estimé revente (/m²) enregistré dans l&apos;analyse financière
</Text>
<TextInput
className="mt-2 rounded-xl border border-slate-200 bg-white p-3 text-base text-slate-900"
keyboardType="decimal-pad"
placeholder="Ex. 5200"
placeholderTextColor="#94a3b8"
value={prixReventeM2Draft}
onChangeText={setPrixReventeM2Draft}
onEndEditing={() => void savePrixReventeM2()}
/>
{isPatching ? (
<ActivityIndicator className="mt-2" color="#1D4ED8" />
) : (
<Text className="mt-1 text-xs text-slate-400">Sauvegarde à la sortie du champ.</Text>
)}
</Section>
<Section title="Finances">
{!analyse ? (
<Text className="text-slate-600">Aucune analyse enregistrée. Utilisez le calculateur.</Text>
@ -139,6 +210,9 @@ export default function BienDetailScreen() {
<InfoLine label="Portage (total)" value={formatEUR(calc.frais_portage_total)} />
<InfoLine label="Prix de revient" value={formatEUR(calc.prix_revient)} />
<InfoLine label="Prix revente cible" value={formatEUR(analyse.prix_revente_cible)} />
{analyse.prix_revente_m2 != null ? (
<InfoLine label="Prix revente ref. (€/m²)" value={String(analyse.prix_revente_m2)} />
) : null}
<InfoLine label="Marge brute" value={formatEUR(calc.marge_brute)} />
<InfoLine label="Marge nette" value={formatEUR(calc.marge_nette)} />
</>

View File

@ -0,0 +1,329 @@
import { useMemo, useState } from 'react';
import {
ActivityIndicator,
Alert,
Modal,
Pressable,
ScrollView,
Text,
TextInput,
View,
} from 'react-native';
import { UI } from '@/constants/uiTheme';
import { useGrillePrix } from '@/hooks/useGrillePrix';
import type { GrillePrixEtat, GrillePrixRecord, GrillePrixTypeBien } from '@/types/collections';
import { formatPocketBaseError } from '@/utils/pocketbaseErrors';
const TYPES: GrillePrixTypeBien[] = ['appartement', 'maison', 'immeuble'];
const ETATS: GrillePrixEtat[] = ['bon_etat', 'a_renover', 'travaux_lourds'];
const TYPE_LABEL: Record<GrillePrixTypeBien, string> = {
appartement: 'Appartement',
maison: 'Maison',
immeuble: 'Immeuble',
};
const ETAT_LABEL: Record<GrillePrixEtat, string> = {
bon_etat: 'Bon état',
a_renover: 'À rénover',
travaux_lourds: 'Travaux lourds',
};
type EditorState =
| { mode: 'create' }
| { mode: 'edit'; row: GrillePrixRecord };
function parseNum(raw: string): number | null {
const n = Number(String(raw).replace(',', '.').trim());
return Number.isFinite(n) && n >= 0 ? n : null;
}
export function GrillePrixTab() {
const { rows, isLoading, error, createRow, updateRow, deleteRow, isMutating } = useGrillePrix();
const [editor, setEditor] = useState<EditorState | null>(null);
const [typeBien, setTypeBien] = useState<GrillePrixTypeBien>('appartement');
const [etat, setEtat] = useState<GrillePrixEtat>('bon_etat');
const [pa, setPa] = useState('');
const [pr, setPr] = useState('');
const [ville, setVille] = useState('');
const moyenneLabel = useMemo(() => {
const vals = rows
.map((r) => r.marge_estimee_pct)
.filter((x): x is number => typeof x === 'number' && !Number.isNaN(x));
if (vals.length === 0) return '—';
const m = vals.reduce((a, b) => a + b, 0) / vals.length;
return `${m.toFixed(1)} %`;
}, [rows]);
const openCreate = () => {
setTypeBien('appartement');
setEtat('bon_etat');
setPa('');
setPr('');
setVille('');
setEditor({ mode: 'create' });
};
const openEdit = (row: GrillePrixRecord) => {
setTypeBien(row.type_bien);
setEtat(row.etat);
setPa(String(row.prix_achat_m2));
setPr(String(row.prix_revente_m2));
setVille(row.ville ?? '');
setEditor({ mode: 'edit', row });
};
const closeEditor = () => setEditor(null);
const submitEditor = async () => {
const achat = parseNum(pa);
const revente = parseNum(pr);
if (achat == null || revente == null) {
Alert.alert('Saisie', 'Indique des prix au m² valides (≥ 0).');
return;
}
try {
const payload = {
type_bien: typeBien,
etat,
prix_achat_m2: achat,
prix_revente_m2: revente,
ville: ville.trim() || undefined,
};
if (editor?.mode === 'create') {
await createRow(payload);
} else if (editor?.mode === 'edit') {
await updateRow({ id: editor.row.id, input: payload });
}
closeEditor();
} catch (e) {
Alert.alert('Erreur', formatPocketBaseError(e));
}
};
const confirmDelete = (row: GrillePrixRecord) => {
Alert.alert('Supprimer cette ligne ?', `${TYPE_LABEL[row.type_bien]} · ${ETAT_LABEL[row.etat]}`, [
{ text: 'Annuler', style: 'cancel' },
{
text: 'Supprimer',
style: 'destructive',
onPress: () =>
void deleteRow(row.id).catch((e) => Alert.alert('Erreur', formatPocketBaseError(e))),
},
]);
};
if (isLoading) {
return (
<View className="flex-1 items-center justify-center py-16">
<ActivityIndicator size="large" color={UI.primary} />
</View>
);
}
return (
<View className="flex-1">
{error ? (
<Text className="px-3 py-2 text-base text-red-700">{formatPocketBaseError(error)}</Text>
) : null}
<ScrollView horizontal className="flex-1" contentContainerStyle={{ paddingBottom: 120 }}>
<View>
<View className="min-w-[720px] flex-row border-b-2 bg-white px-2 py-3" style={{ borderColor: UI.border }}>
<Text className="w-28 text-xs font-bold uppercase" style={{ color: UI.textMuted }}>
Type
</Text>
<Text className="w-32 text-xs font-bold uppercase" style={{ color: UI.textMuted }}>
État
</Text>
<Text className="w-28 text-xs font-bold uppercase" style={{ color: UI.textMuted }}>
Achat /m²
</Text>
<Text className="w-28 text-xs font-bold uppercase" style={{ color: UI.textMuted }}>
Revente /m²
</Text>
<Text className="w-24 text-xs font-bold uppercase" style={{ color: UI.textMuted }}>
Marge %
</Text>
<Text className="w-28 text-xs font-bold uppercase" style={{ color: UI.textMuted }}>
Ville
</Text>
<Text className="w-20" />
</View>
{rows.length === 0 ? (
<Text className="p-6 text-base" style={{ color: UI.textMuted }}>
Aucune ligne. Appuie sur + pour créer ton référentiel.
</Text>
) : (
rows.map((r) => (
<View
key={r.id}
className="min-w-[720px] flex-row border-b px-2 py-4"
style={{ borderColor: UI.border }}
>
<Pressable
onPress={() => openEdit(r)}
className="min-w-0 flex-1 flex-row active:bg-slate-100"
>
<Text className="w-28 text-base font-semibold" style={{ color: UI.text }}>
{TYPE_LABEL[r.type_bien]}
</Text>
<Text className="w-32 text-base" style={{ color: UI.text }}>
{ETAT_LABEL[r.etat]}
</Text>
<Text className="w-28 text-base" style={{ color: UI.text }}>
{r.prix_achat_m2}
</Text>
<Text className="w-28 text-base" style={{ color: UI.text }}>
{r.prix_revente_m2}
</Text>
<Text className="w-24 text-base font-bold" style={{ color: UI.success }}>
{r.marge_estimee_pct != null ? `${r.marge_estimee_pct.toFixed(1)} %` : '—'}
</Text>
<Text className="w-28 text-base" style={{ color: UI.textMuted }} numberOfLines={1}>
{r.ville ?? '—'}
</Text>
</Pressable>
<Pressable onPress={() => confirmDelete(r)} className="w-20 items-center justify-center">
<Text className="font-bold" style={{ color: UI.danger }}>
</Text>
</Pressable>
</View>
))
)}
</View>
</ScrollView>
<View
className="border-t-2 px-4 py-4"
style={{ borderColor: UI.border, backgroundColor: UI.card }}
>
<Text className="text-lg font-bold" style={{ color: UI.text }}>
Marge moyenne du référentiel
</Text>
<Text className="mt-1 text-3xl font-bold" style={{ color: UI.primary }}>
{moyenneLabel}
</Text>
</View>
<Pressable
accessibilityLabel="Ajouter une ligne"
onPress={openCreate}
className="absolute bottom-24 right-5 h-16 w-16 items-center justify-center rounded-2xl"
style={{ backgroundColor: UI.primary, elevation: 8 }}
>
<Text className="text-3xl font-light text-white">+</Text>
</Pressable>
<Modal visible={editor != null} animationType="slide" transparent>
<View className="flex-1 justify-end bg-black/50">
<View className="rounded-t-3xl bg-white p-5">
<Text className="text-2xl font-bold" style={{ color: UI.text }}>
{editor?.mode === 'edit' ? 'Modifier la ligne' : 'Nouvelle ligne'}
</Text>
<Text className="mt-4 text-base font-semibold" style={{ color: UI.textMuted }}>
Type de bien
</Text>
<View className="mt-2 flex-row flex-wrap gap-2">
{TYPES.map((t) => (
<Pressable
key={t}
onPress={() => setTypeBien(t)}
className="min-h-[48px] rounded-xl border-2 px-4 py-2"
style={{
borderColor: typeBien === t ? UI.primary : UI.border,
backgroundColor: typeBien === t ? '#EFF6FF' : UI.card,
}}
>
<Text className="text-base font-bold" style={{ color: UI.text }}>
{TYPE_LABEL[t]}
</Text>
</Pressable>
))}
</View>
<Text className="mt-4 text-base font-semibold" style={{ color: UI.textMuted }}>
État
</Text>
<View className="mt-2 flex-row flex-wrap gap-2">
{ETATS.map((t) => (
<Pressable
key={t}
onPress={() => setEtat(t)}
className="min-h-[48px] rounded-xl border-2 px-4 py-2"
style={{
borderColor: etat === t ? UI.primary : UI.border,
backgroundColor: etat === t ? '#EFF6FF' : UI.card,
}}
>
<Text className="text-base font-bold" style={{ color: UI.text }}>
{ETAT_LABEL[t]}
</Text>
</Pressable>
))}
</View>
<Text className="mt-4 text-base font-semibold" style={{ color: UI.textMuted }}>
Prix achat (/m²)
</Text>
<TextInput
className="mt-1 rounded-xl border-2 px-4 py-3 text-lg"
style={{ borderColor: UI.border, color: UI.text }}
keyboardType="decimal-pad"
value={pa}
onChangeText={setPa}
placeholder="4500"
placeholderTextColor={UI.textMuted}
/>
<Text className="mt-4 text-base font-semibold" style={{ color: UI.textMuted }}>
Prix revente (/m²)
</Text>
<TextInput
className="mt-1 rounded-xl border-2 px-4 py-3 text-lg"
style={{ borderColor: UI.border, color: UI.text }}
keyboardType="decimal-pad"
value={pr}
onChangeText={setPr}
placeholder="5200"
placeholderTextColor={UI.textMuted}
/>
<Text className="mt-4 text-base font-semibold" style={{ color: UI.textMuted }}>
Ville (optionnel)
</Text>
<TextInput
className="mt-1 rounded-xl border-2 px-4 py-3 text-lg"
style={{ borderColor: UI.border, color: UI.text }}
value={ville}
onChangeText={setVille}
placeholder="Lyon"
placeholderTextColor={UI.textMuted}
/>
<View className="mt-6 flex-row gap-3">
<Pressable
onPress={closeEditor}
className="min-h-[52px] flex-1 items-center justify-center rounded-2xl border-2"
style={{ borderColor: UI.border }}
>
<Text className="text-lg font-bold" style={{ color: UI.text }}>
Annuler
</Text>
</Pressable>
<Pressable
onPress={() => void submitEditor()}
disabled={isMutating}
className="min-h-[52px] flex-1 items-center justify-center rounded-2xl"
style={{ backgroundColor: UI.primary }}
>
{isMutating ? (
<ActivityIndicator color="#fff" />
) : (
<Text className="text-lg font-bold text-white">Enregistrer</Text>
)}
</Pressable>
</View>
</View>
</View>
</Modal>
</View>
);
}

View File

@ -0,0 +1,284 @@
import { Ionicons } from '@expo/vector-icons';
import * as Clipboard from 'expo-clipboard';
import type { ReactNode } from 'react';
import { useCallback, useState } from 'react';
import {
ActivityIndicator,
Alert,
Linking,
Pressable,
ScrollView,
Text,
TextInput,
View,
} from 'react-native';
import {
LEGI_L151_36,
LEGI_L152_6,
OFF_MARKET_KEYWORDS,
PROSPECTION_CHECKLIST,
} from '@/constants/rechercheMarche';
import { UI } from '@/constants/uiTheme';
import { useNotesProspectionRecherche } from '@/hooks/useNotesProspectionRecherche';
import { formatPocketBaseError } from '@/utils/pocketbaseErrors';
const LBC = 'https://www.leboncoin.fr/';
const MOTEUR = 'https://www.moteurimmo.fr/';
function Collapsible({
title,
children,
defaultOpen,
}: {
title: string;
children: ReactNode;
defaultOpen?: boolean;
}) {
const [open, setOpen] = useState(Boolean(defaultOpen));
return (
<View className="mb-3 rounded-2xl border-2 bg-white" style={{ borderColor: UI.border }}>
<Pressable
accessibilityRole="button"
onPress={() => setOpen((o) => !o)}
className="min-h-[52px] flex-row items-center justify-between px-4 py-3"
>
<Text className="flex-1 pr-2 text-lg font-bold" style={{ color: UI.text }}>
{title}
</Text>
<Ionicons name={open ? 'chevron-up' : 'chevron-down'} size={22} color={UI.textMuted} />
</Pressable>
{open ? <View className="border-t-2 px-4 pb-4 pt-2" style={{ borderColor: UI.border }}>{children}</View> : null}
</View>
);
}
export function OpportunitesTab() {
const { getState, saveChecklistItem, isLoading, isSaving } = useNotesProspectionRecherche();
const [expandedNoteId, setExpandedNoteId] = useState<string | null>(null);
const [noteDraft, setNoteDraft] = useState('');
const persistItem = useCallback(
async (questionId: string, next: { done: boolean; note: string }) => {
try {
await saveChecklistItem({ questionId, data: next });
} catch (e) {
Alert.alert('Sauvegarde', formatPocketBaseError(e));
}
},
[saveChecklistItem],
);
const openNoteEditor = (questionId: string) => {
const st = getState(questionId);
setExpandedNoteId(questionId);
setNoteDraft(st.note);
};
const saveNoteFor = async (questionId: string) => {
const st = getState(questionId);
await persistItem(questionId, { done: st.done, note: noteDraft.trim() });
setExpandedNoteId(null);
};
const copyText = async (text: string) => {
try {
await Clipboard.setStringAsync(text);
Alert.alert('Copié', 'Collage dans Leboncoin ou Moteur Immo.');
} catch {
Alert.alert('Presse-papiers', 'Copie impossible sur cet appareil.');
}
};
if (isLoading) {
return (
<View className="flex-1 items-center justify-center py-16">
<ActivityIndicator size="large" color={UI.primary} />
</View>
);
}
return (
<ScrollView className="flex-1 px-3 pt-2" contentContainerStyle={{ paddingBottom: 120 }}>
<Collapsible title="1. Mots-clés off-market">
{OFF_MARKET_KEYWORDS.map((k) => (
<View key={k.id} className="mb-3 rounded-xl border-2 p-3" style={{ borderColor: UI.border }}>
<Text className="text-base font-bold" style={{ color: UI.text }}>
{k.label}
</Text>
<Text selectable className="mt-2 font-mono text-sm leading-5" style={{ color: UI.text }}>
{k.text}
</Text>
<Pressable
onPress={() => void copyText(k.text)}
className="mt-3 min-h-[48px] items-center justify-center rounded-xl"
style={{ backgroundColor: UI.primary }}
>
<Text className="text-base font-bold text-white">Copier</Text>
</Pressable>
</View>
))}
<View className="mt-2 flex-row flex-wrap gap-3">
<Pressable
onPress={() => void Linking.openURL(LBC)}
className="min-h-[52px] flex-1 min-w-[140px] items-center justify-center rounded-2xl border-2"
style={{ borderColor: UI.warning, backgroundColor: '#FFFBEB' }}
>
<Text className="text-lg font-bold" style={{ color: '#B45309' }}>
Leboncoin
</Text>
</Pressable>
<Pressable
onPress={() => void Linking.openURL(MOTEUR)}
className="min-h-[52px] flex-1 min-w-[140px] items-center justify-center rounded-2xl border-2"
style={{ borderColor: UI.warning, backgroundColor: '#FFFBEB' }}
>
<Text className="text-lg font-bold" style={{ color: '#B45309' }}>
Moteur Immo
</Text>
</Pressable>
</View>
<View className="mt-4 rounded-2xl border-2 p-4" style={{ borderColor: UI.primary, backgroundColor: '#EFF6FF' }}>
<Text className="text-base font-bold" style={{ color: UI.primary }}>
Astuce maison de ville
</Text>
<Text className="mt-2 text-base leading-6" style={{ color: UI.text }}>
Surface terrain max 100 m² + surface habitable min 150 m² maisons sans jardin, moins de concurrence,
idéal division.
</Text>
</View>
</Collapsible>
<Collapsible title='2. Biens "fatigués" — décotes'>
<Text className="text-base leading-6" style={{ color: UI.text }}>
Trier par ancienneté sur Moteur Immo. 40+ mois en ligne + baisses répétées = vendeur motivé. Décote possible
sous prix marché.
</Text>
<View className="mt-3 self-start rounded-xl px-3 py-2" style={{ backgroundColor: '#FEE2E2' }}>
<Text className="text-lg font-bold" style={{ color: UI.danger }}>
10 % à 24 %
</Text>
</View>
<Pressable
onPress={() => void Linking.openURL(MOTEUR)}
className="mt-4 min-h-[52px] items-center justify-center rounded-2xl"
style={{ backgroundColor: UI.danger }}
>
<Text className="text-center text-lg font-bold text-white">Moteur Immo tri par ancienneté</Text>
</Pressable>
</Collapsible>
<Collapsible title="3. Vérifier la division — articles">
<View className="mb-3 rounded-2xl border-2 p-4" style={{ borderColor: UI.primary, backgroundColor: '#EFF6FF' }}>
<Text className="text-lg font-bold" style={{ color: UI.primary }}>
Article L151-36
</Text>
<Text className="mt-2 text-base leading-6" style={{ color: UI.text }}>
1 place de parking max par logement créé en zone bien desservie.
</Text>
<Pressable
onPress={() => void Linking.openURL(LEGI_L151_36)}
className="mt-3 min-h-[48px] items-center justify-center rounded-xl bg-white"
>
<Text className="text-base font-bold" style={{ color: UI.primary }}>
Ouvrir sur Légifrance
</Text>
</Pressable>
</View>
<View className="rounded-2xl border-2 p-4" style={{ borderColor: UI.success, backgroundColor: '#F0FDF4' }}>
<Text className="text-lg font-bold" style={{ color: UI.success }}>
Article L152-6
</Text>
<Text className="mt-2 text-base leading-6" style={{ color: UI.text }}>
Dans 500 m d&apos;une gare ou métro division sans obligation parking.
</Text>
<Pressable
onPress={() => void Linking.openURL(LEGI_L152_6)}
className="mt-3 min-h-[48px] items-center justify-center rounded-xl bg-white"
>
<Text className="text-base font-bold" style={{ color: UI.success }}>
Ouvrir sur Légifrance
</Text>
</Pressable>
</View>
</Collapsible>
<Collapsible title="4. Confirmer avec les pros">
<Text className="mb-2 text-base" style={{ color: UI.textMuted }}>
Coche après échange ; note la réponse pour ton dossier.
</Text>
{PROSPECTION_CHECKLIST.map((item) => {
const st = getState(item.id);
const expanded = expandedNoteId === item.id;
return (
<View key={item.id} className="mb-3 rounded-xl border-2 px-3 py-3" style={{ borderColor: UI.border }}>
<Pressable
accessibilityRole="checkbox"
accessibilityState={{ checked: st.done }}
onPress={() => void persistItem(item.id, { done: !st.done, note: st.note })}
className="min-h-[48px] flex-row items-start gap-3"
>
<View
className="mt-0.5 h-8 w-8 items-center justify-center rounded-lg border-2"
style={{
borderColor: st.done ? UI.success : UI.border,
backgroundColor: st.done ? UI.success : UI.card,
}}
>
{st.done ? <Text className="font-bold text-white"></Text> : null}
</View>
<View className="min-w-0 flex-1">
<Text className="text-base font-bold" style={{ color: UI.textMuted }}>
{item.label}
</Text>
<Text className="mt-1 text-base leading-6" style={{ color: UI.text }}>
{item.question}
</Text>
</View>
</Pressable>
<Pressable
onPress={() => (expanded ? setExpandedNoteId(null) : openNoteEditor(item.id))}
className="mt-3 min-h-[44px] justify-center rounded-xl px-3"
style={{ backgroundColor: '#F1F5F9' }}
>
<Text className="text-base font-semibold" style={{ color: UI.primary }}>
{expanded ? 'Fermer la note' : st.note ? 'Modifier la note' : 'Ajouter une note'}
</Text>
</Pressable>
{expanded ? (
<View className="mt-2">
<TextInput
className="min-h-[88px] rounded-xl border-2 px-3 py-2 text-base"
style={{ borderColor: UI.border, color: UI.text }}
multiline
textAlignVertical="top"
value={noteDraft}
onChangeText={setNoteDraft}
placeholder="Réponse du pro…"
placeholderTextColor={UI.textMuted}
/>
<Pressable
onPress={() => void saveNoteFor(item.id)}
disabled={isSaving}
className="mt-2 min-h-[48px] items-center justify-center rounded-xl"
style={{ backgroundColor: UI.primary }}
>
{isSaving ? (
<ActivityIndicator color="#fff" />
) : (
<Text className="text-base font-bold text-white">Enregistrer la note</Text>
)}
</Pressable>
</View>
) : st.note ? (
<Text className="mt-2 text-base italic" style={{ color: UI.textMuted }}>
{st.note}
</Text>
) : null}
</View>
);
})}
</Collapsible>
</ScrollView>
);
}

View File

@ -0,0 +1,168 @@
import { Ionicons } from '@expo/vector-icons';
import type { ComponentProps } from 'react';
import { useEffect, useState } from 'react';
import {
ActivityIndicator,
Alert,
Keyboard,
Linking,
Pressable,
ScrollView,
Text,
TextInput,
View,
} from 'react-native';
import {
dvfSearchUrl,
meilleursAgentsUrlForVille,
SECTOR_TOOLS,
type SectorTool,
} from '@/constants/rechercheMarche';
import { UI } from '@/constants/uiTheme';
import { useAnalyseSecteurForVille, useSaveAnalyseSecteur } from '@/hooks/useAnalysesSecteur';
import { formatPocketBaseError } from '@/utils/pocketbaseErrors';
type IonName = ComponentProps<typeof Ionicons>['name'];
function resolveToolUrl(tool: SectorTool, ville: string): string {
if (tool.id === 'ma') return meilleursAgentsUrlForVille(ville);
if (tool.id === 'dvf') return dvfSearchUrl(ville);
return tool.url;
}
export function SecteurTab() {
const [ville, setVille] = useState('');
const [notes, setNotes] = useState('');
const secteurQ = useAnalyseSecteurForVille(ville);
const saveMut = useSaveAnalyseSecteur();
useEffect(() => {
if (secteurQ.data?.notes != null) setNotes(secteurQ.data.notes);
}, [secteurQ.data?.id, secteurQ.data?.notes]);
const onOpenTool = async (tool: SectorTool) => {
const url = resolveToolUrl(tool, ville);
const ok = await Linking.canOpenURL(url);
if (!ok) {
Alert.alert('Lien', 'Impossible douvrir 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 lanalyse.');
return;
}
Keyboard.dismiss();
Alert.alert(
'Secteur',
`Analyse pour « ${v} » : utilise les outils ci-dessous (données externes), puis consigne tes notes en bas de page.`,
);
};
const onSaveNotes = async () => {
const v = ville.trim();
if (!v) {
Alert.alert('Ville', 'Renseigne la ville avant de sauvegarder les notes.');
return;
}
try {
await saveMut.mutateAsync({ ville: v, notes });
} catch (e) {
Alert.alert('Erreur', formatPocketBaseError(e));
}
};
return (
<ScrollView className="flex-1 px-3 pt-2" contentContainerStyle={{ paddingBottom: 120 }} keyboardShouldPersistTaps="handled">
<Text className="text-base font-semibold" style={{ color: UI.text }}>
Ville / commune
</Text>
<TextInput
className="mt-2 rounded-2xl border-2 px-4 text-lg"
style={{ borderColor: UI.border, color: UI.text, minHeight: 52, backgroundColor: UI.card }}
placeholder="Ex. Lyon 3e, Bordeaux…"
placeholderTextColor={UI.textMuted}
value={ville}
onChangeText={setVille}
onSubmitEditing={onAnalyser}
/>
<Pressable
accessibilityRole="button"
onPress={onAnalyser}
className="mt-3 min-h-[52px] items-center justify-center rounded-2xl active:opacity-90"
style={{ backgroundColor: UI.primary }}
>
<Text className="text-lg font-bold text-white">Analyser</Text>
</Pressable>
<Text className="mb-2 mt-8 text-xl font-bold" style={{ color: UI.text }}>
Outils marché
</Text>
<Text className="mb-3 text-base" style={{ color: UI.textMuted }}>
Données externes ouverture dans le navigateur.
</Text>
{SECTOR_TOOLS.map((tool) => (
<Pressable
key={tool.id}
accessibilityRole="button"
onPress={() => void onOpenTool(tool)}
className="mb-3 flex-row items-center rounded-2xl border-2 bg-white p-4 active:opacity-90"
style={{ borderColor: UI.border }}
>
<View
className="mr-3 h-12 w-12 items-center justify-center rounded-xl"
style={{ backgroundColor: '#EFF6FF' }}
>
<Ionicons name={tool.icon as IonName} size={24} color={UI.primary} />
</View>
<View className="min-w-0 flex-1">
<Text className="text-lg font-bold" style={{ color: UI.text }}>
{tool.title}
</Text>
<Text className="mt-1 text-base leading-5" style={{ color: UI.textMuted }}>
{tool.description}
</Text>
<View className="mt-2 self-start rounded-full px-2 py-1" style={{ backgroundColor: '#E0E7FF' }}>
<Text className="text-xs font-bold" style={{ color: UI.primary }}>
Externe
</Text>
</View>
</View>
</Pressable>
))}
<Text className="mb-2 mt-4 text-xl font-bold" style={{ color: UI.text }}>
Notes secteur
</Text>
{secteurQ.isFetching ? <ActivityIndicator color={UI.primary} className="mb-2" /> : null}
<TextInput
className="min-h-[140px] rounded-2xl border-2 px-4 py-3 text-lg"
style={{ borderColor: UI.border, color: UI.text, backgroundColor: UI.card }}
multiline
textAlignVertical="top"
placeholder="Synthèse prix, tension, typologie…"
placeholderTextColor={UI.textMuted}
value={notes}
onChangeText={setNotes}
/>
<Pressable
accessibilityRole="button"
onPress={() => void onSaveNotes()}
disabled={saveMut.isPending}
className="mt-3 min-h-[52px] items-center justify-center rounded-2xl active:opacity-90"
style={{ backgroundColor: UI.success }}
>
{saveMut.isPending ? (
<ActivityIndicator color="#fff" />
) : (
<Text className="text-lg font-bold text-white">Sauvegarder</Text>
)}
</Pressable>
</ScrollView>
);
}

View File

@ -0,0 +1,34 @@
import { View } from 'react-native';
import { UI } from '@/constants/uiTheme';
export function DashboardSkeleton() {
return (
<View className="p-4" accessibilityLabel="Chargement du tableau de bord">
<View className="mb-2 h-7 w-[55%] rounded-lg" style={{ backgroundColor: '#E2E8F0' }} />
<View className="mb-4 h-24 rounded-2xl border" style={{ borderColor: UI.border, backgroundColor: UI.card }} />
<View className="mb-2 h-7 w-[40%] rounded-lg" style={{ backgroundColor: '#E2E8F0' }} />
<View className="mb-4 flex-row gap-3">
{[1, 2, 3].map((k) => (
<View
key={k}
className="h-24 flex-1 rounded-2xl border"
style={{ borderColor: UI.border, backgroundColor: UI.card }}
/>
))}
</View>
<View className="mb-2 h-7 w-[35%] rounded-lg" style={{ backgroundColor: '#E2E8F0' }} />
<View className="mb-4 h-20 rounded-2xl border" style={{ borderColor: UI.border, backgroundColor: '#F8FAFC' }} />
<View className="mb-2 h-7 w-[45%] rounded-lg" style={{ backgroundColor: '#E2E8F0' }} />
<View className="gap-3">
{[1, 2, 3].map((k) => (
<View
key={k}
className="h-16 rounded-2xl border"
style={{ borderColor: UI.border, backgroundColor: UI.card }}
/>
))}
</View>
</View>
);
}

View File

@ -0,0 +1,57 @@
import { Link, type Href } from 'expo-router';
import { Pressable, Text, View } from 'react-native';
import { UI } from '@/constants/uiTheme';
export type EmptyStateProps = {
title: string;
description?: string;
actionLabel?: string;
actionHref?: Href;
onAction?: () => void;
};
export function EmptyState({ title, description, actionLabel, actionHref, onAction }: EmptyStateProps) {
const actionNode =
actionLabel && actionHref != null ? (
<Link href={actionHref} asChild>
<Pressable
accessibilityRole="button"
className="mt-6 min-h-[52px] w-full max-w-sm items-center justify-center rounded-2xl px-5 active:opacity-90"
style={{ backgroundColor: UI.primary }}
>
<Text className="text-center text-lg font-semibold text-white">{actionLabel}</Text>
</Pressable>
</Link>
) : actionLabel && onAction ? (
<Pressable
accessibilityRole="button"
onPress={onAction}
className="mt-6 min-h-[52px] w-full max-w-sm items-center justify-center rounded-2xl px-5 active:opacity-90"
style={{ backgroundColor: UI.primary }}
>
<Text className="text-center text-lg font-semibold text-white">{actionLabel}</Text>
</Pressable>
) : null;
return (
<View className="items-center justify-center px-5 py-10">
<Text
className="text-center text-xl font-bold leading-7"
style={{ color: UI.text }}
accessibilityRole="header"
>
{title}
</Text>
{description ? (
<Text
className="mt-3 max-w-sm text-center text-base leading-6"
style={{ color: UI.textMuted }}
>
{description}
</Text>
) : null}
{actionNode}
</View>
);
}

View File

@ -0,0 +1,39 @@
import { View } from 'react-native';
import { UI } from '@/constants/uiTheme';
type Props = {
rows?: number;
/** Hauteur des cartes (liste verticale). */
variant?: 'card' | 'compact';
};
export function ListSkeleton({ rows = 6, variant = 'card' }: Props) {
const gap = variant === 'compact' ? 10 : 14;
const pad = variant === 'compact' ? 12 : 16;
return (
<View className="px-3 pt-3" accessibilityLabel="Chargement en cours">
{Array.from({ length: rows }).map((_, i) => (
<View
key={i}
className="mb-3 rounded-2xl border bg-white"
style={{
borderColor: UI.border,
padding: pad,
gap,
}}
>
<View
className="h-5 rounded-md"
style={{ width: '62%', backgroundColor: '#E2E8F0' }}
/>
<View className="h-4 rounded-md" style={{ width: '92%', backgroundColor: '#F1F5F9' }} />
<View className="h-4 rounded-md" style={{ width: '78%', backgroundColor: '#F1F5F9' }} />
{variant === 'card' ? (
<View className="h-4 rounded-md" style={{ width: '44%', backgroundColor: '#E2E8F0' }} />
) : null}
</View>
))}
</View>
);
}

View File

@ -0,0 +1,37 @@
import { ScrollView, View } from 'react-native';
import { UI } from '@/constants/uiTheme';
const COL_W = 216;
export function PipelineSkeleton() {
return (
<ScrollView
horizontal
className="flex-1"
contentContainerStyle={{ padding: 12, paddingBottom: 96 }}
accessibilityLabel="Chargement du pipeline"
>
{Array.from({ length: 4 }).map((_, col) => (
<View
key={col}
className="mr-3 rounded-2xl border bg-white p-3"
style={{ width: COL_W, borderColor: UI.border }}
>
<View className="mb-3 h-6 w-[85%] rounded-md" style={{ backgroundColor: '#E2E8F0' }} />
<View className="mb-2 h-3 w-10 rounded" style={{ backgroundColor: '#CBD5E1' }} />
{Array.from({ length: 3 }).map((__, row) => (
<View
key={row}
className="mb-2 rounded-xl border p-3"
style={{ borderColor: '#E2E8F0', backgroundColor: '#F8FAFC' }}
>
<View className="mb-2 h-4 w-[90%] rounded" style={{ backgroundColor: '#E2E8F0' }} />
<View className="h-3 w-[55%] rounded" style={{ backgroundColor: '#F1F5F9' }} />
</View>
))}
</View>
))}
</ScrollView>
);
}

View File

@ -0,0 +1,103 @@
export type SectorTool = {
id: string;
title: string;
description: string;
url: string;
/** Nom Ionicons (outline). */
icon: string;
};
export const SECTOR_TOOLS: SectorTool[] = [
{
id: 'ma',
title: 'Prix au m² — Meilleurs Agents',
description: 'Prix moyen, évolution, comparaison communes',
url: 'https://www.meilleursagents.com/prix-immobilier/',
icon: 'stats-chart-outline',
},
{
id: 'dvf',
title: 'Transactions réelles — DVF',
description: 'Prix de vente réels, toutes transactions',
url: 'https://dvf.etalab.gouv.fr/',
icon: 'document-text-outline',
},
{
id: 'moteur',
title: 'Annonces actives — Moteur Immo',
description: 'Agrège tous les sites, historique baisses de prix',
url: 'https://www.moteurimmo.fr/',
icon: 'search-outline',
},
{
id: 'insee',
title: 'Données INSEE',
description: 'Revenus médians, démographie, vacance logements',
url: 'https://www.insee.fr/fr/statistiques/zones/1405599',
icon: 'business-outline',
},
{
id: 'geo',
title: 'Carte & PLU — Géoportail',
description: 'Transports, écoles, zones PLU, foncier',
url: 'https://www.geoportail.gouv.fr/',
icon: 'map-outline',
},
{
id: 'pappers',
title: 'Concurrence MDB — Pappers Immo',
description: 'Historique transactions, propriétaires, sociétés actives',
url: 'https://immobilier.pappers.fr/',
icon: 'people-outline',
},
];
export const OFF_MARKET_KEYWORDS: { id: string; label: string; text: string }[] = [
{
id: 'k1',
label: 'Pack division studios',
text: '"studio" + "lots réunis" + "vendu libre" + "deux studios"',
},
{
id: 'k2',
label: 'Multi-lots / réunion',
text: '"appartements réunis" + "configuration possible" + "immeuble" + "multi-lots"',
},
];
export const PROSPECTION_CHECKLIST: { id: string; label: string; question: string }[] = [
{ id: 'agent_immo', label: 'Agents immo', question: 'À combien ça part vraiment ? Cest rapide à vendre ?' },
{ id: 'notaire', label: 'Notaires', question: 'Quelles tendances dans vos actes récents ?' },
{ id: 'geometre', label: 'Géomètres', question: 'Divisions fréquentes sur ce secteur ?' },
{ id: 'banquier', label: 'Banquiers', question: 'Vous financez souvent des projets ici ?' },
{ id: 'mdb', label: 'Autres MDB', question: 'Tu trouves facilement sur ce secteur ?' },
{ id: 'artisan', label: 'Artisans', question: 'Vous travaillez beaucoup dans ce quartier ?' },
];
export const CATEGORIE_NOTES_PROSPECTION = 'recherche_opportunites';
export const LEGI_L151_36 =
'https://www.legifrance.gouv.fr/codes/article_lc/LEGIARTI000031211239';
export const LEGI_L152_6 =
'https://www.legifrance.gouv.fr/codes/article_lc/LEGIARTI000043978020/2021-08-25';
export function meilleursAgentsUrlForVille(ville: string): string {
const v = ville.trim();
if (!v) return SECTOR_TOOLS[0].url;
return `https://www.meilleursagents.com/prix-immobilier/${encodeURIComponent(v)}/`;
}
export function dvfSearchUrl(ville: string): string {
const v = ville.trim();
if (!v) return 'https://dvf.etalab.gouv.fr/';
return `https://dvf.etalab.gouv.fr/?q=${encodeURIComponent(v)}`;
}
export function geoportailBienUrl(lat: number, lon: number): string {
return `https://www.geoportail.gouv.fr/?lon=${lon}&lat=${lat}&z=17`;
}
export function marginPctFromPrices(achat: number, revente: number): number | null {
if (!Number.isFinite(achat) || !Number.isFinite(revente) || achat <= 0) return null;
return ((revente - achat) / achat) * 100;
}

12
app/constants/uiTheme.ts Normal file
View File

@ -0,0 +1,12 @@
/** Palette terrain / extérieur — contraste élevé, actions distinctes. */
export const UI = {
primary: '#1D4ED8',
success: '#16A34A',
warning: '#D97706',
danger: '#DC2626',
screen: '#F1F5F9',
card: '#FFFFFF',
text: '#0F172A',
textMuted: '#475569',
border: '#CBD5E1',
} as const;

View File

@ -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<s
taxe_fonciere_annuelle: form.taxe_fonciere_annuelle,
charges_copropriete_mensuelle: form.charges_copropriete_mensuelle,
prix_revente_cible: form.prix_revente_cible,
...(form.prix_revente_m2 !== undefined && form.prix_revente_m2 !== null
? { prix_revente_m2: form.prix_revente_m2 }
: {}),
frais_agence_vente_pct: form.frais_agence_vente_pct,
taux_impot: form.taux_impot,
marge_brute: calc.marge_brute,
@ -153,6 +158,30 @@ export function useAnalyse(bienId: string | undefined) {
},
});
const patchMutation = useMutation({
mutationFn: async (patch: { prix_revente_m2?: number | null }) => {
if (!bienId || !uid) throw new Error('Données manquantes');
const res = await pb.collection('analyses_financieres').getList<AnalyseFinanciereRecord>(1, 1, {
filter: `bien="${bienId}" && user="${uid}"`,
sort: '-id',
});
const existing = res.items[0];
if (existing) {
return pb.collection('analyses_financieres').update<AnalyseFinanciereRecord>(existing.id, patch);
}
return pb.collection('analyses_financieres').create<AnalyseFinanciereRecord>({
user: uid,
bien: bienId,
type_bien_fiscal: 'ancien',
...patch,
});
},
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ['analyse_financiere', bienId, uid] });
void queryClient.invalidateQueries({ queryKey: ['bien_detail', bienId] });
},
});
return {
analyse: query.data ?? null,
isLoading: query.isPending,
@ -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,
};
}

View File

@ -0,0 +1,60 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { getCurrentUserId, pb } from '@/services/pocketbase';
import type { AnalyseSecteurRecord } from '@/types/collections';
function escapeFilterValue(s: string): string {
return s.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
}
export function useAnalyseSecteurForVille(ville: string) {
const uid = getCurrentUserId();
const key = ville.trim().toLowerCase();
return useQuery({
queryKey: ['analyse_secteur', uid, key],
queryFn: async (): Promise<AnalyseSecteurRecord | null> => {
if (!uid || !key) return null;
const esc = escapeFilterValue(ville.trim());
const list = await pb.collection('analyses_secteur').getFullList<AnalyseSecteurRecord>({
filter: `user="${uid}" && ville="${esc}"`,
sort: '-updated',
});
return list[0] ?? null;
},
enabled: Boolean(uid && key),
});
}
export function useSaveAnalyseSecteur() {
const uid = getCurrentUserId();
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (payload: { ville: string; notes: string }) => {
if (!uid) throw new Error('Non connecté');
const ville = payload.ville.trim();
if (!ville) throw new Error('Ville requise');
const esc = escapeFilterValue(ville);
const existing = await pb.collection('analyses_secteur').getFullList<AnalyseSecteurRecord>({
filter: `user="${uid}" && ville="${esc}"`,
sort: '-updated',
});
const row = existing[0];
if (row) {
return pb.collection('analyses_secteur').update<AnalyseSecteurRecord>(row.id, {
notes: payload.notes,
});
}
return pb.collection('analyses_secteur').create<AnalyseSecteurRecord>({
user: uid,
ville,
notes: payload.notes,
});
},
onSuccess: (_, v) => {
const key = v.ville.trim().toLowerCase();
void queryClient.invalidateQueries({ queryKey: ['analyse_secteur', uid, key] });
},
});
}

View File

@ -0,0 +1,79 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { marginPctFromPrices } from '@/constants/rechercheMarche';
import { getCurrentUserId, pb } from '@/services/pocketbase';
import type { GrillePrixEtat, GrillePrixRecord, GrillePrixTypeBien } from '@/types/collections';
export type GrillePrixInput = {
type_bien: GrillePrixTypeBien;
etat: GrillePrixEtat;
prix_achat_m2: number;
prix_revente_m2: number;
ville?: string;
};
function withMargin(input: GrillePrixInput): Record<string, unknown> {
const marge = marginPctFromPrices(input.prix_achat_m2, input.prix_revente_m2);
return {
type_bien: input.type_bien,
etat: input.etat,
prix_achat_m2: input.prix_achat_m2,
prix_revente_m2: input.prix_revente_m2,
ville: input.ville?.trim() || undefined,
marge_estimee_pct: marge != null ? Math.round(marge * 100) / 100 : undefined,
};
}
export function useGrillePrix() {
const uid = getCurrentUserId();
const queryClient = useQueryClient();
const query = useQuery({
queryKey: ['grille_prix', uid],
queryFn: async () => {
if (!uid) return [] as GrillePrixRecord[];
return pb.collection('grille_prix').getFullList<GrillePrixRecord>({
filter: `user="${uid}"`,
sort: '-updated',
});
},
enabled: Boolean(uid),
});
const invalidate = () => {
void queryClient.invalidateQueries({ queryKey: ['grille_prix', uid] });
};
const createRow = useMutation({
mutationFn: async (input: GrillePrixInput) => {
if (!uid) throw new Error('Non connecté');
return pb.collection('grille_prix').create<GrillePrixRecord>({
user: uid,
...withMargin(input),
});
},
onSuccess: invalidate,
});
const updateRow = useMutation({
mutationFn: async ({ id, input }: { id: string; input: GrillePrixInput }) => {
return pb.collection('grille_prix').update<GrillePrixRecord>(id, withMargin(input));
},
onSuccess: invalidate,
});
const deleteRow = useMutation({
mutationFn: async (id: string) => pb.collection('grille_prix').delete(id),
onSuccess: invalidate,
});
return {
rows: query.data ?? [],
isLoading: query.isPending,
error: query.error,
createRow: createRow.mutateAsync,
updateRow: updateRow.mutateAsync,
deleteRow: deleteRow.mutateAsync,
isMutating: createRow.isPending || updateRow.isPending || deleteRow.isPending,
};
}

View File

@ -0,0 +1,90 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { CATEGORIE_NOTES_PROSPECTION } from '@/constants/rechercheMarche';
import { getCurrentUserId, pb } from '@/services/pocketbase';
import type { NoteProspectionRecord } from '@/types/collections';
function escapePb(s: string): string {
return s.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
}
export type ChecklistPersist = { done: boolean; note: string };
function encodeReponse(data: ChecklistPersist): string {
return JSON.stringify(data);
}
function decodeReponse(raw?: string | null): ChecklistPersist {
if (!raw?.trim()) return { done: false, note: '' };
try {
const v = JSON.parse(raw) as unknown;
if (v && typeof v === 'object' && 'done' in v) {
const o = v as { done?: boolean; note?: string };
return { done: Boolean(o.done), note: typeof o.note === 'string' ? o.note : '' };
}
} catch {
return { done: false, note: raw };
}
return { done: false, note: '' };
}
export function useNotesProspectionRecherche() {
const uid = getCurrentUserId();
const queryClient = useQueryClient();
const query = useQuery({
queryKey: ['notes_prospection', uid, CATEGORIE_NOTES_PROSPECTION],
queryFn: async () => {
if (!uid) return [] as NoteProspectionRecord[];
return pb.collection('notes_prospection').getFullList<NoteProspectionRecord>({
filter: `user="${uid}" && categorie="${CATEGORIE_NOTES_PROSPECTION}"`,
sort: '-id',
});
},
enabled: Boolean(uid),
});
const byQuestionId = (questionId: string): NoteProspectionRecord | undefined =>
query.data?.find((r) => r.question === questionId);
const upsert = useMutation({
mutationFn: async (payload: { questionId: string; data: ChecklistPersist }) => {
if (!uid) throw new Error('Non connecté');
const reponse = encodeReponse(payload.data);
const qe = escapePb(payload.questionId);
const existing = await pb.collection('notes_prospection').getFullList<NoteProspectionRecord>({
filter: `user="${uid}" && categorie="${escapePb(CATEGORIE_NOTES_PROSPECTION)}" && question="${qe}"`,
});
const row = existing[0];
if (row) {
return pb.collection('notes_prospection').update<NoteProspectionRecord>(row.id, {
reponse,
categorie: CATEGORIE_NOTES_PROSPECTION,
});
}
return pb.collection('notes_prospection').create<NoteProspectionRecord>({
user: uid,
question: payload.questionId,
reponse,
categorie: CATEGORIE_NOTES_PROSPECTION,
});
},
onSuccess: () => {
void queryClient.invalidateQueries({
queryKey: ['notes_prospection', uid, CATEGORIE_NOTES_PROSPECTION],
});
},
});
return {
rows: query.data ?? [],
isLoading: query.isPending,
error: query.error,
getState(questionId: string): ChecklistPersist {
const row = byQuestionId(questionId);
return decodeReponse(row?.reponse);
},
saveChecklistItem: upsert.mutateAsync,
isSaving: upsert.isPending,
};
}

12
app/package-lock.json generated
View File

@ -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",

View File

@ -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",

View File

@ -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 & {

View File

@ -1,5 +1,3 @@
version: '3.8'
services:
pocketbase:
image: ghcr.io/muchobien/pocketbase:latest

View File

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

View File

@ -0,0 +1,176 @@
/// <reference path="../pb_data/types.d.ts" />
/**
* 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 (_) {}
}
},
);

View File

@ -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) {