Compare commits

...

2 Commits

Author SHA1 Message Date
360522f30a recherche 2026-05-04 22:11:46 +02:00
2b8741de08 recherche 2026-05-04 21:52:51 +02:00
34 changed files with 3452 additions and 248 deletions

View File

@ -24,7 +24,10 @@ 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,
recherches_sauvegardees, alertes_recherche, annonces_veille, flux_sources,
transactions_secteur, courriers_immobilier
## Règles de code
- TypeScript strict, jamais de any

View File

@ -12,13 +12,39 @@
- [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)
- [x] Multi-agents MVP (migration `1760000000`, hooks `agents_veille.pb.js`, onglet **Veille & agents** : recherches, alertes, annonces veille, transactions secteur, courriers + routes `/api/mdb/agent-*`)
## 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
- Admin : http://localhost:8090/_/ (admin@mdb.fr)
- Binaire : /usr/local/bin/pocketbase
- Données : /pb_data
- Hooks JS : volume `pb_hooks` `--hooksDir=/pb_hooks` (image muchobien)
- Hooks JS : volume `pb_hooks` monté sur `/pb_hooks` ; **docker-compose.dev** : `command: --dir=/pb_data --hooksDir=/pb_hooks` pour charger les routes `/api/mdb/*`
- Variable IA : `ANTHROPIC_API_KEY` dans `.env.local` (chargée par Docker pour PocketBase)
- OS : Windows Git Bash (MSYS_NO_PATHCONV=1)
- PocketBase : v0.23+

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,46 @@
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 { VeilleAgentsTab } from '@/components/recherche/VeilleAgentsTab';
import { UI } from '@/constants/uiTheme';
const TABS = ['Secteur', 'Opportunités', 'Grille de prix', 'Veille & agents'] 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}
{sub === 3 ? <VeilleAgentsTab /> : 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,433 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useCallback, useState } from 'react';
import {
ActivityIndicator,
Alert,
Pressable,
RefreshControl,
ScrollView,
Text,
TextInput,
View,
} from 'react-native';
import { UI } from '@/constants/uiTheme';
import {
agentAlertesScan,
agentDvf,
agentImmobilier,
agentMarchand,
agentRedaction,
agentVeille,
} from '@/services/agentsApi';
import { getCurrentUserId, pb } from '@/services/pocketbase';
import type {
AlerteRechercheRecord,
AnnonceVeilleRecord,
CourrierImmobilierRecord,
RechercheSauvegardeeRecord,
TransactionSecteurRecord,
} from '@/types/collections';
import { formatPocketBaseError } from '@/utils/pocketbaseErrors';
function showLong(title: string, body: string) {
Alert.alert(title, body.length > 3500 ? `${body.slice(0, 3500)}` : body);
}
export function VeilleAgentsTab() {
const uid = getCurrentUserId();
const qc = useQueryClient();
const [nomRecherche, setNomRecherche] = useState('');
const [critereJson, setCritereJson] = useState('{}');
const [titreAnnonce, setTitreAnnonce] = useState('');
const [urlAnnonce, setUrlAnnonce] = useState('');
const [libelleDvf, setLibelleDvf] = useState('');
const [prixM2, setPrixM2] = useState('');
const [nbVentes, setNbVentes] = useState('');
const invalidateVeille = useCallback(() => {
void qc.invalidateQueries({ queryKey: ['veille'] });
}, [qc]);
const recherches = useQuery({
queryKey: ['veille', 'recherches_sauvegardees', uid],
queryFn: () =>
pb.collection('recherches_sauvegardees').getFullList<RechercheSauvegardeeRecord>({ sort: '-updated' }),
enabled: Boolean(uid),
});
const alertes = useQuery({
queryKey: ['veille', 'alertes_recherche', uid],
queryFn: () => pb.collection('alertes_recherche').getFullList<AlerteRechercheRecord>({ sort: '-updated' }),
enabled: Boolean(uid),
});
const annonces = useQuery({
queryKey: ['veille', 'annonces_veille', uid],
queryFn: () => pb.collection('annonces_veille').getFullList<AnnonceVeilleRecord>({ sort: '-updated' }),
enabled: Boolean(uid),
});
const trans = useQuery({
queryKey: ['veille', 'transactions_secteur', uid],
queryFn: () => pb.collection('transactions_secteur').getFullList<TransactionSecteurRecord>({ sort: '-updated' }),
enabled: Boolean(uid),
});
const courriers = useQuery({
queryKey: ['veille', 'courriers_immobilier', uid],
queryFn: () => pb.collection('courriers_immobilier').getFullList<CourrierImmobilierRecord>({ sort: '-updated' }),
enabled: Boolean(uid),
});
const createRecherche = useMutation({
mutationFn: async () => {
if (!uid) throw new Error('Non connecté');
const nom = nomRecherche.trim();
if (!nom) throw new Error('Nom requis');
return pb.collection('recherches_sauvegardees').create<RechercheSauvegardeeRecord>({
user: uid,
nom,
critere_json: critereJson.trim() || '{}',
actif: true,
});
},
onSuccess: () => {
setNomRecherche('');
invalidateVeille();
},
});
const createAlerteSimple = useMutation({
mutationFn: async () => {
if (!uid) throw new Error('Non connecté');
return pb.collection('alertes_recherche').create<AlerteRechercheRecord>({
user: uid,
nom: `Veille ${new Date().toLocaleDateString('fr-FR')}`,
canal: 'in_app',
actif: true,
});
},
onSuccess: invalidateVeille,
onError: (err) => Alert.alert('Erreur', formatPocketBaseError(err)),
});
const scanAlertes = useMutation({
mutationFn: () => agentAlertesScan(),
onSuccess: (r) => {
invalidateVeille();
Alert.alert('Scan alertes', `${r.processed} alertes mises à jour.\n${r.note ?? ''}`);
},
onError: (e) => Alert.alert('Erreur', formatPocketBaseError(e)),
});
const pushAnnonce = useMutation({
mutationFn: () =>
agentVeille({
titre: titreAnnonce.trim(),
url: urlAnnonce.trim() || undefined,
source: 'manuel',
}),
onSuccess: (r) => {
setTitreAnnonce('');
setUrlAnnonce('');
invalidateVeille();
Alert.alert('Veille', r.dedupe ? 'Doublon ignoré.' : `Enregistrée (${r.id}).`);
},
onError: (e) => Alert.alert('Erreur', formatPocketBaseError(e)),
});
const pushDvf = useMutation({
mutationFn: () => {
const lib = libelleDvf.trim();
if (!lib) throw new Error('Libellé requis');
const pm = Number(String(prixM2).replace(',', '.'));
const nv = Number(String(nbVentes).trim());
return agentDvf({
libelle: lib,
prix_m2_median: Number.isFinite(pm) ? pm : undefined,
nb_ventes: Number.isFinite(nv) ? nv : undefined,
});
},
onSuccess: (r) => {
setLibelleDvf('');
setPrixM2('');
setNbVentes('');
invalidateVeille();
showLong('Synthèse marché', r.synthese);
},
onError: (e) => Alert.alert('Erreur', formatPocketBaseError(e)),
});
const runImmobilier = useMutation({
mutationFn: () =>
agentImmobilier({
objectif: 'Prospection ciblée secteur',
contexte: 'Génère un plan + un message court pour relancer des mandataires potentiels.',
save: false,
}),
onSuccess: (r) => showLong('Agent immobilier', r.brouillon),
onError: (e) => Alert.alert('Erreur', formatPocketBaseError(e)),
});
const runMarchand = useMutation({
mutationFn: () =>
agentMarchand({
titre: titreAnnonce.trim() || 'Annonce test',
notes: 'Comparer avec ma grille perso (onglet Grille de prix).',
}),
onSuccess: (r) => showLong('Agent marchand de biens', r.analyse),
onError: (e) => Alert.alert('Erreur', formatPocketBaseError(e)),
});
const runRedaction = useMutation({
mutationFn: () =>
agentRedaction({
kind: 'annonce_agence',
bullets: ['Lumineux', 'Proche transports', 'Charges faibles'],
save: false,
}),
onSuccess: (r) => showLong('Agent rédaction', r.texte),
onError: (e) => Alert.alert('Erreur', formatPocketBaseError(e)),
});
const loading =
recherches.isPending ||
alertes.isPending ||
annonces.isPending ||
trans.isPending ||
courriers.isPending;
return (
<ScrollView
className="flex-1 px-4 py-3"
style={{ backgroundColor: UI.screen }}
refreshControl={
<RefreshControl
refreshing={loading}
onRefresh={() => {
void recherches.refetch();
void alertes.refetch();
void annonces.refetch();
void trans.refetch();
void courriers.refetch();
}}
/>
}
>
<Text className="mb-2 text-lg font-bold" style={{ color: UI.text }}>
Agents IA (MVP)
</Text>
<Text className="mb-4 text-sm" style={{ color: UI.textMuted }}>
Connexion serveur + clé Anthropic requises. Les données restent dans PocketBase.
</Text>
<View className="mb-6 gap-2">
<Text className="font-semibold" style={{ color: UI.text }}>
Lancer un agent
</Text>
<View className="flex-row flex-wrap gap-2">
<AgentButton label="Immobilier" loading={runImmobilier.isPending} onPress={() => runImmobilier.mutate()} />
<AgentButton
label="Marchand"
loading={runMarchand.isPending}
onPress={() => runMarchand.mutate()}
/>
<AgentButton label="Rédaction" loading={runRedaction.isPending} onPress={() => runRedaction.mutate()} />
</View>
</View>
<View className="mb-6 rounded-lg border p-3" style={{ borderColor: UI.border, backgroundColor: UI.card }}>
<Text className="mb-2 font-semibold" style={{ color: UI.text }}>
Agent data / secteur (stub DVF)
</Text>
<TextInput
className="mb-2 rounded border px-2 py-2"
style={{ borderColor: UI.border, color: UI.text }}
placeholder="Libellé zone (ex. Lyon 6e)"
placeholderTextColor={UI.textMuted}
value={libelleDvf}
onChangeText={setLibelleDvf}
/>
<View className="mb-2 flex-row gap-2">
<TextInput
className="flex-1 rounded border px-2 py-2"
style={{ borderColor: UI.border, color: UI.text }}
placeholder="Prix m² médian"
placeholderTextColor={UI.textMuted}
keyboardType="decimal-pad"
value={prixM2}
onChangeText={setPrixM2}
/>
<TextInput
className="flex-1 rounded border px-2 py-2"
style={{ borderColor: UI.border, color: UI.text }}
placeholder="Nb ventes"
placeholderTextColor={UI.textMuted}
keyboardType="number-pad"
value={nbVentes}
onChangeText={setNbVentes}
/>
</View>
<Pressable
className="items-center rounded-lg py-3"
style={{ backgroundColor: UI.primary }}
onPress={() => pushDvf.mutate()}
disabled={pushDvf.isPending || !libelleDvf.trim()}
>
{pushDvf.isPending ? (
<ActivityIndicator color="#fff" />
) : (
<Text className="font-semibold text-white">Enregistrer + synthèse IA</Text>
)}
</Pressable>
</View>
<View className="mb-6 rounded-lg border p-3" style={{ borderColor: UI.border, backgroundColor: UI.card }}>
<Text className="mb-2 font-semibold" style={{ color: UI.text }}>
Agent veille (dédoublonnage MD5)
</Text>
<TextInput
className="mb-2 rounded border px-2 py-2"
style={{ borderColor: UI.border, color: UI.text }}
placeholder="Titre annonce"
placeholderTextColor={UI.textMuted}
value={titreAnnonce}
onChangeText={setTitreAnnonce}
/>
<TextInput
className="mb-2 rounded border px-2 py-2"
style={{ borderColor: UI.border, color: UI.text }}
placeholder="URL (optionnel)"
placeholderTextColor={UI.textMuted}
value={urlAnnonce}
onChangeText={setUrlAnnonce}
autoCapitalize="none"
/>
<Pressable
className="items-center rounded-lg py-3"
style={{ backgroundColor: UI.primary }}
onPress={() => pushAnnonce.mutate()}
disabled={pushAnnonce.isPending || !titreAnnonce.trim()}
>
{pushAnnonce.isPending ? (
<ActivityIndicator color="#fff" />
) : (
<Text className="font-semibold text-white">Ajouter à la veille</Text>
)}
</Pressable>
</View>
<View className="mb-6 rounded-lg border p-3" style={{ borderColor: UI.border, backgroundColor: UI.card }}>
<Text className="mb-2 font-semibold" style={{ color: UI.text }}>
Recherches sauvegardées
</Text>
<TextInput
className="mb-2 rounded border px-2 py-2"
style={{ borderColor: UI.border, color: UI.text }}
placeholder="Nom"
placeholderTextColor={UI.textMuted}
value={nomRecherche}
onChangeText={setNomRecherche}
/>
<TextInput
className="mb-2 rounded border px-2 py-2"
style={{ borderColor: UI.border, color: UI.text }}
placeholder='Critères JSON (ex. {"prix_max":250000})'
placeholderTextColor={UI.textMuted}
value={critereJson}
onChangeText={setCritereJson}
multiline
/>
<Pressable
className="mb-3 items-center rounded-lg py-2"
style={{ backgroundColor: UI.border }}
onPress={() => createRecherche.mutate()}
disabled={createRecherche.isPending}
>
<Text style={{ color: UI.text }}>Créer la recherche</Text>
</Pressable>
{recherches.data?.map((r) => (
<Text key={r.id} className="text-sm" style={{ color: UI.textMuted }}>
{r.nom}
</Text>
))}
</View>
<View className="mb-6 rounded-lg border p-3" style={{ borderColor: UI.border, backgroundColor: UI.card }}>
<Text className="mb-2 font-semibold" style={{ color: UI.text }}>
Agent alertes (stub scan)
</Text>
<Pressable
className="mb-2 items-center rounded-lg py-2"
style={{ backgroundColor: UI.border }}
onPress={() => createAlerteSimple.mutate()}
disabled={createAlerteSimple.isPending}
>
<Text style={{ color: UI.text }}>Nouvelle alerte in-app</Text>
</Pressable>
<Pressable
className="items-center rounded-lg py-2"
style={{ backgroundColor: UI.border }}
onPress={() => scanAlertes.mutate()}
disabled={scanAlertes.isPending}
>
{scanAlertes.isPending ? (
<ActivityIndicator />
) : (
<Text style={{ color: UI.text }}>Scanner mes alertes actives</Text>
)}
</Pressable>
{alertes.data?.map((a) => (
<Text key={a.id} className="mt-1 text-sm" style={{ color: UI.textMuted }}>
{a.nom} ({a.canal}) {a.actif === false ? '— off' : ''}
</Text>
))}
</View>
<Text className="mb-1 font-semibold" style={{ color: UI.text }}>
Annonces veille ({annonces.data?.length ?? 0})
</Text>
{annonces.data?.slice(0, 8).map((a) => (
<Text key={a.id} className="text-sm" style={{ color: UI.textMuted }}>
[{a.statut}] {a.titre}
</Text>
))}
<Text className="mb-1 mt-4 font-semibold" style={{ color: UI.text }}>
Transactions secteur ({trans.data?.length ?? 0})
</Text>
{trans.data?.slice(0, 6).map((t) => (
<Text key={t.id} className="text-sm" style={{ color: UI.textMuted }}>
{t.libelle}
{t.prix_m2_median != null ? `${t.prix_m2_median} €/m²` : ''}
</Text>
))}
<Text className="mb-1 mt-4 font-semibold" style={{ color: UI.text }}>
Courriers ({courriers.data?.length ?? 0})
</Text>
{courriers.data?.slice(0, 5).map((c) => (
<Text key={c.id} className="text-sm" style={{ color: UI.textMuted }}>
{c.titre} ({c.kind}/{c.etat})
</Text>
))}
<View className="h-24" />
</ScrollView>
);
}
function AgentButton(props: { label: string; loading: boolean; onPress: () => void }) {
return (
<Pressable
className="rounded-lg px-4 py-2"
style={{ backgroundColor: UI.primary }}
onPress={props.onPress}
disabled={props.loading}
>
{props.loading ? <ActivityIndicator color="#fff" size="small" /> : <Text className="text-white">{props.label}</Text>}
</Pressable>
);
}

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

