434 lines
15 KiB
TypeScript
434 lines
15 KiB
TypeScript
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>
|
|
);
|
|
}
|