Compare commits
2 Commits
432f8ce176
...
360522f30a
| Author | SHA1 | Date | |
|---|---|---|---|
| 360522f30a | |||
| 2b8741de08 |
@ -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
|
||||
|
||||
28
AGENTS.md
28
AGENTS.md
@ -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 d’ingestion de flux externes ni de moteur d’alertes ; la grille / secteur restent des données **saisies ou locales** (PocketBase), pas une veille marché temps réel.
|
||||
|
||||
### Personas agents (rôles métier + briques techniques)
|
||||
|
||||
| Agent | Mission | Briques typiques |
|
||||
|-------|---------|------------------|
|
||||
| **Immobilier** | Off-market, diffusion agence (priorité site maison), prospection pour alimenter le pipe | Collections opportunités / contacts / tâches ; hooks ou jobs pour brouillons de contenu ; pas de scraping illégal — privilégier saisie, imports CSV, API partenaires. |
|
||||
| **Marchand de biens** | Prix secteur, €/m², repérage bonnes offres | Grille perso + DVF / transactions (open data) ; scoring simple ; alertes sur critères (prix/m², surface, zone). |
|
||||
| **Data / DVF** | Normaliser transactions publiques, relier zone ↔ bien | Import DVF (fichiers ou API tiers), tables dérivées, carto plus tard. |
|
||||
| **Veille annonces** | Agréger sources autorisées (API, flux partenaires, [API MoteurImmo](https://moteurimmo.fr/) si abonnement) | Collections `sources_flux`, `annonces_brutes`, `alertes_recherche` ; cron PocketBase ou worker externe ; dédoublonnage. |
|
||||
| **Alertes & notif** | Push / email quand une annonce ou une transac matche une recherche sauvegardée | Règles métier + Expo notifications ; file d’événements côté PB. |
|
||||
| **Rédaction / CRM** | Textes vitrine, relances, synthèses pour prospection | Réutiliser le pattern hook IA (`generate_rapport`) par type de prompt. |
|
||||
|
||||
### Phases suggérées
|
||||
|
||||
1. **Modèle de données** : recherches sauvegardées, alertes, log d’ingestion (sans agrégateur massif au début).
|
||||
2. **Données publiques** : DVF ou extrait local par zone (preuve de valeur pour €/m² réel).
|
||||
3. **Une source API fiable** (partenaire ou open data) avant tout volume type MoteurImmo.
|
||||
4. **UI** : liste annonces unifiée + filtres + onglet alertes dans Recherche.
|
||||
|
||||
## Infos techniques
|
||||
- PocketBase : http://localhost:8090
|
||||
- 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+
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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’à l’acte."
|
||||
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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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 d’urgent"
|
||||
description="Les tâches en retard ou marquées urgentes apparaîtront ici."
|
||||
actionLabel="Voir l’agenda"
|
||||
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 aujourd’hui.</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 aujourd’hui dans l’agenda."
|
||||
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>
|
||||
</>
|
||||
);
|
||||
|
||||
46
app/app/(tabs)/recherche.tsx
Normal file
46
app/app/(tabs)/recherche.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
|
||||
@ -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'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)} />
|
||||
</>
|
||||
|
||||
329
app/components/recherche/GrillePrixTab.tsx
Normal file
329
app/components/recherche/GrillePrixTab.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
284
app/components/recherche/OpportunitesTab.tsx
Normal file
284
app/components/recherche/OpportunitesTab.tsx
Normal 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'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>
|
||||
);
|
||||
}
|
||||
168
app/components/recherche/SecteurTab.tsx
Normal file
168
app/components/recherche/SecteurTab.tsx
Normal 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 d’ouvrir ce lien sur cet appareil.');
|
||||
return;
|
||||
}
|
||||
void Linking.openURL(url);
|
||||
};
|
||||
|
||||
const onAnalyser = () => {
|
||||
const v = ville.trim();
|
||||
if (!v) {
|
||||
Alert.alert('Ville', 'Indique une ville ou une commune pour cadrer l’analyse.');
|
||||
return;
|
||||
}
|
||||
Keyboard.dismiss();
|
||||
Alert.alert(
|
||||
'Secteur',
|
||||
`Analyse pour « ${v} » : utilise les outils ci-dessous (données externes), puis consigne tes notes en bas de page.`,
|
||||
);
|
||||
};
|
||||
|
||||
const onSaveNotes = async () => {
|
||||
const v = ville.trim();
|
||||
if (!v) {
|
||||
Alert.alert('Ville', 'Renseigne la ville avant de sauvegarder les notes.');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await saveMut.mutateAsync({ ville: v, notes });
|
||||
} catch (e) {
|
||||
Alert.alert('Erreur', formatPocketBaseError(e));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
433
app/components/recherche/VeilleAgentsTab.tsx
Normal file
433
app/components/recherche/VeilleAgentsTab.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
34
app/components/ui/DashboardSkeleton.tsx
Normal file
34
app/components/ui/DashboardSkeleton.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
57
app/components/ui/EmptyState.tsx
Normal file
57
app/components/ui/EmptyState.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
39
app/components/ui/ListSkeleton.tsx
Normal file
39
app/components/ui/ListSkeleton.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
37
app/components/ui/PipelineSkeleton.tsx
Normal file
37
app/components/ui/PipelineSkeleton.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
103
app/constants/rechercheMarche.ts
Normal file
103
app/constants/rechercheMarche.ts
Normal 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 ? C’est rapide à vendre ?' },
|
||||
{ id: 'notaire', label: 'Notaires', question: 'Quelles tendances dans vos actes récents ?' },
|
||||
{ id: 'geometre', label: 'Géomètres', question: 'Divisions fréquentes sur ce secteur ?' },
|
||||
{ id: 'banquier', label: 'Banquiers', question: 'Vous financez souvent des projets ici ?' },
|
||||
{ id: 'mdb', label: 'Autres MDB', question: 'Tu trouves facilement sur ce secteur ?' },
|
||||
{ id: 'artisan', label: 'Artisans', question: 'Vous travaillez beaucoup dans ce quartier ?' },
|
||||
];
|
||||
|
||||
export const CATEGORIE_NOTES_PROSPECTION = 'recherche_opportunites';
|
||||
|
||||
export const LEGI_L151_36 =
|
||||
'https://www.legifrance.gouv.fr/codes/article_lc/LEGIARTI000031211239';
|
||||
export const LEGI_L152_6 =
|
||||
'https://www.legifrance.gouv.fr/codes/article_lc/LEGIARTI000043978020/2021-08-25';
|
||||
|
||||
export function meilleursAgentsUrlForVille(ville: string): string {
|
||||
const v = ville.trim();
|
||||
if (!v) return SECTOR_TOOLS[0].url;
|
||||
return `https://www.meilleursagents.com/prix-immobilier/${encodeURIComponent(v)}/`;
|
||||
}
|
||||
|
||||
export function dvfSearchUrl(ville: string): string {
|
||||
const v = ville.trim();
|
||||
if (!v) return 'https://dvf.etalab.gouv.fr/';
|
||||
return `https://dvf.etalab.gouv.fr/?q=${encodeURIComponent(v)}`;
|
||||
}
|
||||
|
||||
export function geoportailBienUrl(lat: number, lon: number): string {
|
||||
return `https://www.geoportail.gouv.fr/?lon=${lon}&lat=${lat}&z=17`;
|
||||
}
|
||||
|
||||
export function marginPctFromPrices(achat: number, revente: number): number | null {
|
||||
if (!Number.isFinite(achat) || !Number.isFinite(revente) || achat <= 0) return null;
|
||||
return ((revente - achat) / achat) * 100;
|
||||
}
|
||||
12
app/constants/uiTheme.ts
Normal file
12
app/constants/uiTheme.ts
Normal 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;
|
||||
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
60
app/hooks/useAnalysesSecteur.ts
Normal file
60
app/hooks/useAnalysesSecteur.ts
Normal 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] });
|
||||
},
|
||||
});
|
||||
}
|
||||
79
app/hooks/useGrillePrix.ts
Normal file
79
app/hooks/useGrillePrix.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
90
app/hooks/useNotesProspectionRecherche.ts
Normal file
90
app/hooks/useNotesProspectionRecherche.ts
Normal 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
12
app/package-lock.json
generated
@ -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",
|
||||
|
||||
@ -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
66
app/services/agentsApi.ts
Normal 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: {} });
|
||||
}
|
||||
@ -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 & {
|
||||
|
||||
@ -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:
|
||||
|
||||
333
pocketbase/pb_hooks/agents_veille.pb.js
Normal file
333
pocketbase/pb_hooks/agents_veille.pb.js
Normal 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). */
|
||||
});
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
176
pocketbase/pb_migrations/1746100000_module_recherche.js
Normal file
176
pocketbase/pb_migrations/1746100000_module_recherche.js
Normal 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 (_) {}
|
||||
}
|
||||
},
|
||||
);
|
||||
@ -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) {
|
||||
|
||||
224
pocketbase/pb_migrations/1760000000_agents_veille_collections.js
Normal file
224
pocketbase/pb_migrations/1760000000_agents_veille_collections.js
Normal 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 (_) {}
|
||||
}
|
||||
},
|
||||
);
|
||||
Reference in New Issue
Block a user