66
app/services/agentsApi.ts Normal file
View File

@ -0,0 +1,66 @@
import { pb } from '@/services/pocketbase';
export type AgentImmobilierBody = {
objectif?: string;
contexte?: string;
save?: boolean;
};
export type AgentMarchandBody = {
titre?: string;
prix?: number;
surface?: number;
code_postal?: string;
ville?: string;
notes?: string;
grille_json?: string;
};
export type AgentDvfBody = {
libelle: string;
code_insee?: string;
annee?: number;
prix_m2_median?: number;
nb_ventes?: number;
detail_json?: string;
};
export type AgentVeilleBody = {
titre: string;
url?: string;
source?: string;
prix?: number;
surface?: number;
code_postal?: string;
ville?: string;
};
export type AgentRedactionBody = {
kind?: string;
bullets?: string[];
save?: boolean;
};
export async function agentImmobilier(body: AgentImmobilierBody): Promise<{ brouillon: string; courrier_id?: string }> {
return pb.send('/api/mdb/agent-immobilier', { method: 'POST', body });
}
export async function agentMarchand(body: AgentMarchandBody): Promise<{ analyse: string }> {
return pb.send('/api/mdb/agent-marchand', { method: 'POST', body });
}
export async function agentDvf(body: AgentDvfBody): Promise<{ id: string; synthese: string }> {
return pb.send('/api/mdb/agent-dvf', { method: 'POST', body });
}
export async function agentVeille(body: AgentVeilleBody): Promise<{ id: string; dedupe: boolean; message?: string }> {
return pb.send('/api/mdb/agent-veille', { method: 'POST', body });
}
export async function agentRedaction(body: AgentRedactionBody): Promise<{ texte: string; courrier_id?: string }> {
return pb.send('/api/mdb/agent-redaction', { method: 'POST', body });
}
export async function agentAlertesScan(): Promise<{ processed: number; note?: string }> {
return pb.send('/api/mdb/agent-alertes-scan', { method: 'POST', body: {} });
}

