recherche

This commit is contained in:
Bastien COIGNOUX
2026-05-04 22:11:46 +02:00
parent 2b8741de08
commit 360522f30a
10 changed files with 1137 additions and 4 deletions

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