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)
|
## Collections PocketBase (toutes créées via migration)
|
||||||
etapes_pipeline, contacts, biens, analyses_financieres,
|
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
|
## Règles de code
|
||||||
- TypeScript strict, jamais de any
|
- 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 Visites + IA (`pb_hooks/generate_rapport.pb.js`, route `POST /api/mdb/generate-rapport`)
|
||||||
- [x] Module Agenda (tâches, snooze, création modal)
|
- [x] Module Agenda (tâches, snooze, création modal)
|
||||||
- [x] Dashboard (alertes, KPIs, pipeline, derniers biens, tâches du jour)
|
- [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
|
## Infos techniques
|
||||||
- PocketBase : http://localhost:8090
|
- PocketBase : http://localhost:8090
|
||||||
- Admin : http://localhost:8090/_/ (admin@mdb.fr)
|
- Admin : http://localhost:8090/_/ (admin@mdb.fr)
|
||||||
- Binaire : /usr/local/bin/pocketbase
|
- Binaire : /usr/local/bin/pocketbase
|
||||||
- Données : /pb_data
|
- 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)
|
- Variable IA : `ANTHROPIC_API_KEY` dans `.env.local` (chargée par Docker pour PocketBase)
|
||||||
- OS : Windows Git Bash (MSYS_NO_PATHCONV=1)
|
- OS : Windows Git Bash (MSYS_NO_PATHCONV=1)
|
||||||
- PocketBase : v0.23+
|
- PocketBase : v0.23+
|
||||||
|
|||||||
@ -45,6 +45,13 @@ export default function TabsLayout() {
|
|||||||
tabBarIcon: ({ color, size }) => <Ionicons name="list-outline" size={size} color={color} />,
|
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>
|
</Tabs>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -12,6 +12,9 @@ import {
|
|||||||
View,
|
View,
|
||||||
} from 'react-native';
|
} 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 { useBiens } from '@/hooks/useBiens';
|
||||||
import { useTachesList } from '@/hooks/useTaches';
|
import { useTachesList } from '@/hooks/useTaches';
|
||||||
import type { TacheExpanded } from '@/types/collections';
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<Stack.Screen options={{ title: 'Agenda', headerShown: true }} />
|
<Stack.Screen options={{ title: 'Agenda', headerShown: true }} />
|
||||||
<View className="flex-1 bg-slate-50">
|
<View className="flex-1" style={{ backgroundColor: UI.screen }}>
|
||||||
{error ? (
|
{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}
|
) : null}
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<View className="flex-1 items-center justify-center">
|
<ListSkeleton rows={6} />
|
||||||
<ActivityIndicator color="#1D4ED8" />
|
|
||||||
</View>
|
|
||||||
) : (
|
) : (
|
||||||
<SectionList
|
<SectionList
|
||||||
sections={sections}
|
sections={sections}
|
||||||
keyExtractor={(item) => item.id}
|
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 }) => (
|
renderSectionHeader={({ section }) => (
|
||||||
<Text
|
<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}
|
{section.title}
|
||||||
</Text>
|
</Text>
|
||||||
@ -118,85 +139,138 @@ export default function AgendaTab() {
|
|||||||
const done = t.statut === 'fait';
|
const done = t.statut === 'fait';
|
||||||
const badge = bienLabel(t);
|
const badge = bienLabel(t);
|
||||||
return (
|
return (
|
||||||
<View className="mb-2 rounded-xl border border-slate-200 bg-white p-3">
|
<View
|
||||||
<View className="flex-row items-start gap-3">
|
className="mb-3 rounded-2xl border-2 bg-white p-4"
|
||||||
<Pressable
|
style={{ borderColor: UI.border }}
|
||||||
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'}`}
|
|
||||||
>
|
>
|
||||||
{done ? <Text className="text-xs font-bold text-white">✓</Text> : null}
|
<View className="flex-row items-start gap-4">
|
||||||
|
<Pressable
|
||||||
|
accessibilityRole="checkbox"
|
||||||
|
accessibilityState={{ checked: done }}
|
||||||
|
onPress={() => void toggleDone(t)}
|
||||||
|
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-xl font-bold text-white">✓</Text>
|
||||||
|
) : null}
|
||||||
</Pressable>
|
</Pressable>
|
||||||
<View className="min-w-0 flex-1">
|
<View className="min-w-0 flex-1">
|
||||||
<Text
|
<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}
|
{t.titre}
|
||||||
</Text>
|
</Text>
|
||||||
{badge ? (
|
{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}
|
) : null}
|
||||||
{t.date_echeance ? (
|
{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}
|
) : null}
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
<View className="mt-3 flex-row flex-wrap gap-2">
|
<View className="mt-4 flex-row flex-wrap gap-3">
|
||||||
<Pressable
|
<Pressable
|
||||||
|
accessibilityRole="button"
|
||||||
onPress={() => void snoozeOneDay(t)}
|
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>
|
||||||
<Pressable
|
<Pressable
|
||||||
|
accessibilityRole="button"
|
||||||
onPress={() => confirmDelete(t)}
|
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>
|
</Pressable>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
ListEmptyComponent={
|
ListEmptyComponent={emptyList}
|
||||||
<Text className="py-8 text-center text-slate-600">Aucune tâche à afficher.</Text>
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Pressable
|
<Pressable
|
||||||
|
accessibilityRole="button"
|
||||||
|
accessibilityLabel="Nouvelle tâche"
|
||||||
onPress={openCreate}
|
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>
|
</Pressable>
|
||||||
|
|
||||||
<Modal visible={modalOpen} animationType="slide" transparent>
|
<Modal visible={modalOpen} animationType="slide" transparent>
|
||||||
<View className="flex-1 justify-end bg-black/40">
|
<View className="flex-1 justify-end bg-black/50">
|
||||||
<View className="max-h-[85%] rounded-t-2xl bg-white p-4">
|
<View className="max-h-[88%] rounded-t-3xl bg-white p-5">
|
||||||
<Text className="text-lg font-bold text-slate-900">Nouvelle tâche</Text>
|
<Text className="text-2xl font-bold" style={{ color: UI.text }}>
|
||||||
<Text className="mt-3 text-sm text-slate-500">Titre</Text>
|
Nouvelle tâche
|
||||||
|
</Text>
|
||||||
|
<Text className="mt-4 text-base font-semibold" style={{ color: UI.textMuted }}>
|
||||||
|
Titre
|
||||||
|
</Text>
|
||||||
<TextInput
|
<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}
|
value={newTitre}
|
||||||
onChangeText={setNewTitre}
|
onChangeText={setNewTitre}
|
||||||
placeholder="Appeler le notaire…"
|
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
|
<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}
|
value={newDate}
|
||||||
onChangeText={setNewDate}
|
onChangeText={setNewDate}
|
||||||
placeholder="2026-04-29"
|
placeholder="2026-04-29"
|
||||||
placeholderTextColor="#94a3b8"
|
placeholderTextColor={UI.textMuted}
|
||||||
/>
|
/>
|
||||||
<Text className="mt-4 text-sm text-slate-500">Bien (optionnel)</Text>
|
<Text className="mt-4 text-base font-semibold" style={{ color: UI.textMuted }}>
|
||||||
<ScrollView horizontal className="mt-2" keyboardShouldPersistTaps="handled">
|
Bien (optionnel)
|
||||||
|
</Text>
|
||||||
|
<ScrollView horizontal className="mt-2 max-h-28" keyboardShouldPersistTaps="handled">
|
||||||
<Pressable
|
<Pressable
|
||||||
onPress={() => setNewBienId(undefined)}
|
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-base font-bold ${newBienId == null ? 'text-white' : ''}`}
|
||||||
|
style={newBienId == null ? undefined : { color: UI.text }}
|
||||||
>
|
>
|
||||||
<Text className={`text-sm ${newBienId == null ? 'text-white' : 'text-slate-800'}`}>
|
|
||||||
Aucun
|
Aucun
|
||||||
</Text>
|
</Text>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
@ -204,33 +278,38 @@ export default function AgendaTab() {
|
|||||||
<Pressable
|
<Pressable
|
||||||
key={b.id}
|
key={b.id}
|
||||||
onPress={() => setNewBienId(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={{
|
||||||
<Text
|
borderColor: newBienId === b.id ? UI.primary : UI.border,
|
||||||
numberOfLines={1}
|
backgroundColor: newBienId === b.id ? '#EFF6FF' : UI.card,
|
||||||
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}
|
{b.titre?.trim() || b.ville || b.id}
|
||||||
</Text>
|
</Text>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
))}
|
))}
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
<View className="mt-6 flex-row gap-3">
|
<View className="mt-8 flex-row gap-3">
|
||||||
<Pressable
|
<Pressable
|
||||||
onPress={() => setModalOpen(false)}
|
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>
|
||||||
<Pressable
|
<Pressable
|
||||||
onPress={() => void submitCreate()}
|
onPress={() => void submitCreate()}
|
||||||
disabled={isCreatePending}
|
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 ? (
|
{isCreatePending ? (
|
||||||
<ActivityIndicator color="#fff" />
|
<ActivityIndicator color="#fff" />
|
||||||
) : (
|
) : (
|
||||||
<Text className="font-semibold text-white">Créer</Text>
|
<Text className="text-lg font-bold text-white">Créer</Text>
|
||||||
)}
|
)}
|
||||||
</Pressable>
|
</Pressable>
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
@ -1,7 +1,10 @@
|
|||||||
import { Link, Stack } from 'expo-router';
|
import { Link, Stack } from 'expo-router';
|
||||||
import { useEffect, useMemo, useRef } from 'react';
|
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 { useBiens, type BienExpanded } from '@/hooks/useBiens';
|
||||||
import { useEtapes } from '@/hooks/useEtapes';
|
import { useEtapes } from '@/hooks/useEtapes';
|
||||||
import { formatEUR } from '@/utils/format';
|
import { formatEUR } from '@/utils/format';
|
||||||
@ -48,44 +51,89 @@ export default function BiensScreen() {
|
|||||||
? formatPocketBaseError(etapesInitMutationError)
|
? formatPocketBaseError(etapesInitMutationError)
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
|
const loading = isLoading || etapesLoading;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Stack.Screen options={{ title: 'Biens', headerShown: true }} />
|
<Stack.Screen options={{ title: 'Biens', headerShown: true }} />
|
||||||
<View className="flex-1 bg-slate-50">
|
<View className="flex-1" style={{ backgroundColor: UI.screen }}>
|
||||||
{banner ? (
|
{banner ? (
|
||||||
<View className="border-b border-red-200 bg-red-50 px-3 py-2">
|
<View
|
||||||
<Text className="text-sm text-red-900">{banner}</Text>
|
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>
|
</View>
|
||||||
) : null}
|
) : null}
|
||||||
{isLoading || etapesLoading ? (
|
{loading ? (
|
||||||
<View className="flex-1 items-center justify-center">
|
<PipelineSkeleton />
|
||||||
<ActivityIndicator size="large" color="#1D4ED8" />
|
) : 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>
|
</View>
|
||||||
) : (
|
) : (
|
||||||
<ScrollView horizontal className="flex-1" contentContainerStyle={{ padding: 12, paddingBottom: 96 }}>
|
<ScrollView
|
||||||
|
horizontal
|
||||||
|
className="flex-1"
|
||||||
|
contentContainerStyle={{ padding: 12, paddingBottom: 112 }}
|
||||||
|
>
|
||||||
{etapes.map((e) => {
|
{etapes.map((e) => {
|
||||||
const list = grouped.m.get(e.id) ?? [];
|
const list = grouped.m.get(e.id) ?? [];
|
||||||
return (
|
return (
|
||||||
<View key={e.id} className="mr-3 w-56 rounded-xl border border-slate-200 bg-white p-2">
|
<View
|
||||||
<View className="mb-2 flex-row items-center justify-between border-b border-slate-100 pb-2">
|
key={e.id}
|
||||||
<Text className="flex-1 font-bold text-slate-900" numberOfLines={2}>
|
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}
|
{e.nom}
|
||||||
</Text>
|
</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>
|
</View>
|
||||||
<ScrollView nestedScrollEnabled>
|
<ScrollView nestedScrollEnabled showsVerticalScrollIndicator={false}>
|
||||||
{list.map((b) => (
|
{list.map((b) => (
|
||||||
<Link key={b.id} href={`/bien/${b.id}`} asChild>
|
<Link key={b.id} href={`/bien/${b.id}`} asChild>
|
||||||
<Pressable className="mb-2 rounded-lg border border-slate-100 bg-slate-50 p-2">
|
<Pressable
|
||||||
<Text className="font-medium text-slate-900" numberOfLines={2}>
|
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'}
|
{b.titre ?? 'Sans titre'}
|
||||||
</Text>
|
</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(' ') || '—'}
|
{[b.code_postal, b.ville].filter(Boolean).join(' ') || '—'}
|
||||||
</Text>
|
</Text>
|
||||||
{prixByBien.has(b.id) ? (
|
{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))}
|
{formatEUR(prixByBien.get(b.id))}
|
||||||
</Text>
|
</Text>
|
||||||
) : null}
|
) : null}
|
||||||
@ -96,16 +144,29 @@ export default function BiensScreen() {
|
|||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
<View className="mr-3 w-56 rounded-xl border border-dashed border-slate-300 bg-slate-100/80 p-2">
|
<View
|
||||||
<Text className="mb-2 font-bold text-slate-700">Sans étape</Text>
|
className="mr-3 w-56 rounded-2xl border-2 border-dashed p-3"
|
||||||
<Text className="mb-2 text-xs text-slate-500">
|
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)
|
{(grouped.m.get(grouped.none) ?? []).length} bien(s)
|
||||||
</Text>
|
</Text>
|
||||||
<ScrollView nestedScrollEnabled>
|
<ScrollView nestedScrollEnabled showsVerticalScrollIndicator={false}>
|
||||||
{(grouped.m.get(grouped.none) ?? []).map((b) => (
|
{(grouped.m.get(grouped.none) ?? []).map((b) => (
|
||||||
<Link key={b.id} href={`/bien/${b.id}`} asChild>
|
<Link key={b.id} href={`/bien/${b.id}`} asChild>
|
||||||
<Pressable className="mb-2 rounded-lg border border-slate-200 bg-white p-2">
|
<Pressable
|
||||||
<Text className="font-medium text-slate-900" numberOfLines={2}>
|
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'}
|
{b.titre ?? 'Sans titre'}
|
||||||
</Text>
|
</Text>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
@ -117,10 +178,12 @@ export default function BiensScreen() {
|
|||||||
)}
|
)}
|
||||||
<Link href="/bien/nouveau" asChild>
|
<Link href="/bien/nouveau" asChild>
|
||||||
<Pressable
|
<Pressable
|
||||||
className="absolute bottom-6 right-5 h-14 w-14 items-center justify-center rounded-full bg-blue-700 shadow-md"
|
accessibilityRole="button"
|
||||||
style={{ elevation: 6 }}
|
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>
|
</Pressable>
|
||||||
</Link>
|
</Link>
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
@ -1,16 +1,11 @@
|
|||||||
import { useMemo, useState } from 'react';
|
import { useMemo, useState } from 'react';
|
||||||
import { Link, Stack } from 'expo-router';
|
import { Link, Stack } from 'expo-router';
|
||||||
import {
|
import { Linking, Pressable, SectionList, Text, TextInput, View } from 'react-native';
|
||||||
ActivityIndicator,
|
|
||||||
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 { labelContactCategorie } from '@/constants/contactCategories';
|
||||||
|
import { UI } from '@/constants/uiTheme';
|
||||||
import { useContactsList } from '@/hooks/useContacts';
|
import { useContactsList } from '@/hooks/useContacts';
|
||||||
import type { ContactRecord } from '@/types/collections';
|
import type { ContactRecord } from '@/types/collections';
|
||||||
import { formatPocketBaseError } from '@/utils/pocketbaseErrors';
|
import { formatPocketBaseError } from '@/utils/pocketbaseErrors';
|
||||||
@ -51,59 +46,114 @@ export default function ContactsTab() {
|
|||||||
.sort((a, b) => a.title.localeCompare(b.title, 'fr'));
|
.sort((a, b) => a.title.localeCompare(b.title, 'fr'));
|
||||||
}, [q.data, search]);
|
}, [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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<Stack.Screen options={{ title: 'Contacts', headerShown: true }} />
|
<Stack.Screen options={{ title: 'Contacts', headerShown: true }} />
|
||||||
<View className="flex-1 bg-slate-50">
|
<View className="flex-1" style={{ backgroundColor: UI.screen }}>
|
||||||
{q.error ? (
|
{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}
|
) : null}
|
||||||
<TextInput
|
<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…"
|
placeholder="Rechercher…"
|
||||||
placeholderTextColor="#94a3b8"
|
placeholderTextColor={UI.textMuted}
|
||||||
value={search}
|
value={search}
|
||||||
onChangeText={setSearch}
|
onChangeText={setSearch}
|
||||||
|
accessibilityLabel="Recherche contacts"
|
||||||
/>
|
/>
|
||||||
{q.isPending ? (
|
{q.isPending ? (
|
||||||
<View className="flex-1 items-center justify-center">
|
<ListSkeleton rows={7} />
|
||||||
<ActivityIndicator color="#1D4ED8" />
|
|
||||||
</View>
|
|
||||||
) : (
|
) : (
|
||||||
<SectionList
|
<SectionList
|
||||||
className="flex-1 px-3 pt-2"
|
className="flex-1 px-3 pt-2"
|
||||||
sections={sections}
|
sections={sections}
|
||||||
keyExtractor={(item) => item.id}
|
keyExtractor={(item) => item.id}
|
||||||
contentContainerStyle={{ paddingBottom: 88 }}
|
contentContainerStyle={
|
||||||
|
sections.length === 0 ? { flexGrow: 1, paddingBottom: 100 } : { paddingBottom: 100 }
|
||||||
|
}
|
||||||
renderSectionHeader={({ section: { title } }) => (
|
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 }) => (
|
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>
|
<Link href={`/contact/${c.id}`} asChild>
|
||||||
<Pressable>
|
<Pressable accessibilityRole="button" className="active:opacity-90">
|
||||||
<Text className="font-semibold text-slate-900">
|
<Text className="text-xl font-bold" style={{ color: UI.text }}>
|
||||||
{c.prenom ? `${c.prenom} ` : ''}
|
{c.prenom ? `${c.prenom} ` : ''}
|
||||||
{c.nom}
|
{c.nom}
|
||||||
</Text>
|
</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>
|
</Pressable>
|
||||||
</Link>
|
</Link>
|
||||||
{c.telephone ? (
|
{c.telephone ? (
|
||||||
<Pressable onPress={() => openTel(c.telephone)} className="mt-2 self-start">
|
<Pressable
|
||||||
<Text className="text-sm text-blue-700">{c.telephone}</Text>
|
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>
|
</Pressable>
|
||||||
) : null}
|
) : null}
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
ListEmptyComponent={
|
ListEmptyComponent={listEmpty}
|
||||||
<Text className="py-6 text-center text-slate-600">Aucun contact.</Text>
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<Link href="/contact/nouveau" asChild>
|
<Link href="/contact/nouveau" asChild>
|
||||||
<Pressable className="absolute bottom-6 right-5 rounded-full bg-blue-700 px-4 py-3">
|
<Pressable
|
||||||
<Text className="font-semibold text-white">+ Contact</Text>
|
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>
|
</Pressable>
|
||||||
</Link>
|
</Link>
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
@ -1,12 +1,15 @@
|
|||||||
import { useMemo } from 'react';
|
import { useMemo } from 'react';
|
||||||
import { Link, Stack } from 'expo-router';
|
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 { useBiens } from '@/hooks/useBiens';
|
||||||
|
import type { BienExpanded } from '@/hooks/useBiens';
|
||||||
import { useEtapes } from '@/hooks/useEtapes';
|
import { useEtapes } from '@/hooks/useEtapes';
|
||||||
import { useTachesList } from '@/hooks/useTaches';
|
import { useTachesList } from '@/hooks/useTaches';
|
||||||
import { TYPES_BIENS } from '@/constants/metier';
|
|
||||||
import type { BienExpanded } from '@/hooks/useBiens';
|
|
||||||
import {
|
import {
|
||||||
isTaskActive,
|
isTaskActive,
|
||||||
parsePbDateOnly,
|
parsePbDateOnly,
|
||||||
@ -57,31 +60,65 @@ export default function DashboardScreen() {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Stack.Screen options={{ title: 'Dashboard', headerShown: true }} />
|
<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 ? (
|
{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}
|
) : null}
|
||||||
{tachesError ? (
|
{tachesError ? (
|
||||||
<Text className="mb-2 text-red-700">{formatPocketBaseError(tachesError)}</Text>
|
<View
|
||||||
) : null}
|
className="mb-3 rounded-2xl border-2 px-4 py-3"
|
||||||
|
style={{ borderColor: UI.danger, backgroundColor: '#FEE2E2' }}
|
||||||
{loading ? (
|
>
|
||||||
<View className="py-8">
|
<Text className="text-base font-medium" style={{ color: '#991B1B' }}>
|
||||||
<ActivityIndicator color="#1D4ED8" />
|
{formatPocketBaseError(tachesError)}
|
||||||
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<Text className="mb-2 text-lg font-bold text-slate-900">Alertes urgentes</Text>
|
{loading ? (
|
||||||
{urgent.length === 0 ? (
|
<DashboardSkeleton />
|
||||||
<Text className="mb-6 text-slate-600">Aucune alerte.</Text>
|
|
||||||
) : (
|
) : (
|
||||||
<View className="mb-6 gap-2">
|
<>
|
||||||
|
<Text className="mb-3 text-2xl font-bold" style={{ color: UI.text }}>
|
||||||
|
Alertes urgentes
|
||||||
|
</Text>
|
||||||
|
{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) => (
|
{urgent.slice(0, 6).map((t) => (
|
||||||
<Link key={t.id} href="/(tabs)/agenda" asChild>
|
<Link key={t.id} href="/(tabs)/agenda" asChild>
|
||||||
<Pressable className="rounded-xl border border-red-200 bg-red-50 px-3 py-2">
|
<Pressable
|
||||||
<Text className="font-medium text-red-900">{t.titre}</Text>
|
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 ? (
|
{t.date_echeance ? (
|
||||||
<Text className="text-xs text-red-700">{t.date_echeance}</Text>
|
<Text className="mt-1 text-base" style={{ color: UI.danger }}>
|
||||||
|
{t.date_echeance}
|
||||||
|
</Text>
|
||||||
) : null}
|
) : null}
|
||||||
</Pressable>
|
</Pressable>
|
||||||
</Link>
|
</Link>
|
||||||
@ -89,46 +126,79 @@ export default function DashboardScreen() {
|
|||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Text className="mb-2 text-lg font-bold text-slate-900">Indicateurs</Text>
|
<Text className="mb-3 text-2xl font-bold" style={{ color: UI.text }}>
|
||||||
<View className="mb-6 flex-row flex-wrap gap-3">
|
Indicateurs
|
||||||
<View className="min-w-[100px] flex-1 rounded-xl border border-slate-200 bg-white p-3">
|
</Text>
|
||||||
<Text className="text-2xl font-bold text-slate-900">{biens.length}</Text>
|
<View className="mb-8 flex-row flex-wrap gap-3">
|
||||||
<Text className="text-xs text-slate-500">Biens</Text>
|
<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>
|
</View>
|
||||||
<View className="min-w-[100px] flex-1 rounded-xl border border-slate-200 bg-white p-3">
|
<View
|
||||||
<Text className="text-2xl font-bold text-slate-900">{actifs.length}</Text>
|
className="min-w-[108px] flex-1 rounded-2xl border bg-white p-4"
|
||||||
<Text className="text-xs text-slate-500">Actifs</Text>
|
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>
|
||||||
<View className="min-w-[100px] flex-1 rounded-xl border border-slate-200 bg-white p-3">
|
<View
|
||||||
<Text className="text-2xl font-bold text-slate-900">
|
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}
|
{taches.filter(isTaskActive).length}
|
||||||
</Text>
|
</Text>
|
||||||
<Text className="text-xs text-slate-500">Tâches ouvertes</Text>
|
<Text className="mt-1 text-base font-medium" style={{ color: UI.textMuted }}>
|
||||||
|
Tâches ouvertes
|
||||||
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<Text className="mb-2 text-lg font-bold text-slate-900">Pipeline</Text>
|
<View className="mb-3 flex-row items-center justify-between">
|
||||||
<ScrollView horizontal showsHorizontalScrollIndicator={false} className="mb-6">
|
<Text className="text-2xl font-bold" style={{ color: UI.text }}>
|
||||||
<View className="flex-row gap-2 pb-1">
|
Pipeline
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<ScrollView horizontal showsHorizontalScrollIndicator={false} className="mb-8">
|
||||||
|
<View className="flex-row gap-3 pb-1">
|
||||||
{etapes.map((e) => {
|
{etapes.map((e) => {
|
||||||
const n = etapeCounts.get(e.id) ?? 0;
|
const n = etapeCounts.get(e.id) ?? 0;
|
||||||
return (
|
return (
|
||||||
<View
|
<View
|
||||||
key={e.id}
|
key={e.id}
|
||||||
className="min-w-[120px] rounded-xl border border-slate-200 bg-white px-3 py-2"
|
className="min-w-[132px] rounded-2xl border-2 bg-white px-4 py-4"
|
||||||
|
style={{ borderColor: UI.border }}
|
||||||
>
|
>
|
||||||
<View className="mb-1 h-1 rounded-full" style={{ backgroundColor: e.couleur }} />
|
<View className="mb-2 h-1.5 rounded-full" style={{ backgroundColor: e.couleur }} />
|
||||||
<Text numberOfLines={2} className="text-xs font-semibold text-slate-800">
|
<Text numberOfLines={2} className="text-base font-bold" style={{ color: UI.text }}>
|
||||||
{e.nom}
|
{e.nom}
|
||||||
</Text>
|
</Text>
|
||||||
<Text className="mt-1 text-lg font-bold text-slate-900">{n}</Text>
|
<Text className="mt-2 text-2xl font-bold" style={{ color: UI.primary }}>
|
||||||
|
{n}
|
||||||
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
{(etapeCounts.get('') ?? 0) > 0 ? (
|
{(etapeCounts.get('') ?? 0) > 0 ? (
|
||||||
<View className="min-w-[120px] rounded-xl border border-dashed border-slate-300 bg-white px-3 py-2">
|
<View
|
||||||
<Text className="text-xs font-semibold text-slate-600">Sans étape</Text>
|
className="min-w-[132px] rounded-2xl border-2 border-dashed bg-white px-4 py-4"
|
||||||
<Text className="mt-1 text-lg font-bold text-slate-900">
|
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}
|
{etapeCounts.get('') ?? 0}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
@ -136,57 +206,127 @@ export default function DashboardScreen() {
|
|||||||
</View>
|
</View>
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
|
|
||||||
<View className="mb-2 flex-row items-center justify-between">
|
<View className="mb-3 flex-row items-center justify-between">
|
||||||
<Text className="text-lg font-bold text-slate-900">Derniers biens</Text>
|
<Text className="text-2xl font-bold" style={{ color: UI.text }}>
|
||||||
<Link href="/(tabs)/biens" className="text-sm font-semibold text-blue-700">
|
Derniers biens
|
||||||
Voir tout
|
</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>
|
</Link>
|
||||||
</View>
|
</View>
|
||||||
<View className="mb-6 gap-2">
|
|
||||||
{derniers.length === 0 ? (
|
{derniers.length === 0 ? (
|
||||||
<Text className="text-slate-600">Aucun bien.</Text>
|
<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>
|
||||||
) : (
|
) : (
|
||||||
derniers.map((b) => (
|
<View className="mb-8 gap-3">
|
||||||
|
{derniers.map((b) => (
|
||||||
<Link key={b.id} href={`/bien/${b.id}`} asChild>
|
<Link key={b.id} href={`/bien/${b.id}`} asChild>
|
||||||
<Pressable className="rounded-xl border border-slate-200 bg-white px-3 py-3">
|
<Pressable
|
||||||
<Text className="font-semibold text-slate-900">{bienTitre(b)}</Text>
|
accessibilityRole="button"
|
||||||
<Text className="text-xs text-slate-500">
|
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 ?? ''}
|
{b.expand?.etape?.nom ?? '—'} · {b.ville ?? ''}
|
||||||
</Text>
|
</Text>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
</Link>
|
</Link>
|
||||||
))
|
))}
|
||||||
)}
|
|
||||||
</View>
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
<View className="mb-2 flex-row items-center justify-between">
|
<View className="mb-3 flex-row items-center justify-between">
|
||||||
<Text className="text-lg font-bold text-slate-900">Tâches du jour</Text>
|
<Text className="text-2xl font-bold" style={{ color: UI.text }}>
|
||||||
<Link href="/(tabs)/agenda" className="text-sm font-semibold text-blue-700">
|
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
|
Agenda
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
</Link>
|
</Link>
|
||||||
</View>
|
</View>
|
||||||
<View className="gap-2">
|
|
||||||
{part.today.length === 0 ? (
|
{part.today.length === 0 ? (
|
||||||
<Text className="text-slate-600">Rien de prévu aujourd’hui.</Text>
|
<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>
|
||||||
) : (
|
) : (
|
||||||
part.today.map((t) => (
|
<View className="mb-8 gap-3">
|
||||||
<View key={t.id} className="rounded-xl border border-slate-200 bg-white px-3 py-2">
|
{part.today.map((t) => (
|
||||||
<Text className="font-medium text-slate-900">{t.titre}</Text>
|
<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>
|
</View>
|
||||||
))
|
|
||||||
)}
|
)}
|
||||||
</View>
|
|
||||||
|
|
||||||
<Text className="mb-2 mt-8 text-sm font-semibold uppercase text-slate-500">Raccourcis</Text>
|
<Text className="mb-3 text-lg font-bold uppercase tracking-wide" style={{ color: UI.textMuted }}>
|
||||||
<Link href="/bien/nouveau" className="mb-2 text-base font-semibold text-blue-700">
|
Raccourcis
|
||||||
Nouveau bien
|
</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>
|
||||||
<Link href="/(tabs)/contacts" className="mb-2 text-base font-semibold text-blue-700">
|
<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
|
Contacts
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
</Link>
|
</Link>
|
||||||
<Link href="/(tabs)/visites" className="mb-2 text-base font-semibold text-blue-700">
|
<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
|
Visites
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
</Link>
|
</Link>
|
||||||
|
</View>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</ScrollView>
|
</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 { 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 { AVIS_VISITE } from '@/constants/metier';
|
||||||
|
import { UI } from '@/constants/uiTheme';
|
||||||
import { useVisitesList } from '@/hooks/useVisites';
|
import { useVisitesList } from '@/hooks/useVisites';
|
||||||
import { formatPocketBaseError } from '@/utils/pocketbaseErrors';
|
import { formatPocketBaseError } from '@/utils/pocketbaseErrors';
|
||||||
|
|
||||||
@ -12,31 +15,46 @@ export default function VisitesTab() {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Stack.Screen options={{ title: 'Visites', headerShown: true }} />
|
<Stack.Screen options={{ title: 'Visites', headerShown: true }} />
|
||||||
<View className="flex-1 bg-slate-50">
|
<View className="flex-1" style={{ backgroundColor: UI.screen }}>
|
||||||
{q.error ? (
|
{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}
|
) : null}
|
||||||
{q.isPending ? (
|
{q.isPending ? (
|
||||||
<View className="flex-1 items-center justify-center">
|
<ListSkeleton rows={5} />
|
||||||
<ActivityIndicator color="#1D4ED8" />
|
|
||||||
</View>
|
|
||||||
) : (
|
) : (
|
||||||
<ScrollView className="flex-1 p-3">
|
<ScrollView className="flex-1 px-3 pt-3" contentContainerStyle={{ paddingBottom: 112 }}>
|
||||||
{q.data?.length === 0 ? (
|
{q.data?.length === 0 ? (
|
||||||
<Text className="text-slate-600">Aucune visite.</Text>
|
<EmptyState
|
||||||
) : null}
|
title="Aucune visite"
|
||||||
{q.data?.map((v) => (
|
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
|
<Pressable
|
||||||
key={v.id}
|
key={v.id}
|
||||||
className="mb-2 rounded-xl border border-slate-200 bg-white p-3"
|
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}`)}
|
onPress={() => router.push(`/visite/${v.id}`)}
|
||||||
>
|
>
|
||||||
<Text className="font-semibold text-slate-900">{v.date_visite?.slice(0, 10) ?? '—'}</Text>
|
<Text className="text-xl font-bold" style={{ color: UI.text }}>
|
||||||
<Text className="text-sm text-slate-600">
|
{v.date_visite?.slice(0, 10) ?? '—'}
|
||||||
{v.avis_global ? AVIS_VISITE[v.avis_global]?.label ?? v.avis_global : '—'}
|
</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>
|
</Text>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
))}
|
))
|
||||||
|
)}
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
@ -1,8 +1,11 @@
|
|||||||
import { Link, Stack, useLocalSearchParams, useRouter } from 'expo-router';
|
import { Link, Stack, useLocalSearchParams, useRouter } from 'expo-router';
|
||||||
import type { ReactNode } from 'react';
|
import type { ReactNode } from 'react';
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
import {
|
import {
|
||||||
ActivityIndicator,
|
ActivityIndicator,
|
||||||
|
Alert,
|
||||||
FlatList,
|
FlatList,
|
||||||
|
Linking,
|
||||||
Pressable,
|
Pressable,
|
||||||
ScrollView,
|
ScrollView,
|
||||||
Text,
|
Text,
|
||||||
@ -11,9 +14,10 @@ import {
|
|||||||
} from 'react-native';
|
} from 'react-native';
|
||||||
|
|
||||||
import { AVIS_VISITE, TYPES_BIENS } from '@/constants/metier';
|
import { AVIS_VISITE, TYPES_BIENS } from '@/constants/metier';
|
||||||
|
import { dvfSearchUrl, geoportailBienUrl } from '@/constants/rechercheMarche';
|
||||||
import { useBienDetail } from '@/hooks/useBiens';
|
import { useBienDetail } from '@/hooks/useBiens';
|
||||||
import { useNoteLibre } from '@/hooks/useNoteLibre';
|
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';
|
import { formatEUR } from '@/utils/format';
|
||||||
|
|
||||||
function routeParamId(raw: string | string[] | undefined): string | undefined {
|
function routeParamId(raw: string | string[] | undefined): string | undefined {
|
||||||
@ -27,6 +31,8 @@ export default function BienDetailScreen() {
|
|||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { bundle, isLoading, error } = useBienDetail(id);
|
const { bundle, isLoading, error } = useBienDetail(id);
|
||||||
const { draft, setDraft, hydrated } = useNoteLibre(id, bundle?.notes);
|
const { draft, setDraft, hydrated } = useNoteLibre(id, bundle?.notes);
|
||||||
|
const { patchAnalyse, isPatching } = useAnalyse(id);
|
||||||
|
const [prixReventeM2Draft, setPrixReventeM2Draft] = useState('');
|
||||||
|
|
||||||
if (!id) {
|
if (!id) {
|
||||||
return (
|
return (
|
||||||
@ -73,6 +79,24 @@ export default function BienDetailScreen() {
|
|||||||
const { bien, visites, notes, documents, analyse } = bundle;
|
const { bien, visites, notes, documents, analyse } = bundle;
|
||||||
const etape = bien.expand?.etape;
|
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 = {
|
const analyseInput: AnalyseFormInput = {
|
||||||
prix_achat: analyse?.prix_achat,
|
prix_achat: analyse?.prix_achat,
|
||||||
type_bien_fiscal: analyse?.type_bien_fiscal,
|
type_bien_fiscal: analyse?.type_bien_fiscal,
|
||||||
@ -85,6 +109,7 @@ export default function BienDetailScreen() {
|
|||||||
taxe_fonciere_annuelle: analyse?.taxe_fonciere_annuelle,
|
taxe_fonciere_annuelle: analyse?.taxe_fonciere_annuelle,
|
||||||
charges_copropriete_mensuelle: analyse?.charges_copropriete_mensuelle,
|
charges_copropriete_mensuelle: analyse?.charges_copropriete_mensuelle,
|
||||||
prix_revente_cible: analyse?.prix_revente_cible,
|
prix_revente_cible: analyse?.prix_revente_cible,
|
||||||
|
prix_revente_m2: analyse?.prix_revente_m2,
|
||||||
frais_agence_vente_pct: analyse?.frais_agence_vente_pct,
|
frais_agence_vente_pct: analyse?.frais_agence_vente_pct,
|
||||||
taux_impot: analyse?.taux_impot,
|
taux_impot: analyse?.taux_impot,
|
||||||
};
|
};
|
||||||
@ -128,6 +153,52 @@ export default function BienDetailScreen() {
|
|||||||
<InfoLine label="Priorité" value={bien.priorite != null ? String(bien.priorite) : '—'} />
|
<InfoLine label="Priorité" value={bien.priorite != null ? String(bien.priorite) : '—'} />
|
||||||
</Section>
|
</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">
|
<Section title="Finances">
|
||||||
{!analyse ? (
|
{!analyse ? (
|
||||||
<Text className="text-slate-600">Aucune analyse enregistrée. Utilisez le calculateur.</Text>
|
<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="Portage (total)" value={formatEUR(calc.frais_portage_total)} />
|
||||||
<InfoLine label="Prix de revient" value={formatEUR(calc.prix_revient)} />
|
<InfoLine label="Prix de revient" value={formatEUR(calc.prix_revient)} />
|
||||||
<InfoLine label="Prix revente cible" value={formatEUR(analyse.prix_revente_cible)} />
|
<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 brute" value={formatEUR(calc.marge_brute)} />
|
||||||
<InfoLine label="Marge nette" value={formatEUR(calc.marge_nette)} />
|
<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;
|
taxe_fonciere_annuelle?: number;
|
||||||
charges_copropriete_mensuelle?: number;
|
charges_copropriete_mensuelle?: number;
|
||||||
prix_revente_cible?: number;
|
prix_revente_cible?: number;
|
||||||
|
/** Prix de revente estimé au m² (marché). */
|
||||||
|
prix_revente_m2?: number;
|
||||||
frais_agence_vente_pct?: number;
|
frais_agence_vente_pct?: number;
|
||||||
taux_impot?: number;
|
taux_impot?: number;
|
||||||
};
|
};
|
||||||
@ -98,6 +100,9 @@ function formToRecord(form: AnalyseFormInput, calc: AnalyseCalculated): Record<s
|
|||||||
taxe_fonciere_annuelle: form.taxe_fonciere_annuelle,
|
taxe_fonciere_annuelle: form.taxe_fonciere_annuelle,
|
||||||
charges_copropriete_mensuelle: form.charges_copropriete_mensuelle,
|
charges_copropriete_mensuelle: form.charges_copropriete_mensuelle,
|
||||||
prix_revente_cible: form.prix_revente_cible,
|
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,
|
frais_agence_vente_pct: form.frais_agence_vente_pct,
|
||||||
taux_impot: form.taux_impot,
|
taux_impot: form.taux_impot,
|
||||||
marge_brute: calc.marge_brute,
|
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 {
|
return {
|
||||||
analyse: query.data ?? null,
|
analyse: query.data ?? null,
|
||||||
isLoading: query.isPending,
|
isLoading: query.isPending,
|
||||||
@ -161,6 +190,8 @@ export function useAnalyse(bienId: string | undefined) {
|
|||||||
fetchAnalyse: query.refetch,
|
fetchAnalyse: query.refetch,
|
||||||
saveAnalyse: saveMutation.mutateAsync,
|
saveAnalyse: saveMutation.mutateAsync,
|
||||||
isSaving: saveMutation.isPending,
|
isSaving: saveMutation.isPending,
|
||||||
|
patchAnalyse: patchMutation.mutateAsync,
|
||||||
|
isPatching: patchMutation.isPending,
|
||||||
calculateResults,
|
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",
|
"@react-navigation/native": "^7.1.8",
|
||||||
"@tanstack/react-query": "^5.90.0",
|
"@tanstack/react-query": "^5.90.0",
|
||||||
"expo": "~54.0.0",
|
"expo": "~54.0.0",
|
||||||
|
"expo-clipboard": "~8.0.0",
|
||||||
"expo-constants": "~18.0.13",
|
"expo-constants": "~18.0.13",
|
||||||
"expo-document-picker": "~14.0.8",
|
"expo-document-picker": "~14.0.8",
|
||||||
"expo-font": "~14.0.11",
|
"expo-font": "~14.0.11",
|
||||||
@ -4404,6 +4405,17 @@
|
|||||||
"react-native": "*"
|
"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": {
|
"node_modules/expo-constants": {
|
||||||
"version": "18.0.13",
|
"version": "18.0.13",
|
||||||
"resolved": "https://registry.npmjs.org/expo-constants/-/expo-constants-18.0.13.tgz",
|
"resolved": "https://registry.npmjs.org/expo-constants/-/expo-constants-18.0.13.tgz",
|
||||||
|
|||||||
@ -16,6 +16,7 @@
|
|||||||
"@react-navigation/native": "^7.1.8",
|
"@react-navigation/native": "^7.1.8",
|
||||||
"@tanstack/react-query": "^5.90.0",
|
"@tanstack/react-query": "^5.90.0",
|
||||||
"expo": "~54.0.0",
|
"expo": "~54.0.0",
|
||||||
|
"expo-clipboard": "~8.0.0",
|
||||||
"expo-constants": "~18.0.13",
|
"expo-constants": "~18.0.13",
|
||||||
"expo-document-picker": "~14.0.8",
|
"expo-document-picker": "~14.0.8",
|
||||||
"expo-font": "~14.0.11",
|
"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?: number;
|
||||||
marge_nette_pct?: number;
|
marge_nette_pct?: number;
|
||||||
notes?: string;
|
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 & {
|
export type VisiteRecord = RecordModel & {
|
||||||
|
|||||||
@ -1,11 +1,9 @@
|
|||||||
version: '3.8'
|
|
||||||
|
|
||||||
services:
|
services:
|
||||||
pocketbase:
|
pocketbase:
|
||||||
image: ghcr.io/muchobien/pocketbase:latest
|
image: ghcr.io/muchobien/pocketbase:latest
|
||||||
container_name: mdb-pocketbase-dev
|
container_name: mdb-pocketbase-dev
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
command: --dir=/pb_data
|
command: --dir=/pb_data --hooksDir=/pb_hooks
|
||||||
ports:
|
ports:
|
||||||
- "8090:8090"
|
- "8090:8090"
|
||||||
volumes:
|
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) => {
|
(app) => {
|
||||||
const usersId = app.findCollectionByNameOrId("users").id;
|
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 {
|
try {
|
||||||
return app.findCollectionByNameOrId(name);
|
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();
|
const col = factory();
|
||||||
app.save(col);
|
app.save(col);
|
||||||
return 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",
|
"notes_biens",
|
||||||
"documents_biens",
|
"documents_biens",
|
||||||
"devis_travaux",
|
"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"];
|
const ruleKeys = ["listRule", "viewRule", "createRule", "updateRule", "deleteRule"];
|
||||||
for (const name of names) {
|
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