View File

@ -116,6 +116,100 @@ 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 RechercheSauvegardeeRecord = RecordModel & {
user: string;
nom: string;
critere_json?: string;
actif?: boolean;
};
export type AlerteRechercheRecord = RecordModel & {
user: string;
recherche?: string;
nom: string;
canal: 'in_app' | 'email' | 'push';
actif?: boolean;
derniere_verification?: string;
dernier_nb_resultats?: number;
};
export type AnnonceVeilleStatut = 'nouveau' | 'vu' | 'ecarte' | 'raccroche';
export type AnnonceVeilleRecord = RecordModel & {
user: string;
titre: string;
url?: string;
source?: string;
prix?: number;
surface?: number;
code_postal?: string;
ville?: string;
empreinte?: string;
statut: AnnonceVeilleStatut;
};
export type FluxSourceType = 'api' | 'manuel' | 'csv';
export type FluxSourceRecord = RecordModel & {
user: string;
nom: string;
type: FluxSourceType;
notes?: string;
actif?: boolean;
};
export type TransactionSecteurSource = 'manuel' | 'dvf_import' | 'api_tiers';
export type TransactionSecteurRecord = RecordModel & {
user: string;
libelle: string;
code_insee?: string;
annee?: number;
prix_m2_median?: number;
nb_ventes?: number;
source: TransactionSecteurSource;
detail_json?: string;
};
export type CourrierImmobilierKind = 'prospection' | 'annonce_agence' | 'relance';
export type CourrierImmobilierEtat = 'brouillon' | 'pret';
export type CourrierImmobilierRecord = RecordModel & {
user: string;
titre: string;
corps?: string;
kind: CourrierImmobilierKind;
etat: CourrierImmobilierEtat;
};
export type VisiteRecord = RecordModel & {

View File

@ -1,11 +1,9 @@
version: '3.8'
services:
pocketbase:
image: ghcr.io/muchobien/pocketbase:latest
container_name: mdb-pocketbase-dev
restart: unless-stopped
command: --dir=/pb_data
command: --dir=/pb_data --hooksDir=/pb_hooks
ports:
- "8090:8090"
volumes:

View File

@ -0,0 +1,333 @@
/// <reference path="../pb_data/types.d.ts" />
const ANTHROPIC_MODEL = "claude-sonnet-4-20250514";
function getAnthropicKey() {
return $os.getenv("ANTHROPIC_API_KEY");
}
/**
* @param {string} userText
* @returns {{ text: string, error?: { status: number, body: unknown } }}
*/
function callAnthropic(userText) {
const key = getAnthropicKey();
if (!key) {
return { text: "", error: { status: 500, body: { message: "ANTHROPIC_API_KEY manquante" } } };
}
const payload = {
model: ANTHROPIC_MODEL,
max_tokens: 2200,
messages: [{ role: "user", content: userText }],
};
const res = $http.send({
url: "https://api.anthropic.com/v1/messages",
method: "POST",
headers: {
"x-api-key": key,
"anthropic-version": "2023-06-01",
"content-type": "application/json",
},
body: JSON.stringify(payload),
timeout: 120,
});
if (res.statusCode >= 400) {
const errText = typeof res.raw === "string" ? res.raw : "";
return {
text: "",
error: {
status: 502,
body: { message: "Anthropic", statusCode: res.statusCode, detail: errText.slice(0, 1500) },
},
};
}
let parsed = res.json;
if (parsed == null && typeof res.raw === "string" && res.raw.length > 0) {
try {
parsed = JSON.parse(res.raw);
} catch (_) {
parsed = null;
}
}
const text =
parsed && Array.isArray(parsed.content) && parsed.content[0] && parsed.content[0].text
? parsed.content[0].text
: "";
return { text };
}
function readJsonBody(e) {
const b = e.requestInfo().body || {};
return typeof b === "object" && b != null ? b : {};
}
routerAdd(
"POST",
"/api/mdb/agent-immobilier",
(e) => {
if (!e.auth) {
return e.json(401, { message: "Non autorisé" });
}
const body = readJsonBody(e);
const objectif = typeof body.objectif === "string" ? body.objectif : "prospection off-market";
const contexte = typeof body.contexte === "string" ? body.contexte : "";
const prompt =
"Tu es un agent immobilier senior en France. Rédige un plan d'actions concret (puces) puis un brouillon de message court (email ou message) pour : " +
objectif +
".\nContexte fourni par l'utilisateur :\n" +
contexte;
const { text, error } = callAnthropic(prompt);
if (error) {
return e.json(error.status, error.body);
}
const save = body.save === true;
if (save && text) {
const rec = new Record($app.findCollectionByNameOrId("courriers_immobilier"), {
user: e.auth.id,
titre: "Brouillon — " + objectif.slice(0, 80),
corps: text,
kind: "prospection",
etat: "brouillon",
});
$app.save(rec);
return e.json(200, { brouillon: text, courrier_id: rec.id });
}
return e.json(200, { brouillon: text });
},
$apis.requireAuth(),
);
routerAdd(
"POST",
"/api/mdb/agent-marchand",
(e) => {
if (!e.auth) {
return e.json(401, { message: "Non autorisé" });
}
const body = readJsonBody(e);
const titre = typeof body.titre === "string" ? body.titre : "Annonce";
const prix = typeof body.prix === "number" ? body.prix : null;
const surface = typeof body.surface === "number" ? body.surface : null;
const code_postal = typeof body.code_postal === "string" ? body.code_postal : "";
const ville = typeof body.ville === "string" ? body.ville : "";
const notes = typeof body.notes === "string" ? body.notes : "";
const grille = typeof body.grille_json === "string" ? body.grille_json : "";
const prompt =
"Tu es un marchand de biens en France. Analyse l'offre suivante : titre=" +
titre +
", prix=" +
String(prix) +
", surface_m2=" +
String(surface) +
", CP=" +
code_postal +
", ville=" +
ville +
".\nNotes utilisateur : " +
notes +
"\nRéférentiel grille perso (JSON optionnel) : " +
grille +
"\nRéponds en français : (1) fourchette €/m² si calculable, (2) points de vigilance, (3) verdict rapide opportunité / neutre / risqué.";
const { text, error } = callAnthropic(prompt);
if (error) {
return e.json(error.status, error.body);
}
return e.json(200, { analyse: text });
},
$apis.requireAuth(),
);
routerAdd(
"POST",
"/api/mdb/agent-dvf",
(e) => {
if (!e.auth) {
return e.json(401, { message: "Non autorisé" });
}
const body = readJsonBody(e);
const libelle = typeof body.libelle === "string" ? body.libelle : "";
if (!libelle.trim()) {
return e.json(400, { message: "libelle requis" });
}
const code_insee = typeof body.code_insee === "string" ? body.code_insee : "";
const annee = typeof body.annee === "number" ? body.annee : null;
const prix_m2_median = typeof body.prix_m2_median === "number" ? body.prix_m2_median : null;
const nb_ventes = typeof body.nb_ventes === "number" ? body.nb_ventes : null;
const detail_json = typeof body.detail_json === "string" ? body.detail_json : "";
const col = $app.findCollectionByNameOrId("transactions_secteur");
const data = {
user: e.auth.id,
libelle: libelle.trim(),
source: "manuel",
};
if (code_insee) {
data.code_insee = code_insee;
}
if (annee != null) {
data.annee = annee;
}
if (prix_m2_median != null) {
data.prix_m2_median = prix_m2_median;
}
if (nb_ventes != null) {
data.nb_ventes = nb_ventes;
}
if (detail_json) {
data.detail_json = detail_json;
}
const rec = new Record(col, data);
$app.save(rec);
const prompt =
"Tu es un analyste immobilier. Synthétise en 5 phrases maximum l'intérêt de ces statistiques de marché (secteur, médiane €/m², volume) pour un marchand de biens.\nDonnées : " +
JSON.stringify({
libelle: libelle.trim(),
code_insee,
annee,
prix_m2_median,
nb_ventes,
});
const { text, error } = callAnthropic(prompt);
if (error) {
return e.json(error.status, error.body);
}
return e.json(200, { id: rec.id, synthese: text });
},
$apis.requireAuth(),
);
routerAdd(
"POST",
"/api/mdb/agent-veille",
(e) => {
if (!e.auth) {
return e.json(401, { message: "Non autorisé" });
}
const body = readJsonBody(e);
const titre = typeof body.titre === "string" ? body.titre.trim() : "";
if (!titre) {
return e.json(400, { message: "titre requis" });
}
const url = typeof body.url === "string" ? body.url.trim() : "";
const source = typeof body.source === "string" ? body.source.trim() : "manuel";
const prix = typeof body.prix === "number" ? body.prix : undefined;
const surface = typeof body.surface === "number" ? body.surface : undefined;
const code_postal = typeof body.code_postal === "string" ? body.code_postal : "";
const ville = typeof body.ville === "string" ? body.ville : "";
const empreinte = $security.md5((url || "") + "\n" + titre);
const col = $app.findCollectionByNameOrId("annonces_veille");
const filt = 'user = "' + e.auth.id + '" && empreinte = "' + empreinte + '"';
let existing = null;
try {
existing = $app.findFirstRecordByFilter("annonces_veille", filt);
} catch (_) {
existing = null;
}
if (existing != null && existing.id) {
return e.json(200, { id: existing.id, dedupe: true, message: "Déjà enregistrée" });
}
const ann = {
user: e.auth.id,
titre,
url,
source,
empreinte,
statut: "nouveau",
};
if (prix !== undefined) {
ann.prix = prix;
}
if (surface !== undefined) {
ann.surface = surface;
}
if (code_postal) {
ann.code_postal = code_postal;
}
if (ville) {
ann.ville = ville;
}
const rec = new Record(col, ann);
$app.save(rec);
return e.json(200, { id: rec.id, dedupe: false });
},
$apis.requireAuth(),
);
routerAdd(
"POST",
"/api/mdb/agent-redaction",
(e) => {
if (!e.auth) {
return e.json(401, { message: "Non autorisé" });
}
const body = readJsonBody(e);
const kind = typeof body.kind === "string" ? body.kind : "annonce_agence";
const bullets = Array.isArray(body.bullets) ? body.bullets.map(String).join("\n- ") : "";
const prompt =
"Tu es rédacteur pour une agence immobilière en France. Rédige un texte court et professionnel (titres + paragraphes) à partir des puces :\n- " +
bullets +
"\nType de contenu : " +
kind +
".";
const { text, error } = callAnthropic(prompt);
if (error) {
return e.json(error.status, error.body);
}
const save = body.save === true;
if (save && text) {
const col = $app.findCollectionByNameOrId("courriers_immobilier");
const rec = new Record(col, {
user: e.auth.id,
titre: "Rédaction — " + kind,
corps: text,
kind: kind === "relance" || kind === "prospection" ? kind : "annonce_agence",
etat: "pret",
});
$app.save(rec);
return e.json(200, { texte: text, courrier_id: rec.id });
}
return e.json(200, { texte: text });
},
$apis.requireAuth(),
);
routerAdd(
"POST",
"/api/mdb/agent-alertes-scan",
(e) => {
if (!e.auth) {
return e.json(401, { message: "Non autorisé" });
}
const now = new Date().toISOString();
let updated = 0;
try {
const rows = $app.findRecordsByFilter(
"alertes_recherche",
'user = "' + e.auth.id + '"',
"-created",
50,
0,
);
for (let i = 0; i < rows.length; i++) {
const r = rows[i];
if (!r) {
continue;
}
if (r.get("actif") === false) {
continue;
}
r.set("derniere_verification", now);
r.set("dernier_nb_resultats", 0);
$app.save(r);
updated++;
}
} catch (_) {
/* noop */
}
return e.json(200, { processed: updated, note: "Stub: branchement annonces agrégées à venir." });
},
$apis.requireAuth(),
);
cronAdd("mdb_agents_alertes_tick", "0 * * * *", () => {
/* Placeholder : futur batch serveur (sans auth utilisateur). */
});

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,15 @@ migrate(
"notes_biens",
"documents_biens",
"devis_travaux",
"analyses_secteur",
"notes_prospection",
"grille_prix",
"recherches_sauvegardees",
"alertes_recherche",
"annonces_veille",
"flux_sources",
"transactions_secteur",
"courriers_immobilier",
];
const ruleKeys = ["listRule", "viewRule", "createRule", "updateRule", "deleteRule"];
for (const name of names) {

View File

@ -0,0 +1,224 @@
/// <reference path="../pb_data/types.d.ts" />
/**
* Fondations multi-agents : recherches sauvegardées, alertes, veille annonces,
* sources de flux, transactions secteur (stub DVF), courriers prospection.
*/
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 1760000000: 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 addUserRules(col) {
col.listRule = ownRecords;
col.viewRule = ownRecords;
col.createRule = authOnly;
col.updateRule = ownRecords;
col.deleteRule = ownRecords;
}
function userRel() {
return new RelationField({
name: "user",
required: true,
collectionId: usersId,
maxSelect: 1,
cascadeDelete: true,
});
}
loadOrCreate("recherches_sauvegardees", () => {
const col = new Collection({ name: "recherches_sauvegardees", type: "base" });
col.fields.add(userRel());
col.fields.add(new TextField({ name: "nom", required: true }));
col.fields.add(new TextField({ name: "critere_json", required: false }));
col.fields.add(new BoolField({ name: "actif", required: false }));
addUserRules(col);
return col;
});
const rechRef = findExistingCollection("recherches_sauvegardees");
const rechId = rechRef ? String(rechRef.id || rechRef.Id || "").trim() : "";
if (!rechId) {
throw new Error("migration 1760000000: recherches_sauvegardees introuvable après création");
}
loadOrCreate("alertes_recherche", () => {
const col = new Collection({ name: "alertes_recherche", type: "base" });
col.fields.add(userRel());
col.fields.add(
new RelationField({
name: "recherche",
required: false,
collectionId: rechId,
maxSelect: 1,
cascadeDelete: false,
}),
);
col.fields.add(new TextField({ name: "nom", required: true }));
col.fields.add(
new SelectField({
name: "canal",
required: true,
maxSelect: 1,
values: ["in_app", "email", "push"],
}),
);
col.fields.add(new BoolField({ name: "actif", required: false }));
col.fields.add(new TextField({ name: "derniere_verification", required: false }));
col.fields.add(new NumberField({ name: "dernier_nb_resultats", required: false }));
addUserRules(col);
return col;
});
loadOrCreate("annonces_veille", () => {
const col = new Collection({ name: "annonces_veille", type: "base" });
col.fields.add(userRel());
col.fields.add(new TextField({ name: "titre", required: true }));
col.fields.add(new TextField({ name: "url", required: false }));
col.fields.add(new TextField({ name: "source", required: false }));
col.fields.add(new NumberField({ name: "prix", required: false }));
col.fields.add(new NumberField({ name: "surface", required: false }));
col.fields.add(new TextField({ name: "code_postal", required: false }));
col.fields.add(new TextField({ name: "ville", required: false }));
col.fields.add(new TextField({ name: "empreinte", required: false }));
col.fields.add(
new SelectField({
name: "statut",
required: true,
maxSelect: 1,
values: ["nouveau", "vu", "ecarte", "raccroche"],
}),
);
addUserRules(col);
return col;
});
loadOrCreate("flux_sources", () => {
const col = new Collection({ name: "flux_sources", type: "base" });
col.fields.add(userRel());
col.fields.add(new TextField({ name: "nom", required: true }));
col.fields.add(
new SelectField({
name: "type",
required: true,
maxSelect: 1,
values: ["api", "manuel", "csv"],
}),
);
col.fields.add(new TextField({ name: "notes", required: false }));
col.fields.add(new BoolField({ name: "actif", required: false }));
addUserRules(col);
return col;
});
loadOrCreate("transactions_secteur", () => {
const col = new Collection({ name: "transactions_secteur", type: "base" });
col.fields.add(userRel());
col.fields.add(new TextField({ name: "libelle", required: true }));
col.fields.add(new TextField({ name: "code_insee", required: false }));
col.fields.add(new NumberField({ name: "annee", required: false }));
col.fields.add(new NumberField({ name: "prix_m2_median", required: false }));
col.fields.add(new NumberField({ name: "nb_ventes", required: false }));
col.fields.add(
new SelectField({
name: "source",
required: true,
maxSelect: 1,
values: ["manuel", "dvf_import", "api_tiers"],
}),
);
col.fields.add(new TextField({ name: "detail_json", required: false }));
addUserRules(col);
return col;
});
loadOrCreate("courriers_immobilier", () => {
const col = new Collection({ name: "courriers_immobilier", type: "base" });
col.fields.add(userRel());
col.fields.add(new TextField({ name: "titre", required: true }));
col.fields.add(new TextField({ name: "corps", required: false }));
col.fields.add(
new SelectField({
name: "kind",
required: true,
maxSelect: 1,
values: ["prospection", "annonce_agence", "relance"],
}),
);
col.fields.add(
new SelectField({
name: "etat",
required: true,
maxSelect: 1,
values: ["brouillon", "pret"],
}),
);
addUserRules(col);
return col;
});
},
(app) => {
const names = [
"alertes_recherche",
"annonces_veille",
"flux_sources",
"transactions_secteur",
"courriers_immobilier",
"recherches_sauvegardees",
];
for (const name of names) {
try {
app.delete(app.findCollectionByNameOrId(name));
} catch (_) {}
}
},
);