recherche
This commit is contained in:
@ -24,7 +24,8 @@ export const pb = new PocketBase(process.env.EXPO_PUBLIC_PB_URL);
|
||||
|
||||
## Collections PocketBase (toutes créées via migration)
|
||||
etapes_pipeline, contacts, biens, analyses_financieres,
|
||||
visites, taches, notes_biens, documents_biens, devis_travaux
|
||||
visites, taches, notes_biens, documents_biens, devis_travaux,
|
||||
analyses_secteur, notes_prospection, grille_prix
|
||||
|
||||
## Règles de code
|
||||
- TypeScript strict, jamais de any
|
||||
|
||||
25
AGENTS.md
25
AGENTS.md
@ -12,6 +12,31 @@
|
||||
- [x] Module Visites + IA (`pb_hooks/generate_rapport.pb.js`, route `POST /api/mdb/generate-rapport`)
|
||||
- [x] Module Agenda (tâches, snooze, création modal)
|
||||
- [x] Dashboard (alertes, KPIs, pipeline, derniers biens, tâches du jour)
|
||||
- [x] Module Recherche & Analyse marché (onglet Recherche : Secteur / Opportunités / Grille de prix + fiche bien)
|
||||
|
||||
## Roadmap — Agrégation type MoteurImmo & agents IA
|
||||
|
||||
Référence produit : [moteurimmo.fr](https://moteurimmo.fr/) (agrégation multi-portails, alertes, DVF/transactions, API pro).
|
||||
|
||||
**Écart actuel** : pas d’ingestion de flux externes ni de moteur d’alertes ; la grille / secteur restent des données **saisies ou locales** (PocketBase), pas une veille marché temps réel.
|
||||
|
||||
### Personas agents (rôles métier + briques techniques)
|
||||
|
||||
| Agent | Mission | Briques typiques |
|
||||
|-------|---------|------------------|
|
||||
| **Immobilier** | Off-market, diffusion agence (priorité site maison), prospection pour alimenter le pipe | Collections opportunités / contacts / tâches ; hooks ou jobs pour brouillons de contenu ; pas de scraping illégal — privilégier saisie, imports CSV, API partenaires. |
|
||||
| **Marchand de biens** | Prix secteur, €/m², repérage bonnes offres | Grille perso + DVF / transactions (open data) ; scoring simple ; alertes sur critères (prix/m², surface, zone). |
|
||||
| **Data / DVF** | Normaliser transactions publiques, relier zone ↔ bien | Import DVF (fichiers ou API tiers), tables dérivées, carto plus tard. |
|
||||
| **Veille annonces** | Agréger sources autorisées (API, flux partenaires, [API MoteurImmo](https://moteurimmo.fr/) si abonnement) | Collections `sources_flux`, `annonces_brutes`, `alertes_recherche` ; cron PocketBase ou worker externe ; dédoublonnage. |
|
||||
| **Alertes & notif** | Push / email quand une annonce ou une transac matche une recherche sauvegardée | Règles métier + Expo notifications ; file d’événements côté PB. |
|
||||
| **Rédaction / CRM** | Textes vitrine, relances, synthèses pour prospection | Réutiliser le pattern hook IA (`generate_rapport`) par type de prompt. |
|
||||
|
||||
### Phases suggérées
|
||||
|
||||
1. **Modèle de données** : recherches sauvegardées, alertes, log d’ingestion (sans agrégateur massif au début).
|
||||
2. **Données publiques** : DVF ou extrait local par zone (preuve de valeur pour €/m² réel).
|
||||
3. **Une source API fiable** (partenaire ou open data) avant tout volume type MoteurImmo.
|
||||
4. **UI** : liste annonces unifiée + filtres + onglet alertes dans Recherche.
|
||||
|
||||
## Infos techniques
|
||||
- PocketBase : http://localhost:8090
|
||||
|
||||
@ -45,6 +45,13 @@ export default function TabsLayout() {
|
||||
tabBarIcon: ({ color, size }) => <Ionicons name="list-outline" size={size} color={color} />,
|
||||
}}
|
||||
/>
|
||||
<Tabs.Screen
|
||||
name="recherche"
|
||||
options={{
|
||||
title: 'Recherche',
|
||||
tabBarIcon: ({ color, size }) => <Ionicons name="search-outline" size={size} color={color} />,
|
||||
}}
|
||||
/>
|
||||
</Tabs>
|
||||
);
|
||||
}
|
||||
|
||||
@ -12,6 +12,9 @@ import {
|
||||
View,
|
||||
} from 'react-native';
|
||||
|
||||
import { EmptyState } from '@/components/ui/EmptyState';
|
||||
import { ListSkeleton } from '@/components/ui/ListSkeleton';
|
||||
import { UI } from '@/constants/uiTheme';
|
||||
import { useBiens } from '@/hooks/useBiens';
|
||||
import { useTachesList } from '@/hooks/useTaches';
|
||||
import type { TacheExpanded } from '@/types/collections';
|
||||
@ -91,25 +94,43 @@ export default function AgendaTab() {
|
||||
]);
|
||||
};
|
||||
|
||||
const emptyList =
|
||||
!isLoading && sections.length === 0 ? (
|
||||
<EmptyState
|
||||
title="Agenda vide"
|
||||
description="Ajoute des rappels (relances, visites, banque) pour piloter ta semaine."
|
||||
actionLabel="+ Nouvelle tâche"
|
||||
onAction={openCreate}
|
||||
/>
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Stack.Screen options={{ title: 'Agenda', headerShown: true }} />
|
||||
<View className="flex-1 bg-slate-50">
|
||||
<View className="flex-1" style={{ backgroundColor: UI.screen }}>
|
||||
{error ? (
|
||||
<Text className="p-4 text-red-700">{formatPocketBaseError(error)}</Text>
|
||||
<View
|
||||
className="mx-3 mt-3 rounded-2xl border-2 px-4 py-3"
|
||||
style={{ borderColor: UI.danger, backgroundColor: '#FEE2E2' }}
|
||||
>
|
||||
<Text className="text-base font-medium" style={{ color: '#991B1B' }}>
|
||||
{formatPocketBaseError(error)}
|
||||
</Text>
|
||||
</View>
|
||||
) : null}
|
||||
{isLoading ? (
|
||||
<View className="flex-1 items-center justify-center">
|
||||
<ActivityIndicator color="#1D4ED8" />
|
||||
</View>
|
||||
<ListSkeleton rows={6} />
|
||||
) : (
|
||||
<SectionList
|
||||
sections={sections}
|
||||
keyExtractor={(item) => item.id}
|
||||
contentContainerStyle={{ padding: 12, paddingBottom: 96 }}
|
||||
contentContainerStyle={
|
||||
sections.length === 0 ? { flexGrow: 1, padding: 12, paddingBottom: 112 } : { padding: 12, paddingBottom: 112 }
|
||||
}
|
||||
renderSectionHeader={({ section }) => (
|
||||
<Text
|
||||
className={`pb-2 pt-3 text-xs font-semibold uppercase ${section.tint === 'red' ? 'text-red-600' : 'text-slate-500'}`}
|
||||
className="pb-2 pt-4 text-sm font-bold uppercase tracking-wide"
|
||||
style={{ color: section.tint === 'red' ? UI.danger : UI.textMuted }}
|
||||
>
|
||||
{section.title}
|
||||
</Text>
|
||||
@ -118,85 +139,138 @@ export default function AgendaTab() {
|
||||
const done = t.statut === 'fait';
|
||||
const badge = bienLabel(t);
|
||||
return (
|
||||
<View className="mb-2 rounded-xl border border-slate-200 bg-white p-3">
|
||||
<View className="flex-row items-start gap-3">
|
||||
<View
|
||||
className="mb-3 rounded-2xl border-2 bg-white p-4"
|
||||
style={{ borderColor: UI.border }}
|
||||
>
|
||||
<View className="flex-row items-start gap-4">
|
||||
<Pressable
|
||||
accessibilityRole="checkbox"
|
||||
accessibilityState={{ checked: done }}
|
||||
onPress={() => void toggleDone(t)}
|
||||
className={`mt-0.5 h-6 w-6 items-center justify-center rounded border ${done ? 'border-green-600 bg-green-600' : 'border-slate-300 bg-white'}`}
|
||||
className="mt-1 h-12 w-12 items-center justify-center rounded-xl border-2 active:opacity-90"
|
||||
style={{
|
||||
borderColor: done ? UI.success : UI.border,
|
||||
backgroundColor: done ? UI.success : UI.card,
|
||||
}}
|
||||
>
|
||||
{done ? <Text className="text-xs font-bold text-white">✓</Text> : null}
|
||||
{done ? (
|
||||
<Text className="text-xl font-bold text-white">✓</Text>
|
||||
) : null}
|
||||
</Pressable>
|
||||
<View className="min-w-0 flex-1">
|
||||
<Text
|
||||
className={`font-semibold text-slate-900 ${done ? 'text-slate-400 line-through' : ''}`}
|
||||
className={`text-lg font-bold leading-6 ${done ? 'text-slate-400 line-through' : ''}`}
|
||||
style={!done ? { color: UI.text } : undefined}
|
||||
>
|
||||
{t.titre}
|
||||
</Text>
|
||||
{badge ? (
|
||||
<Text className="mt-1 text-xs font-medium text-blue-700">{badge}</Text>
|
||||
<Text className="mt-2 text-base font-semibold" style={{ color: UI.primary }}>
|
||||
{badge}
|
||||
</Text>
|
||||
) : null}
|
||||
{t.date_echeance ? (
|
||||
<Text className="mt-1 text-xs text-slate-500">{t.date_echeance}</Text>
|
||||
<Text className="mt-1 text-base" style={{ color: UI.textMuted }}>
|
||||
{t.date_echeance}
|
||||
</Text>
|
||||
) : null}
|
||||
</View>
|
||||
</View>
|
||||
<View className="mt-3 flex-row flex-wrap gap-2">
|
||||
<View className="mt-4 flex-row flex-wrap gap-3">
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
onPress={() => void snoozeOneDay(t)}
|
||||
className="rounded-lg bg-slate-100 px-3 py-2"
|
||||
className="min-h-[52px] min-w-[140px] flex-1 items-center justify-center rounded-2xl border-2 px-4 active:opacity-90"
|
||||
style={{ borderColor: UI.warning, backgroundColor: '#FFFBEB' }}
|
||||
>
|
||||
<Text className="text-sm font-medium text-slate-800">Snooze +1 j</Text>
|
||||
<Text className="text-lg font-bold" style={{ color: '#B45309' }}>
|
||||
+1 jour
|
||||
</Text>
|
||||
</Pressable>
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
onPress={() => confirmDelete(t)}
|
||||
className="rounded-lg bg-red-50 px-3 py-2"
|
||||
className="min-h-[52px] min-w-[120px] flex-1 items-center justify-center rounded-2xl border-2 px-4 active:opacity-90"
|
||||
style={{ borderColor: UI.danger, backgroundColor: '#FEF2F2' }}
|
||||
>
|
||||
<Text className="text-sm font-medium text-red-700">Supprimer</Text>
|
||||
<Text className="text-lg font-bold" style={{ color: UI.danger }}>
|
||||
Supprimer
|
||||
</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}}
|
||||
ListEmptyComponent={
|
||||
<Text className="py-8 text-center text-slate-600">Aucune tâche à afficher.</Text>
|
||||
}
|
||||
ListEmptyComponent={emptyList}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Nouvelle tâche"
|
||||
onPress={openCreate}
|
||||
className="absolute bottom-6 right-5 rounded-full bg-blue-700 px-4 py-3"
|
||||
className="absolute bottom-6 right-5 min-h-[56px] justify-center rounded-2xl px-6 py-4 active:opacity-90"
|
||||
style={{ backgroundColor: UI.primary, elevation: 8 }}
|
||||
>
|
||||
<Text className="font-semibold text-white">+ Tâche</Text>
|
||||
<Text className="text-lg font-bold text-white">+ Tâche</Text>
|
||||
</Pressable>
|
||||
|
||||
<Modal visible={modalOpen} animationType="slide" transparent>
|
||||
<View className="flex-1 justify-end bg-black/40">
|
||||
<View className="max-h-[85%] rounded-t-2xl bg-white p-4">
|
||||
<Text className="text-lg font-bold text-slate-900">Nouvelle tâche</Text>
|
||||
<Text className="mt-3 text-sm text-slate-500">Titre</Text>
|
||||
<View className="flex-1 justify-end bg-black/50">
|
||||
<View className="max-h-[88%] rounded-t-3xl bg-white p-5">
|
||||
<Text className="text-2xl font-bold" style={{ color: UI.text }}>
|
||||
Nouvelle tâche
|
||||
</Text>
|
||||
<Text className="mt-4 text-base font-semibold" style={{ color: UI.textMuted }}>
|
||||
Titre
|
||||
</Text>
|
||||
<TextInput
|
||||
className="mt-1 rounded-xl border border-slate-200 px-3 py-2 text-base text-slate-900"
|
||||
className="mt-2 rounded-2xl border-2 px-4 text-lg"
|
||||
style={{
|
||||
borderColor: UI.border,
|
||||
color: UI.text,
|
||||
minHeight: 52,
|
||||
backgroundColor: UI.card,
|
||||
}}
|
||||
value={newTitre}
|
||||
onChangeText={setNewTitre}
|
||||
placeholder="Appeler le notaire…"
|
||||
placeholderTextColor="#94a3b8"
|
||||
placeholderTextColor={UI.textMuted}
|
||||
/>
|
||||
<Text className="mt-3 text-sm text-slate-500">Échéance (AAAA-MM-JJ)</Text>
|
||||
<Text className="mt-4 text-base font-semibold" style={{ color: UI.textMuted }}>
|
||||
Échéance (AAAA-MM-JJ)
|
||||
</Text>
|
||||
<TextInput
|
||||
className="mt-1 rounded-xl border border-slate-200 px-3 py-2 text-base text-slate-900"
|
||||
className="mt-2 rounded-2xl border-2 px-4 text-lg"
|
||||
style={{
|
||||
borderColor: UI.border,
|
||||
color: UI.text,
|
||||
minHeight: 52,
|
||||
backgroundColor: UI.card,
|
||||
}}
|
||||
value={newDate}
|
||||
onChangeText={setNewDate}
|
||||
placeholder="2026-04-29"
|
||||
placeholderTextColor="#94a3b8"
|
||||
placeholderTextColor={UI.textMuted}
|
||||
/>
|
||||
<Text className="mt-4 text-sm text-slate-500">Bien (optionnel)</Text>
|
||||
<ScrollView horizontal className="mt-2" keyboardShouldPersistTaps="handled">
|
||||
<Text className="mt-4 text-base font-semibold" style={{ color: UI.textMuted }}>
|
||||
Bien (optionnel)
|
||||
</Text>
|
||||
<ScrollView horizontal className="mt-2 max-h-28" keyboardShouldPersistTaps="handled">
|
||||
<Pressable
|
||||
onPress={() => setNewBienId(undefined)}
|
||||
className={`mr-2 rounded-full px-3 py-2 ${newBienId == null ? 'bg-slate-800' : 'bg-slate-100'}`}
|
||||
className={`mr-2 min-h-[48px] justify-center rounded-2xl px-4 ${newBienId == null ? '' : 'border-2'}`}
|
||||
style={
|
||||
newBienId == null
|
||||
? { backgroundColor: UI.primary }
|
||||
: { borderColor: UI.border, backgroundColor: UI.screen }
|
||||
}
|
||||
>
|
||||
<Text className={`text-sm ${newBienId == null ? 'text-white' : 'text-slate-800'}`}>
|
||||
<Text
|
||||
className={`text-base font-bold ${newBienId == null ? 'text-white' : ''}`}
|
||||
style={newBienId == null ? undefined : { color: UI.text }}
|
||||
>
|
||||
Aucun
|
||||
</Text>
|
||||
</Pressable>
|
||||
@ -204,33 +278,38 @@ export default function AgendaTab() {
|
||||
<Pressable
|
||||
key={b.id}
|
||||
onPress={() => setNewBienId(b.id)}
|
||||
className={`mr-2 max-w-[200px] rounded-full px-3 py-2 ${newBienId === b.id ? 'bg-blue-700' : 'bg-slate-100'}`}
|
||||
className="mr-2 max-w-[220px] min-h-[48px] justify-center rounded-2xl border-2 px-4"
|
||||
style={{
|
||||
borderColor: newBienId === b.id ? UI.primary : UI.border,
|
||||
backgroundColor: newBienId === b.id ? '#EFF6FF' : UI.card,
|
||||
}}
|
||||
>
|
||||
<Text
|
||||
numberOfLines={1}
|
||||
className={`text-sm ${newBienId === b.id ? 'text-white' : 'text-slate-800'}`}
|
||||
>
|
||||
<Text numberOfLines={1} className="text-base font-bold" style={{ color: UI.text }}>
|
||||
{b.titre?.trim() || b.ville || b.id}
|
||||
</Text>
|
||||
</Pressable>
|
||||
))}
|
||||
</ScrollView>
|
||||
<View className="mt-6 flex-row gap-3">
|
||||
<View className="mt-8 flex-row gap-3">
|
||||
<Pressable
|
||||
onPress={() => setModalOpen(false)}
|
||||
className="flex-1 items-center rounded-xl border border-slate-200 py-3"
|
||||
className="min-h-[56px] flex-1 items-center justify-center rounded-2xl border-2 active:opacity-90"
|
||||
style={{ borderColor: UI.border }}
|
||||
>
|
||||
<Text className="font-semibold text-slate-800">Annuler</Text>
|
||||
<Text className="text-lg font-bold" style={{ color: UI.text }}>
|
||||
Annuler
|
||||
</Text>
|
||||
</Pressable>
|
||||
<Pressable
|
||||
onPress={() => void submitCreate()}
|
||||
disabled={isCreatePending}
|
||||
className="flex-1 items-center rounded-xl bg-blue-700 py-3"
|
||||
className="min-h-[56px] flex-1 items-center justify-center rounded-2xl active:opacity-90"
|
||||
style={{ backgroundColor: UI.primary }}
|
||||
>
|
||||
{isCreatePending ? (
|
||||
<ActivityIndicator color="#fff" />
|
||||
) : (
|
||||
<Text className="font-semibold text-white">Créer</Text>
|
||||
<Text className="text-lg font-bold text-white">Créer</Text>
|
||||
)}
|
||||
</Pressable>
|
||||
</View>
|
||||
|
||||
@ -1,7 +1,10 @@
|
||||
import { Link, Stack } from 'expo-router';
|
||||
import { useEffect, useMemo, useRef } from 'react';
|
||||
import { ActivityIndicator, Pressable, ScrollView, Text, View } from 'react-native';
|
||||
import { Pressable, ScrollView, Text, View } from 'react-native';
|
||||
|
||||
import { EmptyState } from '@/components/ui/EmptyState';
|
||||
import { PipelineSkeleton } from '@/components/ui/PipelineSkeleton';
|
||||
import { UI } from '@/constants/uiTheme';
|
||||
import { useBiens, type BienExpanded } from '@/hooks/useBiens';
|
||||
import { useEtapes } from '@/hooks/useEtapes';
|
||||
import { formatEUR } from '@/utils/format';
|
||||
@ -48,44 +51,89 @@ export default function BiensScreen() {
|
||||
? formatPocketBaseError(etapesInitMutationError)
|
||||
: null;
|
||||
|
||||
const loading = isLoading || etapesLoading;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Stack.Screen options={{ title: 'Biens', headerShown: true }} />
|
||||
<View className="flex-1 bg-slate-50">
|
||||
<View className="flex-1" style={{ backgroundColor: UI.screen }}>
|
||||
{banner ? (
|
||||
<View className="border-b border-red-200 bg-red-50 px-3 py-2">
|
||||
<Text className="text-sm text-red-900">{banner}</Text>
|
||||
<View
|
||||
className="border-b-2 px-4 py-3"
|
||||
style={{ borderColor: UI.danger, backgroundColor: '#FEE2E2' }}
|
||||
>
|
||||
<Text className="text-base font-medium" style={{ color: '#991B1B' }}>
|
||||
{banner}
|
||||
</Text>
|
||||
</View>
|
||||
) : null}
|
||||
{isLoading || etapesLoading ? (
|
||||
<View className="flex-1 items-center justify-center">
|
||||
<ActivityIndicator size="large" color="#1D4ED8" />
|
||||
{loading ? (
|
||||
<PipelineSkeleton />
|
||||
) : biens.length === 0 ? (
|
||||
<View className="flex-1 justify-center px-2">
|
||||
<EmptyState
|
||||
title="Pipeline vide"
|
||||
description="Crée un bien pour suivre tes pistes du premier contact jusqu’à l’acte."
|
||||
actionLabel="Ajouter un bien"
|
||||
actionHref="/bien/nouveau"
|
||||
/>
|
||||
</View>
|
||||
) : (
|
||||
<ScrollView horizontal className="flex-1" contentContainerStyle={{ padding: 12, paddingBottom: 96 }}>
|
||||
<ScrollView
|
||||
horizontal
|
||||
className="flex-1"
|
||||
contentContainerStyle={{ padding: 12, paddingBottom: 112 }}
|
||||
>
|
||||
{etapes.map((e) => {
|
||||
const list = grouped.m.get(e.id) ?? [];
|
||||
return (
|
||||
<View key={e.id} className="mr-3 w-56 rounded-xl border border-slate-200 bg-white p-2">
|
||||
<View className="mb-2 flex-row items-center justify-between border-b border-slate-100 pb-2">
|
||||
<Text className="flex-1 font-bold text-slate-900" numberOfLines={2}>
|
||||
<View
|
||||
key={e.id}
|
||||
className="mr-3 w-56 rounded-2xl border-2 bg-white p-3"
|
||||
style={{ borderColor: UI.border }}
|
||||
>
|
||||
<View
|
||||
className="mb-3 flex-row items-center justify-between border-b-2 pb-3"
|
||||
style={{ borderColor: '#E2E8F0' }}
|
||||
>
|
||||
<Text
|
||||
className="flex-1 text-lg font-bold leading-6"
|
||||
style={{ color: UI.text }}
|
||||
numberOfLines={2}
|
||||
>
|
||||
{e.nom}
|
||||
</Text>
|
||||
<Text className="text-xs text-slate-500">{list.length}</Text>
|
||||
<View
|
||||
className="ml-2 min-w-[36px] items-center rounded-full px-2 py-1"
|
||||
style={{ backgroundColor: UI.primary }}
|
||||
>
|
||||
<Text className="text-base font-bold text-white">{list.length}</Text>
|
||||
</View>
|
||||
</View>
|
||||
<Text className="mb-2 text-xs text-slate-500">{list.length} bien(s)</Text>
|
||||
<ScrollView nestedScrollEnabled>
|
||||
<ScrollView nestedScrollEnabled showsVerticalScrollIndicator={false}>
|
||||
{list.map((b) => (
|
||||
<Link key={b.id} href={`/bien/${b.id}`} asChild>
|
||||
<Pressable className="mb-2 rounded-lg border border-slate-100 bg-slate-50 p-2">
|
||||
<Text className="font-medium text-slate-900" numberOfLines={2}>
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
className="mb-3 min-h-[72px] justify-center rounded-xl border-2 px-3 py-3 active:opacity-90"
|
||||
style={{ borderColor: '#E2E8F0', backgroundColor: '#F8FAFC' }}
|
||||
>
|
||||
<Text
|
||||
className="text-base font-bold leading-5"
|
||||
style={{ color: UI.text }}
|
||||
numberOfLines={2}
|
||||
>
|
||||
{b.titre ?? 'Sans titre'}
|
||||
</Text>
|
||||
<Text className="text-xs text-slate-500" numberOfLines={1}>
|
||||
<Text
|
||||
className="mt-1 text-base"
|
||||
style={{ color: UI.textMuted }}
|
||||
numberOfLines={1}
|
||||
>
|
||||
{[b.code_postal, b.ville].filter(Boolean).join(' ') || '—'}
|
||||
</Text>
|
||||
{prixByBien.has(b.id) ? (
|
||||
<Text className="mt-1 text-xs font-semibold text-slate-700">
|
||||
<Text className="mt-2 text-base font-bold" style={{ color: UI.success }}>
|
||||
{formatEUR(prixByBien.get(b.id))}
|
||||
</Text>
|
||||
) : null}
|
||||
@ -96,16 +144,29 @@ export default function BiensScreen() {
|
||||
</View>
|
||||
);
|
||||
})}
|
||||
<View className="mr-3 w-56 rounded-xl border border-dashed border-slate-300 bg-slate-100/80 p-2">
|
||||
<Text className="mb-2 font-bold text-slate-700">Sans étape</Text>
|
||||
<Text className="mb-2 text-xs text-slate-500">
|
||||
<View
|
||||
className="mr-3 w-56 rounded-2xl border-2 border-dashed p-3"
|
||||
style={{ borderColor: UI.warning, backgroundColor: '#FFFBEB' }}
|
||||
>
|
||||
<Text className="mb-3 text-lg font-bold" style={{ color: UI.text }}>
|
||||
Sans étape
|
||||
</Text>
|
||||
<Text className="mb-3 text-base" style={{ color: UI.textMuted }}>
|
||||
{(grouped.m.get(grouped.none) ?? []).length} bien(s)
|
||||
</Text>
|
||||
<ScrollView nestedScrollEnabled>
|
||||
<ScrollView nestedScrollEnabled showsVerticalScrollIndicator={false}>
|
||||
{(grouped.m.get(grouped.none) ?? []).map((b) => (
|
||||
<Link key={b.id} href={`/bien/${b.id}`} asChild>
|
||||
<Pressable className="mb-2 rounded-lg border border-slate-200 bg-white p-2">
|
||||
<Text className="font-medium text-slate-900" numberOfLines={2}>
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
className="mb-3 min-h-[72px] justify-center rounded-xl border-2 bg-white px-3 py-3 active:opacity-90"
|
||||
style={{ borderColor: UI.border }}
|
||||
>
|
||||
<Text
|
||||
className="text-base font-bold leading-5"
|
||||
style={{ color: UI.text }}
|
||||
numberOfLines={2}
|
||||
>
|
||||
{b.titre ?? 'Sans titre'}
|
||||
</Text>
|
||||
</Pressable>
|
||||
@ -117,10 +178,12 @@ export default function BiensScreen() {
|
||||
)}
|
||||
<Link href="/bien/nouveau" asChild>
|
||||
<Pressable
|
||||
className="absolute bottom-6 right-5 h-14 w-14 items-center justify-center rounded-full bg-blue-700 shadow-md"
|
||||
style={{ elevation: 6 }}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Ajouter un bien"
|
||||
className="absolute bottom-6 right-5 h-16 w-16 items-center justify-center rounded-2xl shadow-lg active:opacity-90"
|
||||
style={{ backgroundColor: UI.primary, elevation: 8 }}
|
||||
>
|
||||
<Text className="text-3xl leading-8 font-light text-white">+</Text>
|
||||
<Text className="text-4xl font-light leading-none text-white">+</Text>
|
||||
</Pressable>
|
||||
</Link>
|
||||
</View>
|
||||
|
||||
@ -1,16 +1,11 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { Link, Stack } from 'expo-router';
|
||||
import {
|
||||
ActivityIndicator,
|
||||
Linking,
|
||||
Pressable,
|
||||
SectionList,
|
||||
Text,
|
||||
TextInput,
|
||||
View,
|
||||
} from 'react-native';
|
||||
import { Linking, Pressable, SectionList, Text, TextInput, View } from 'react-native';
|
||||
|
||||
import { EmptyState } from '@/components/ui/EmptyState';
|
||||
import { ListSkeleton } from '@/components/ui/ListSkeleton';
|
||||
import { labelContactCategorie } from '@/constants/contactCategories';
|
||||
import { UI } from '@/constants/uiTheme';
|
||||
import { useContactsList } from '@/hooks/useContacts';
|
||||
import type { ContactRecord } from '@/types/collections';
|
||||
import { formatPocketBaseError } from '@/utils/pocketbaseErrors';
|
||||
@ -51,59 +46,114 @@ export default function ContactsTab() {
|
||||
.sort((a, b) => a.title.localeCompare(b.title, 'fr'));
|
||||
}, [q.data, search]);
|
||||
|
||||
const listEmpty =
|
||||
!q.isPending && sections.length === 0 ? (
|
||||
search.trim() ? (
|
||||
<EmptyState
|
||||
title="Aucun résultat"
|
||||
description="Essaie un autre mot-clé ou réinitialise la recherche."
|
||||
actionLabel="Effacer la recherche"
|
||||
onAction={() => setSearch('')}
|
||||
/>
|
||||
) : (
|
||||
<EmptyState
|
||||
title="Aucun contact"
|
||||
description="Ajoute notaires, artisans et partenaires pour les retrouver vite."
|
||||
actionLabel="Nouveau contact"
|
||||
actionHref="/contact/nouveau"
|
||||
/>
|
||||
)
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Stack.Screen options={{ title: 'Contacts', headerShown: true }} />
|
||||
<View className="flex-1 bg-slate-50">
|
||||
<View className="flex-1" style={{ backgroundColor: UI.screen }}>
|
||||
{q.error ? (
|
||||
<Text className="p-4 text-red-700">{formatPocketBaseError(q.error)}</Text>
|
||||
<View
|
||||
className="mx-3 mt-3 rounded-2xl border-2 px-4 py-3"
|
||||
style={{ borderColor: UI.danger, backgroundColor: '#FEE2E2' }}
|
||||
>
|
||||
<Text className="text-base font-medium" style={{ color: '#991B1B' }}>
|
||||
{formatPocketBaseError(q.error)}
|
||||
</Text>
|
||||
</View>
|
||||
) : null}
|
||||
<TextInput
|
||||
className="mx-3 mt-2 rounded-xl border border-slate-200 bg-white px-3 py-2 text-base text-slate-900"
|
||||
className="mx-3 mt-3 rounded-2xl border-2 bg-white px-4 text-lg"
|
||||
style={{
|
||||
borderColor: UI.border,
|
||||
color: UI.text,
|
||||
minHeight: 52,
|
||||
paddingVertical: 12,
|
||||
}}
|
||||
placeholder="Rechercher…"
|
||||
placeholderTextColor="#94a3b8"
|
||||
placeholderTextColor={UI.textMuted}
|
||||
value={search}
|
||||
onChangeText={setSearch}
|
||||
accessibilityLabel="Recherche contacts"
|
||||
/>
|
||||
{q.isPending ? (
|
||||
<View className="flex-1 items-center justify-center">
|
||||
<ActivityIndicator color="#1D4ED8" />
|
||||
</View>
|
||||
<ListSkeleton rows={7} />
|
||||
) : (
|
||||
<SectionList
|
||||
className="flex-1 px-3 pt-2"
|
||||
sections={sections}
|
||||
keyExtractor={(item) => item.id}
|
||||
contentContainerStyle={{ paddingBottom: 88 }}
|
||||
contentContainerStyle={
|
||||
sections.length === 0 ? { flexGrow: 1, paddingBottom: 100 } : { paddingBottom: 100 }
|
||||
}
|
||||
renderSectionHeader={({ section: { title } }) => (
|
||||
<Text className="pb-1 pt-3 text-xs font-semibold uppercase text-slate-500">{title}</Text>
|
||||
<Text
|
||||
className="pb-2 pt-4 text-sm font-bold uppercase tracking-wide"
|
||||
style={{ color: UI.textMuted }}
|
||||
>
|
||||
{title}
|
||||
</Text>
|
||||
)}
|
||||
renderItem={({ item: c }) => (
|
||||
<View className="mb-2 rounded-xl border border-slate-200 bg-white px-3 py-3">
|
||||
<View
|
||||
className="mb-3 rounded-2xl border-2 bg-white px-4 py-4"
|
||||
style={{ borderColor: UI.border }}
|
||||
>
|
||||
<Link href={`/contact/${c.id}`} asChild>
|
||||
<Pressable>
|
||||
<Text className="font-semibold text-slate-900">
|
||||
<Pressable accessibilityRole="button" className="active:opacity-90">
|
||||
<Text className="text-xl font-bold" style={{ color: UI.text }}>
|
||||
{c.prenom ? `${c.prenom} ` : ''}
|
||||
{c.nom}
|
||||
</Text>
|
||||
{c.societe ? <Text className="text-sm text-slate-500">{c.societe}</Text> : null}
|
||||
{c.societe ? (
|
||||
<Text className="mt-1 text-lg" style={{ color: UI.textMuted }}>
|
||||
{c.societe}
|
||||
</Text>
|
||||
) : null}
|
||||
</Pressable>
|
||||
</Link>
|
||||
{c.telephone ? (
|
||||
<Pressable onPress={() => openTel(c.telephone)} className="mt-2 self-start">
|
||||
<Text className="text-sm text-blue-700">{c.telephone}</Text>
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
onPress={() => openTel(c.telephone)}
|
||||
className="mt-3 min-h-[48px] justify-center self-start rounded-xl px-3 active:opacity-90"
|
||||
style={{ backgroundColor: '#EFF6FF' }}
|
||||
>
|
||||
<Text className="text-lg font-bold" style={{ color: UI.primary }}>
|
||||
{c.telephone}
|
||||
</Text>
|
||||
</Pressable>
|
||||
) : null}
|
||||
</View>
|
||||
)}
|
||||
ListEmptyComponent={
|
||||
<Text className="py-6 text-center text-slate-600">Aucun contact.</Text>
|
||||
}
|
||||
ListEmptyComponent={listEmpty}
|
||||
/>
|
||||
)}
|
||||
<Link href="/contact/nouveau" asChild>
|
||||
<Pressable className="absolute bottom-6 right-5 rounded-full bg-blue-700 px-4 py-3">
|
||||
<Text className="font-semibold text-white">+ Contact</Text>
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Nouveau contact"
|
||||
className="absolute bottom-6 right-5 min-h-[56px] justify-center rounded-2xl px-6 py-4 active:opacity-90"
|
||||
style={{ backgroundColor: UI.primary, elevation: 6 }}
|
||||
>
|
||||
<Text className="text-lg font-bold text-white">+ Contact</Text>
|
||||
</Pressable>
|
||||
</Link>
|
||||
</View>
|
||||
|
||||
@ -1,12 +1,15 @@
|
||||
import { useMemo } from 'react';
|
||||
import { Link, Stack } from 'expo-router';
|
||||
import { ActivityIndicator, Pressable, ScrollView, Text, View } from 'react-native';
|
||||
import { Pressable, ScrollView, Text, View } from 'react-native';
|
||||
|
||||
import { EmptyState } from '@/components/ui/EmptyState';
|
||||
import { DashboardSkeleton } from '@/components/ui/DashboardSkeleton';
|
||||
import { TYPES_BIENS } from '@/constants/metier';
|
||||
import { UI } from '@/constants/uiTheme';
|
||||
import { useBiens } from '@/hooks/useBiens';
|
||||
import type { BienExpanded } from '@/hooks/useBiens';
|
||||
import { useEtapes } from '@/hooks/useEtapes';
|
||||
import { useTachesList } from '@/hooks/useTaches';
|
||||
import { TYPES_BIENS } from '@/constants/metier';
|
||||
import type { BienExpanded } from '@/hooks/useBiens';
|
||||
import {
|
||||
isTaskActive,
|
||||
parsePbDateOnly,
|
||||
@ -57,136 +60,273 @@ export default function DashboardScreen() {
|
||||
return (
|
||||
<>
|
||||
<Stack.Screen options={{ title: 'Dashboard', headerShown: true }} />
|
||||
<ScrollView className="flex-1 bg-slate-50 p-4" contentContainerStyle={{ paddingBottom: 32 }}>
|
||||
<ScrollView
|
||||
className="flex-1 p-4"
|
||||
style={{ backgroundColor: UI.screen }}
|
||||
contentContainerStyle={{ paddingBottom: 40 }}
|
||||
>
|
||||
{biensError ? (
|
||||
<Text className="mb-2 text-red-700">{formatPocketBaseError(biensError)}</Text>
|
||||
<View
|
||||
className="mb-3 rounded-2xl border-2 px-4 py-3"
|
||||
style={{ borderColor: UI.danger, backgroundColor: '#FEE2E2' }}
|
||||
>
|
||||
<Text className="text-base font-medium" style={{ color: '#991B1B' }}>
|
||||
{formatPocketBaseError(biensError)}
|
||||
</Text>
|
||||
</View>
|
||||
) : null}
|
||||
{tachesError ? (
|
||||
<Text className="mb-2 text-red-700">{formatPocketBaseError(tachesError)}</Text>
|
||||
<View
|
||||
className="mb-3 rounded-2xl border-2 px-4 py-3"
|
||||
style={{ borderColor: UI.danger, backgroundColor: '#FEE2E2' }}
|
||||
>
|
||||
<Text className="text-base font-medium" style={{ color: '#991B1B' }}>
|
||||
{formatPocketBaseError(tachesError)}
|
||||
</Text>
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
{loading ? (
|
||||
<View className="py-8">
|
||||
<ActivityIndicator color="#1D4ED8" />
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
<Text className="mb-2 text-lg font-bold text-slate-900">Alertes urgentes</Text>
|
||||
{urgent.length === 0 ? (
|
||||
<Text className="mb-6 text-slate-600">Aucune alerte.</Text>
|
||||
<DashboardSkeleton />
|
||||
) : (
|
||||
<View className="mb-6 gap-2">
|
||||
{urgent.slice(0, 6).map((t) => (
|
||||
<Link key={t.id} href="/(tabs)/agenda" asChild>
|
||||
<Pressable className="rounded-xl border border-red-200 bg-red-50 px-3 py-2">
|
||||
<Text className="font-medium text-red-900">{t.titre}</Text>
|
||||
{t.date_echeance ? (
|
||||
<Text className="text-xs text-red-700">{t.date_echeance}</Text>
|
||||
) : null}
|
||||
</Pressable>
|
||||
</Link>
|
||||
))}
|
||||
</View>
|
||||
)}
|
||||
|
||||
<Text className="mb-2 text-lg font-bold text-slate-900">Indicateurs</Text>
|
||||
<View className="mb-6 flex-row flex-wrap gap-3">
|
||||
<View className="min-w-[100px] flex-1 rounded-xl border border-slate-200 bg-white p-3">
|
||||
<Text className="text-2xl font-bold text-slate-900">{biens.length}</Text>
|
||||
<Text className="text-xs text-slate-500">Biens</Text>
|
||||
</View>
|
||||
<View className="min-w-[100px] flex-1 rounded-xl border border-slate-200 bg-white p-3">
|
||||
<Text className="text-2xl font-bold text-slate-900">{actifs.length}</Text>
|
||||
<Text className="text-xs text-slate-500">Actifs</Text>
|
||||
</View>
|
||||
<View className="min-w-[100px] flex-1 rounded-xl border border-slate-200 bg-white p-3">
|
||||
<Text className="text-2xl font-bold text-slate-900">
|
||||
{taches.filter(isTaskActive).length}
|
||||
<>
|
||||
<Text className="mb-3 text-2xl font-bold" style={{ color: UI.text }}>
|
||||
Alertes urgentes
|
||||
</Text>
|
||||
<Text className="text-xs text-slate-500">Tâches ouvertes</Text>
|
||||
</View>
|
||||
</View>
|
||||
{urgent.length === 0 ? (
|
||||
<EmptyState
|
||||
title="Rien d’urgent"
|
||||
description="Les tâches en retard ou marquées urgentes apparaîtront ici."
|
||||
actionLabel="Voir l’agenda"
|
||||
actionHref="/(tabs)/agenda"
|
||||
/>
|
||||
) : (
|
||||
<View className="mb-8 gap-3">
|
||||
{urgent.slice(0, 6).map((t) => (
|
||||
<Link key={t.id} href="/(tabs)/agenda" asChild>
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
className="min-h-[56px] justify-center rounded-2xl border-2 px-4 py-3 active:opacity-90"
|
||||
style={{
|
||||
borderColor: UI.danger,
|
||||
backgroundColor: '#FEF2F2',
|
||||
}}
|
||||
>
|
||||
<Text className="text-lg font-semibold" style={{ color: '#991B1B' }}>
|
||||
{t.titre}
|
||||
</Text>
|
||||
{t.date_echeance ? (
|
||||
<Text className="mt-1 text-base" style={{ color: UI.danger }}>
|
||||
{t.date_echeance}
|
||||
</Text>
|
||||
) : null}
|
||||
</Pressable>
|
||||
</Link>
|
||||
))}
|
||||
</View>
|
||||
)}
|
||||
|
||||
<Text className="mb-2 text-lg font-bold text-slate-900">Pipeline</Text>
|
||||
<ScrollView horizontal showsHorizontalScrollIndicator={false} className="mb-6">
|
||||
<View className="flex-row gap-2 pb-1">
|
||||
{etapes.map((e) => {
|
||||
const n = etapeCounts.get(e.id) ?? 0;
|
||||
return (
|
||||
<View
|
||||
key={e.id}
|
||||
className="min-w-[120px] rounded-xl border border-slate-200 bg-white px-3 py-2"
|
||||
>
|
||||
<View className="mb-1 h-1 rounded-full" style={{ backgroundColor: e.couleur }} />
|
||||
<Text numberOfLines={2} className="text-xs font-semibold text-slate-800">
|
||||
{e.nom}
|
||||
</Text>
|
||||
<Text className="mt-1 text-lg font-bold text-slate-900">{n}</Text>
|
||||
</View>
|
||||
);
|
||||
})}
|
||||
{(etapeCounts.get('') ?? 0) > 0 ? (
|
||||
<View className="min-w-[120px] rounded-xl border border-dashed border-slate-300 bg-white px-3 py-2">
|
||||
<Text className="text-xs font-semibold text-slate-600">Sans étape</Text>
|
||||
<Text className="mt-1 text-lg font-bold text-slate-900">
|
||||
{etapeCounts.get('') ?? 0}
|
||||
<Text className="mb-3 text-2xl font-bold" style={{ color: UI.text }}>
|
||||
Indicateurs
|
||||
</Text>
|
||||
<View className="mb-8 flex-row flex-wrap gap-3">
|
||||
<View
|
||||
className="min-w-[108px] flex-1 rounded-2xl border bg-white p-4"
|
||||
style={{ borderColor: UI.border, borderLeftWidth: 5, borderLeftColor: UI.primary }}
|
||||
>
|
||||
<Text className="text-3xl font-bold" style={{ color: UI.text }}>
|
||||
{biens.length}
|
||||
</Text>
|
||||
<Text className="mt-1 text-base font-medium" style={{ color: UI.textMuted }}>
|
||||
Biens
|
||||
</Text>
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
</ScrollView>
|
||||
<View
|
||||
className="min-w-[108px] flex-1 rounded-2xl border bg-white p-4"
|
||||
style={{ borderColor: UI.border, borderLeftWidth: 5, borderLeftColor: UI.success }}
|
||||
>
|
||||
<Text className="text-3xl font-bold" style={{ color: UI.text }}>
|
||||
{actifs.length}
|
||||
</Text>
|
||||
<Text className="mt-1 text-base font-medium" style={{ color: UI.textMuted }}>
|
||||
Actifs
|
||||
</Text>
|
||||
</View>
|
||||
<View
|
||||
className="min-w-[108px] flex-1 rounded-2xl border bg-white p-4"
|
||||
style={{ borderColor: UI.border, borderLeftWidth: 5, borderLeftColor: UI.warning }}
|
||||
>
|
||||
<Text className="text-3xl font-bold" style={{ color: UI.text }}>
|
||||
{taches.filter(isTaskActive).length}
|
||||
</Text>
|
||||
<Text className="mt-1 text-base font-medium" style={{ color: UI.textMuted }}>
|
||||
Tâches ouvertes
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View className="mb-2 flex-row items-center justify-between">
|
||||
<Text className="text-lg font-bold text-slate-900">Derniers biens</Text>
|
||||
<Link href="/(tabs)/biens" className="text-sm font-semibold text-blue-700">
|
||||
Voir tout
|
||||
</Link>
|
||||
</View>
|
||||
<View className="mb-6 gap-2">
|
||||
{derniers.length === 0 ? (
|
||||
<Text className="text-slate-600">Aucun bien.</Text>
|
||||
) : (
|
||||
derniers.map((b) => (
|
||||
<Link key={b.id} href={`/bien/${b.id}`} asChild>
|
||||
<Pressable className="rounded-xl border border-slate-200 bg-white px-3 py-3">
|
||||
<Text className="font-semibold text-slate-900">{bienTitre(b)}</Text>
|
||||
<Text className="text-xs text-slate-500">
|
||||
{b.expand?.etape?.nom ?? '—'} · {b.ville ?? ''}
|
||||
<View className="mb-3 flex-row items-center justify-between">
|
||||
<Text className="text-2xl font-bold" style={{ color: UI.text }}>
|
||||
Pipeline
|
||||
</Text>
|
||||
</View>
|
||||
<ScrollView horizontal showsHorizontalScrollIndicator={false} className="mb-8">
|
||||
<View className="flex-row gap-3 pb-1">
|
||||
{etapes.map((e) => {
|
||||
const n = etapeCounts.get(e.id) ?? 0;
|
||||
return (
|
||||
<View
|
||||
key={e.id}
|
||||
className="min-w-[132px] rounded-2xl border-2 bg-white px-4 py-4"
|
||||
style={{ borderColor: UI.border }}
|
||||
>
|
||||
<View className="mb-2 h-1.5 rounded-full" style={{ backgroundColor: e.couleur }} />
|
||||
<Text numberOfLines={2} className="text-base font-bold" style={{ color: UI.text }}>
|
||||
{e.nom}
|
||||
</Text>
|
||||
<Text className="mt-2 text-2xl font-bold" style={{ color: UI.primary }}>
|
||||
{n}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
})}
|
||||
{(etapeCounts.get('') ?? 0) > 0 ? (
|
||||
<View
|
||||
className="min-w-[132px] rounded-2xl border-2 border-dashed bg-white px-4 py-4"
|
||||
style={{ borderColor: UI.border }}
|
||||
>
|
||||
<Text className="text-base font-bold" style={{ color: UI.textMuted }}>
|
||||
Sans étape
|
||||
</Text>
|
||||
<Text className="mt-2 text-2xl font-bold" style={{ color: UI.warning }}>
|
||||
{etapeCounts.get('') ?? 0}
|
||||
</Text>
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
</ScrollView>
|
||||
|
||||
<View className="mb-3 flex-row items-center justify-between">
|
||||
<Text className="text-2xl font-bold" style={{ color: UI.text }}>
|
||||
Derniers biens
|
||||
</Text>
|
||||
<Link href="/(tabs)/biens" asChild>
|
||||
<Pressable
|
||||
hitSlop={12}
|
||||
className="min-h-[48px] justify-center px-2"
|
||||
accessibilityRole="link"
|
||||
>
|
||||
<Text className="text-lg font-bold" style={{ color: UI.primary }}>
|
||||
Tout voir
|
||||
</Text>
|
||||
</Pressable>
|
||||
</Link>
|
||||
))
|
||||
)}
|
||||
</View>
|
||||
|
||||
<View className="mb-2 flex-row items-center justify-between">
|
||||
<Text className="text-lg font-bold text-slate-900">Tâches du jour</Text>
|
||||
<Link href="/(tabs)/agenda" className="text-sm font-semibold text-blue-700">
|
||||
Agenda
|
||||
</Link>
|
||||
</View>
|
||||
<View className="gap-2">
|
||||
{part.today.length === 0 ? (
|
||||
<Text className="text-slate-600">Rien de prévu aujourd’hui.</Text>
|
||||
) : (
|
||||
part.today.map((t) => (
|
||||
<View key={t.id} className="rounded-xl border border-slate-200 bg-white px-3 py-2">
|
||||
<Text className="font-medium text-slate-900">{t.titre}</Text>
|
||||
</View>
|
||||
{derniers.length === 0 ? (
|
||||
<View className="mb-8">
|
||||
<EmptyState
|
||||
title="Aucun bien enregistré"
|
||||
description="Ajoute ton premier prospect pour démarrer le pipeline."
|
||||
actionLabel="Créer un bien"
|
||||
actionHref="/bien/nouveau"
|
||||
/>
|
||||
</View>
|
||||
))
|
||||
)}
|
||||
</View>
|
||||
) : (
|
||||
<View className="mb-8 gap-3">
|
||||
{derniers.map((b) => (
|
||||
<Link key={b.id} href={`/bien/${b.id}`} asChild>
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
className="min-h-[64px] justify-center rounded-2xl border-2 bg-white px-4 py-4 active:opacity-90"
|
||||
style={{ borderColor: UI.border }}
|
||||
>
|
||||
<Text className="text-lg font-bold" style={{ color: UI.text }}>
|
||||
{bienTitre(b)}
|
||||
</Text>
|
||||
<Text className="mt-1 text-base" style={{ color: UI.textMuted }}>
|
||||
{b.expand?.etape?.nom ?? '—'} · {b.ville ?? ''}
|
||||
</Text>
|
||||
</Pressable>
|
||||
</Link>
|
||||
))}
|
||||
</View>
|
||||
)}
|
||||
|
||||
<Text className="mb-2 mt-8 text-sm font-semibold uppercase text-slate-500">Raccourcis</Text>
|
||||
<Link href="/bien/nouveau" className="mb-2 text-base font-semibold text-blue-700">
|
||||
Nouveau bien
|
||||
</Link>
|
||||
<Link href="/(tabs)/contacts" className="mb-2 text-base font-semibold text-blue-700">
|
||||
Contacts
|
||||
</Link>
|
||||
<Link href="/(tabs)/visites" className="mb-2 text-base font-semibold text-blue-700">
|
||||
Visites
|
||||
</Link>
|
||||
<View className="mb-3 flex-row items-center justify-between">
|
||||
<Text className="text-2xl font-bold" style={{ color: UI.text }}>
|
||||
Tâches du jour
|
||||
</Text>
|
||||
<Link href="/(tabs)/agenda" asChild>
|
||||
<Pressable hitSlop={12} className="min-h-[48px] justify-center px-2">
|
||||
<Text className="text-lg font-bold" style={{ color: UI.primary }}>
|
||||
Agenda
|
||||
</Text>
|
||||
</Pressable>
|
||||
</Link>
|
||||
</View>
|
||||
{part.today.length === 0 ? (
|
||||
<View className="mb-8">
|
||||
<EmptyState
|
||||
title="Journée libre"
|
||||
description="Aucune échéance aujourd’hui dans l’agenda."
|
||||
actionLabel="Planifier une tâche"
|
||||
actionHref="/(tabs)/agenda"
|
||||
/>
|
||||
</View>
|
||||
) : (
|
||||
<View className="mb-8 gap-3">
|
||||
{part.today.map((t) => (
|
||||
<View
|
||||
key={t.id}
|
||||
className="min-h-[56px] justify-center rounded-2xl border-2 bg-white px-4 py-4"
|
||||
style={{ borderColor: UI.border }}
|
||||
>
|
||||
<Text className="text-lg font-semibold" style={{ color: UI.text }}>
|
||||
{t.titre}
|
||||
</Text>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
)}
|
||||
|
||||
<Text className="mb-3 text-lg font-bold uppercase tracking-wide" style={{ color: UI.textMuted }}>
|
||||
Raccourcis
|
||||
</Text>
|
||||
<View className="gap-3">
|
||||
<Link href="/bien/nouveau" asChild>
|
||||
<Pressable
|
||||
className="min-h-[56px] items-center justify-center rounded-2xl active:opacity-90"
|
||||
style={{ backgroundColor: UI.primary }}
|
||||
accessibilityRole="button"
|
||||
>
|
||||
<Text className="text-lg font-bold text-white">Nouveau bien</Text>
|
||||
</Pressable>
|
||||
</Link>
|
||||
<Link href="/(tabs)/contacts" asChild>
|
||||
<Pressable
|
||||
className="min-h-[56px] items-center justify-center rounded-2xl border-2 bg-white active:opacity-90"
|
||||
style={{ borderColor: UI.primary }}
|
||||
accessibilityRole="button"
|
||||
>
|
||||
<Text className="text-lg font-bold" style={{ color: UI.primary }}>
|
||||
Contacts
|
||||
</Text>
|
||||
</Pressable>
|
||||
</Link>
|
||||
<Link href="/(tabs)/visites" asChild>
|
||||
<Pressable
|
||||
className="min-h-[56px] items-center justify-center rounded-2xl border-2 bg-white active:opacity-90"
|
||||
style={{ borderColor: UI.success }}
|
||||
accessibilityRole="button"
|
||||
>
|
||||
<Text className="text-lg font-bold" style={{ color: UI.success }}>
|
||||
Visites
|
||||
</Text>
|
||||
</Pressable>
|
||||
</Link>
|
||||
</View>
|
||||
</>
|
||||
)}
|
||||
</ScrollView>
|
||||
</>
|
||||
);
|
||||
|
||||
44
app/app/(tabs)/recherche.tsx
Normal file
44
app/app/(tabs)/recherche.tsx
Normal file
@ -0,0 +1,44 @@
|
||||
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 { UI } from '@/constants/uiTheme';
|
||||
|
||||
const TABS = ['Secteur', 'Opportunités', 'Grille de prix'] 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}
|
||||
</View>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@ -1,7 +1,10 @@
|
||||
import { Stack, useRouter } from 'expo-router';
|
||||
import { ActivityIndicator, Pressable, ScrollView, Text, View } from 'react-native';
|
||||
import { Pressable, ScrollView, Text, View } from 'react-native';
|
||||
|
||||
import { EmptyState } from '@/components/ui/EmptyState';
|
||||
import { ListSkeleton } from '@/components/ui/ListSkeleton';
|
||||
import { AVIS_VISITE } from '@/constants/metier';
|
||||
import { UI } from '@/constants/uiTheme';
|
||||
import { useVisitesList } from '@/hooks/useVisites';
|
||||
import { formatPocketBaseError } from '@/utils/pocketbaseErrors';
|
||||
|
||||
@ -12,31 +15,46 @@ export default function VisitesTab() {
|
||||
return (
|
||||
<>
|
||||
<Stack.Screen options={{ title: 'Visites', headerShown: true }} />
|
||||
<View className="flex-1 bg-slate-50">
|
||||
<View className="flex-1" style={{ backgroundColor: UI.screen }}>
|
||||
{q.error ? (
|
||||
<Text className="p-4 text-red-700">{formatPocketBaseError(q.error)}</Text>
|
||||
<View
|
||||
className="mx-3 mt-3 rounded-2xl border-2 px-4 py-3"
|
||||
style={{ borderColor: UI.danger, backgroundColor: '#FEE2E2' }}
|
||||
>
|
||||
<Text className="text-base font-medium" style={{ color: '#991B1B' }}>
|
||||
{formatPocketBaseError(q.error)}
|
||||
</Text>
|
||||
</View>
|
||||
) : null}
|
||||
{q.isPending ? (
|
||||
<View className="flex-1 items-center justify-center">
|
||||
<ActivityIndicator color="#1D4ED8" />
|
||||
</View>
|
||||
<ListSkeleton rows={5} />
|
||||
) : (
|
||||
<ScrollView className="flex-1 p-3">
|
||||
<ScrollView className="flex-1 px-3 pt-3" contentContainerStyle={{ paddingBottom: 112 }}>
|
||||
{q.data?.length === 0 ? (
|
||||
<Text className="text-slate-600">Aucune visite.</Text>
|
||||
) : null}
|
||||
{q.data?.map((v) => (
|
||||
<Pressable
|
||||
key={v.id}
|
||||
className="mb-2 rounded-xl border border-slate-200 bg-white p-3"
|
||||
onPress={() => router.push(`/visite/${v.id}`)}
|
||||
>
|
||||
<Text className="font-semibold text-slate-900">{v.date_visite?.slice(0, 10) ?? '—'}</Text>
|
||||
<Text className="text-sm text-slate-600">
|
||||
{v.avis_global ? AVIS_VISITE[v.avis_global]?.label ?? v.avis_global : '—'}
|
||||
</Text>
|
||||
</Pressable>
|
||||
))}
|
||||
<EmptyState
|
||||
title="Aucune visite"
|
||||
description="Les visites sont liées à un bien : ouvre un bien depuis le pipeline pour consigner une visite."
|
||||
actionLabel="Ouvrir les biens"
|
||||
actionHref="/(tabs)/biens"
|
||||
/>
|
||||
) : (
|
||||
q.data?.map((v) => (
|
||||
<Pressable
|
||||
key={v.id}
|
||||
accessibilityRole="button"
|
||||
className="mb-3 min-h-[76px] justify-center rounded-2xl border-2 bg-white px-4 py-4 active:opacity-90"
|
||||
style={{ borderColor: UI.border }}
|
||||
onPress={() => router.push(`/visite/${v.id}`)}
|
||||
>
|
||||
<Text className="text-xl font-bold" style={{ color: UI.text }}>
|
||||
{v.date_visite?.slice(0, 10) ?? '—'}
|
||||
</Text>
|
||||
<Text className="mt-1 text-lg" style={{ color: UI.textMuted }}>
|
||||
{v.avis_global ? AVIS_VISITE[v.avis_global]?.label ?? v.avis_global : 'Avis non renseigné'}
|
||||
</Text>
|
||||
</Pressable>
|
||||
))
|
||||
)}
|
||||
</ScrollView>
|
||||
)}
|
||||
</View>
|
||||
|
||||
@ -1,8 +1,11 @@
|
||||
import { Link, Stack, useLocalSearchParams, useRouter } from 'expo-router';
|
||||
import type { ReactNode } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import {
|
||||
ActivityIndicator,
|
||||
Alert,
|
||||
FlatList,
|
||||
Linking,
|
||||
Pressable,
|
||||
ScrollView,
|
||||
Text,
|
||||
@ -11,9 +14,10 @@ import {
|
||||
} from 'react-native';
|
||||
|
||||
import { AVIS_VISITE, TYPES_BIENS } from '@/constants/metier';
|
||||
import { dvfSearchUrl, geoportailBienUrl } from '@/constants/rechercheMarche';
|
||||
import { useBienDetail } from '@/hooks/useBiens';
|
||||
import { useNoteLibre } from '@/hooks/useNoteLibre';
|
||||
import { calculateResults, type AnalyseFormInput } from '@/hooks/useAnalyse';
|
||||
import { calculateResults, useAnalyse, type AnalyseFormInput } from '@/hooks/useAnalyse';
|
||||
import { formatEUR } from '@/utils/format';
|
||||
|
||||
function routeParamId(raw: string | string[] | undefined): string | undefined {
|
||||
@ -27,6 +31,8 @@ export default function BienDetailScreen() {
|
||||
const router = useRouter();
|
||||
const { bundle, isLoading, error } = useBienDetail(id);
|
||||
const { draft, setDraft, hydrated } = useNoteLibre(id, bundle?.notes);
|
||||
const { patchAnalyse, isPatching } = useAnalyse(id);
|
||||
const [prixReventeM2Draft, setPrixReventeM2Draft] = useState('');
|
||||
|
||||
if (!id) {
|
||||
return (
|
||||
@ -73,6 +79,24 @@ export default function BienDetailScreen() {
|
||||
const { bien, visites, notes, documents, analyse } = bundle;
|
||||
const etape = bien.expand?.etape;
|
||||
|
||||
useEffect(() => {
|
||||
setPrixReventeM2Draft(analyse?.prix_revente_m2 != null ? String(analyse.prix_revente_m2) : '');
|
||||
}, [analyse?.id, analyse?.prix_revente_m2]);
|
||||
|
||||
const savePrixReventeM2 = async () => {
|
||||
const raw = prixReventeM2Draft.trim().replace(',', '.');
|
||||
if (raw === '') {
|
||||
await patchAnalyse({ prix_revente_m2: null });
|
||||
return;
|
||||
}
|
||||
const n = Number(raw);
|
||||
if (!Number.isFinite(n) || n < 0) {
|
||||
Alert.alert('Prix au m²', 'Saisis un nombre positif ou laisse vide.');
|
||||
return;
|
||||
}
|
||||
await patchAnalyse({ prix_revente_m2: n });
|
||||
};
|
||||
|
||||
const analyseInput: AnalyseFormInput = {
|
||||
prix_achat: analyse?.prix_achat,
|
||||
type_bien_fiscal: analyse?.type_bien_fiscal,
|
||||
@ -85,6 +109,7 @@ export default function BienDetailScreen() {
|
||||
taxe_fonciere_annuelle: analyse?.taxe_fonciere_annuelle,
|
||||
charges_copropriete_mensuelle: analyse?.charges_copropriete_mensuelle,
|
||||
prix_revente_cible: analyse?.prix_revente_cible,
|
||||
prix_revente_m2: analyse?.prix_revente_m2,
|
||||
frais_agence_vente_pct: analyse?.frais_agence_vente_pct,
|
||||
taux_impot: analyse?.taux_impot,
|
||||
};
|
||||
@ -128,6 +153,52 @@ export default function BienDetailScreen() {
|
||||
<InfoLine label="Priorité" value={bien.priorite != null ? String(bien.priorite) : '—'} />
|
||||
</Section>
|
||||
|
||||
<Section title="Analyse marché">
|
||||
<View className="flex-row flex-wrap gap-3">
|
||||
<Pressable
|
||||
className="min-h-[48px] justify-center rounded-xl px-4 py-3"
|
||||
style={{ backgroundColor: '#1D4ED8' }}
|
||||
onPress={() => void Linking.openURL(dvfSearchUrl(bien.ville ?? ''))}
|
||||
>
|
||||
<Text className="text-center text-base font-semibold text-white">Prix secteur (DVF)</Text>
|
||||
</Pressable>
|
||||
<Pressable
|
||||
className="min-h-[48px] justify-center rounded-xl border-2 border-slate-300 bg-white px-4 py-3"
|
||||
onPress={() => {
|
||||
const la = bien.latitude;
|
||||
const lo = bien.longitude;
|
||||
if (la == null || lo == null || Number.isNaN(Number(la)) || Number.isNaN(Number(lo))) {
|
||||
Alert.alert(
|
||||
'Carte',
|
||||
'Ajoute latitude et longitude sur la fiche bien pour ouvrir le Géoportail centré.',
|
||||
);
|
||||
return;
|
||||
}
|
||||
void Linking.openURL(geoportailBienUrl(Number(la), Number(lo)));
|
||||
}}
|
||||
>
|
||||
<Text className="text-center text-base font-semibold text-slate-900">Voir sur la carte</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
<Text className="mt-4 text-sm font-medium text-slate-500">
|
||||
Prix estimé revente (€/m²) — enregistré dans l'analyse financière
|
||||
</Text>
|
||||
<TextInput
|
||||
className="mt-2 rounded-xl border border-slate-200 bg-white p-3 text-base text-slate-900"
|
||||
keyboardType="decimal-pad"
|
||||
placeholder="Ex. 5200"
|
||||
placeholderTextColor="#94a3b8"
|
||||
value={prixReventeM2Draft}
|
||||
onChangeText={setPrixReventeM2Draft}
|
||||
onEndEditing={() => void savePrixReventeM2()}
|
||||
/>
|
||||
{isPatching ? (
|
||||
<ActivityIndicator className="mt-2" color="#1D4ED8" />
|
||||
) : (
|
||||
<Text className="mt-1 text-xs text-slate-400">Sauvegarde à la sortie du champ.</Text>
|
||||
)}
|
||||
</Section>
|
||||
|
||||
<Section title="Finances">
|
||||
{!analyse ? (
|
||||
<Text className="text-slate-600">Aucune analyse enregistrée. Utilisez le calculateur.</Text>
|
||||
@ -139,6 +210,9 @@ export default function BienDetailScreen() {
|
||||
<InfoLine label="Portage (total)" value={formatEUR(calc.frais_portage_total)} />
|
||||
<InfoLine label="Prix de revient" value={formatEUR(calc.prix_revient)} />
|
||||
<InfoLine label="Prix revente cible" value={formatEUR(analyse.prix_revente_cible)} />
|
||||
{analyse.prix_revente_m2 != null ? (
|
||||
<InfoLine label="Prix revente ref. (€/m²)" value={String(analyse.prix_revente_m2)} />
|
||||
) : null}
|
||||
<InfoLine label="Marge brute" value={formatEUR(calc.marge_brute)} />
|
||||
<InfoLine label="Marge nette" value={formatEUR(calc.marge_nette)} />
|
||||
</>
|
||||
|
||||
329
app/components/recherche/GrillePrixTab.tsx
Normal file
329
app/components/recherche/GrillePrixTab.tsx
Normal file
@ -0,0 +1,329 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import {
|
||||
ActivityIndicator,
|
||||
Alert,
|
||||
Modal,
|
||||
Pressable,
|
||||
ScrollView,
|
||||
Text,
|
||||
TextInput,
|
||||
View,
|
||||
} from 'react-native';
|
||||
|
||||
import { UI } from '@/constants/uiTheme';
|
||||
import { useGrillePrix } from '@/hooks/useGrillePrix';
|
||||
import type { GrillePrixEtat, GrillePrixRecord, GrillePrixTypeBien } from '@/types/collections';
|
||||
import { formatPocketBaseError } from '@/utils/pocketbaseErrors';
|
||||
|
||||
const TYPES: GrillePrixTypeBien[] = ['appartement', 'maison', 'immeuble'];
|
||||
const ETATS: GrillePrixEtat[] = ['bon_etat', 'a_renover', 'travaux_lourds'];
|
||||
|
||||
const TYPE_LABEL: Record<GrillePrixTypeBien, string> = {
|
||||
appartement: 'Appartement',
|
||||
maison: 'Maison',
|
||||
immeuble: 'Immeuble',
|
||||
};
|
||||
|
||||
const ETAT_LABEL: Record<GrillePrixEtat, string> = {
|
||||
bon_etat: 'Bon état',
|
||||
a_renover: 'À rénover',
|
||||
travaux_lourds: 'Travaux lourds',
|
||||
};
|
||||
|
||||
type EditorState =
|
||||
| { mode: 'create' }
|
||||
| { mode: 'edit'; row: GrillePrixRecord };
|
||||
|
||||
function parseNum(raw: string): number | null {
|
||||
const n = Number(String(raw).replace(',', '.').trim());
|
||||
return Number.isFinite(n) && n >= 0 ? n : null;
|
||||
}
|
||||
|
||||
export function GrillePrixTab() {
|
||||
const { rows, isLoading, error, createRow, updateRow, deleteRow, isMutating } = useGrillePrix();
|
||||
const [editor, setEditor] = useState<EditorState | null>(null);
|
||||
const [typeBien, setTypeBien] = useState<GrillePrixTypeBien>('appartement');
|
||||
const [etat, setEtat] = useState<GrillePrixEtat>('bon_etat');
|
||||
const [pa, setPa] = useState('');
|
||||
const [pr, setPr] = useState('');
|
||||
const [ville, setVille] = useState('');
|
||||
|
||||
const moyenneLabel = useMemo(() => {
|
||||
const vals = rows
|
||||
.map((r) => r.marge_estimee_pct)
|
||||
.filter((x): x is number => typeof x === 'number' && !Number.isNaN(x));
|
||||
if (vals.length === 0) return '—';
|
||||
const m = vals.reduce((a, b) => a + b, 0) / vals.length;
|
||||
return `${m.toFixed(1)} %`;
|
||||
}, [rows]);
|
||||
|
||||
const openCreate = () => {
|
||||
setTypeBien('appartement');
|
||||
setEtat('bon_etat');
|
||||
setPa('');
|
||||
setPr('');
|
||||
setVille('');
|
||||
setEditor({ mode: 'create' });
|
||||
};
|
||||
|
||||
const openEdit = (row: GrillePrixRecord) => {
|
||||
setTypeBien(row.type_bien);
|
||||
setEtat(row.etat);
|
||||
setPa(String(row.prix_achat_m2));
|
||||
setPr(String(row.prix_revente_m2));
|
||||
setVille(row.ville ?? '');
|
||||
setEditor({ mode: 'edit', row });
|
||||
};
|
||||
|
||||
const closeEditor = () => setEditor(null);
|
||||
|
||||
const submitEditor = async () => {
|
||||
const achat = parseNum(pa);
|
||||
const revente = parseNum(pr);
|
||||
if (achat == null || revente == null) {
|
||||
Alert.alert('Saisie', 'Indique des prix au m² valides (≥ 0).');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const payload = {
|
||||
type_bien: typeBien,
|
||||
etat,
|
||||
prix_achat_m2: achat,
|
||||
prix_revente_m2: revente,
|
||||
ville: ville.trim() || undefined,
|
||||
};
|
||||
if (editor?.mode === 'create') {
|
||||
await createRow(payload);
|
||||
} else if (editor?.mode === 'edit') {
|
||||
await updateRow({ id: editor.row.id, input: payload });
|
||||
}
|
||||
closeEditor();
|
||||
} catch (e) {
|
||||
Alert.alert('Erreur', formatPocketBaseError(e));
|
||||
}
|
||||
};
|
||||
|
||||
const confirmDelete = (row: GrillePrixRecord) => {
|
||||
Alert.alert('Supprimer cette ligne ?', `${TYPE_LABEL[row.type_bien]} · ${ETAT_LABEL[row.etat]}`, [
|
||||
{ text: 'Annuler', style: 'cancel' },
|
||||
{
|
||||
text: 'Supprimer',
|
||||
style: 'destructive',
|
||||
onPress: () =>
|
||||
void deleteRow(row.id).catch((e) => Alert.alert('Erreur', formatPocketBaseError(e))),
|
||||
},
|
||||
]);
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<View className="flex-1 items-center justify-center py-16">
|
||||
<ActivityIndicator size="large" color={UI.primary} />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<View className="flex-1">
|
||||
{error ? (
|
||||
<Text className="px-3 py-2 text-base text-red-700">{formatPocketBaseError(error)}</Text>
|
||||
) : null}
|
||||
<ScrollView horizontal className="flex-1" contentContainerStyle={{ paddingBottom: 120 }}>
|
||||
<View>
|
||||
<View className="min-w-[720px] flex-row border-b-2 bg-white px-2 py-3" style={{ borderColor: UI.border }}>
|
||||
<Text className="w-28 text-xs font-bold uppercase" style={{ color: UI.textMuted }}>
|
||||
Type
|
||||
</Text>
|
||||
<Text className="w-32 text-xs font-bold uppercase" style={{ color: UI.textMuted }}>
|
||||
État
|
||||
</Text>
|
||||
<Text className="w-28 text-xs font-bold uppercase" style={{ color: UI.textMuted }}>
|
||||
Achat €/m²
|
||||
</Text>
|
||||
<Text className="w-28 text-xs font-bold uppercase" style={{ color: UI.textMuted }}>
|
||||
Revente €/m²
|
||||
</Text>
|
||||
<Text className="w-24 text-xs font-bold uppercase" style={{ color: UI.textMuted }}>
|
||||
Marge %
|
||||
</Text>
|
||||
<Text className="w-28 text-xs font-bold uppercase" style={{ color: UI.textMuted }}>
|
||||
Ville
|
||||
</Text>
|
||||
<Text className="w-20" />
|
||||
</View>
|
||||
{rows.length === 0 ? (
|
||||
<Text className="p-6 text-base" style={{ color: UI.textMuted }}>
|
||||
Aucune ligne. Appuie sur + pour créer ton référentiel.
|
||||
</Text>
|
||||
) : (
|
||||
rows.map((r) => (
|
||||
<View
|
||||
key={r.id}
|
||||
className="min-w-[720px] flex-row border-b px-2 py-4"
|
||||
style={{ borderColor: UI.border }}
|
||||
>
|
||||
<Pressable
|
||||
onPress={() => openEdit(r)}
|
||||
className="min-w-0 flex-1 flex-row active:bg-slate-100"
|
||||
>
|
||||
<Text className="w-28 text-base font-semibold" style={{ color: UI.text }}>
|
||||
{TYPE_LABEL[r.type_bien]}
|
||||
</Text>
|
||||
<Text className="w-32 text-base" style={{ color: UI.text }}>
|
||||
{ETAT_LABEL[r.etat]}
|
||||
</Text>
|
||||
<Text className="w-28 text-base" style={{ color: UI.text }}>
|
||||
{r.prix_achat_m2}
|
||||
</Text>
|
||||
<Text className="w-28 text-base" style={{ color: UI.text }}>
|
||||
{r.prix_revente_m2}
|
||||
</Text>
|
||||
<Text className="w-24 text-base font-bold" style={{ color: UI.success }}>
|
||||
{r.marge_estimee_pct != null ? `${r.marge_estimee_pct.toFixed(1)} %` : '—'}
|
||||
</Text>
|
||||
<Text className="w-28 text-base" style={{ color: UI.textMuted }} numberOfLines={1}>
|
||||
{r.ville ?? '—'}
|
||||
</Text>
|
||||
</Pressable>
|
||||
<Pressable onPress={() => confirmDelete(r)} className="w-20 items-center justify-center">
|
||||
<Text className="font-bold" style={{ color: UI.danger }}>
|
||||
✕
|
||||
</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
))
|
||||
)}
|
||||
</View>
|
||||
</ScrollView>
|
||||
|
||||
<View
|
||||
className="border-t-2 px-4 py-4"
|
||||
style={{ borderColor: UI.border, backgroundColor: UI.card }}
|
||||
>
|
||||
<Text className="text-lg font-bold" style={{ color: UI.text }}>
|
||||
Marge moyenne du référentiel
|
||||
</Text>
|
||||
<Text className="mt-1 text-3xl font-bold" style={{ color: UI.primary }}>
|
||||
{moyenneLabel}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<Pressable
|
||||
accessibilityLabel="Ajouter une ligne"
|
||||
onPress={openCreate}
|
||||
className="absolute bottom-24 right-5 h-16 w-16 items-center justify-center rounded-2xl"
|
||||
style={{ backgroundColor: UI.primary, elevation: 8 }}
|
||||
>
|
||||
<Text className="text-3xl font-light text-white">+</Text>
|
||||
</Pressable>
|
||||
|
||||
<Modal visible={editor != null} animationType="slide" transparent>
|
||||
<View className="flex-1 justify-end bg-black/50">
|
||||
<View className="rounded-t-3xl bg-white p-5">
|
||||
<Text className="text-2xl font-bold" style={{ color: UI.text }}>
|
||||
{editor?.mode === 'edit' ? 'Modifier la ligne' : 'Nouvelle ligne'}
|
||||
</Text>
|
||||
<Text className="mt-4 text-base font-semibold" style={{ color: UI.textMuted }}>
|
||||
Type de bien
|
||||
</Text>
|
||||
<View className="mt-2 flex-row flex-wrap gap-2">
|
||||
{TYPES.map((t) => (
|
||||
<Pressable
|
||||
key={t}
|
||||
onPress={() => setTypeBien(t)}
|
||||
className="min-h-[48px] rounded-xl border-2 px-4 py-2"
|
||||
style={{
|
||||
borderColor: typeBien === t ? UI.primary : UI.border,
|
||||
backgroundColor: typeBien === t ? '#EFF6FF' : UI.card,
|
||||
}}
|
||||
>
|
||||
<Text className="text-base font-bold" style={{ color: UI.text }}>
|
||||
{TYPE_LABEL[t]}
|
||||
</Text>
|
||||
</Pressable>
|
||||
))}
|
||||
</View>
|
||||
<Text className="mt-4 text-base font-semibold" style={{ color: UI.textMuted }}>
|
||||
État
|
||||
</Text>
|
||||
<View className="mt-2 flex-row flex-wrap gap-2">
|
||||
{ETATS.map((t) => (
|
||||
<Pressable
|
||||
key={t}
|
||||
onPress={() => setEtat(t)}
|
||||
className="min-h-[48px] rounded-xl border-2 px-4 py-2"
|
||||
style={{
|
||||
borderColor: etat === t ? UI.primary : UI.border,
|
||||
backgroundColor: etat === t ? '#EFF6FF' : UI.card,
|
||||
}}
|
||||
>
|
||||
<Text className="text-base font-bold" style={{ color: UI.text }}>
|
||||
{ETAT_LABEL[t]}
|
||||
</Text>
|
||||
</Pressable>
|
||||
))}
|
||||
</View>
|
||||
<Text className="mt-4 text-base font-semibold" style={{ color: UI.textMuted }}>
|
||||
Prix achat (€/m²)
|
||||
</Text>
|
||||
<TextInput
|
||||
className="mt-1 rounded-xl border-2 px-4 py-3 text-lg"
|
||||
style={{ borderColor: UI.border, color: UI.text }}
|
||||
keyboardType="decimal-pad"
|
||||
value={pa}
|
||||
onChangeText={setPa}
|
||||
placeholder="4500"
|
||||
placeholderTextColor={UI.textMuted}
|
||||
/>
|
||||
<Text className="mt-4 text-base font-semibold" style={{ color: UI.textMuted }}>
|
||||
Prix revente (€/m²)
|
||||
</Text>
|
||||
<TextInput
|
||||
className="mt-1 rounded-xl border-2 px-4 py-3 text-lg"
|
||||
style={{ borderColor: UI.border, color: UI.text }}
|
||||
keyboardType="decimal-pad"
|
||||
value={pr}
|
||||
onChangeText={setPr}
|
||||
placeholder="5200"
|
||||
placeholderTextColor={UI.textMuted}
|
||||
/>
|
||||
<Text className="mt-4 text-base font-semibold" style={{ color: UI.textMuted }}>
|
||||
Ville (optionnel)
|
||||
</Text>
|
||||
<TextInput
|
||||
className="mt-1 rounded-xl border-2 px-4 py-3 text-lg"
|
||||
style={{ borderColor: UI.border, color: UI.text }}
|
||||
value={ville}
|
||||
onChangeText={setVille}
|
||||
placeholder="Lyon"
|
||||
placeholderTextColor={UI.textMuted}
|
||||
/>
|
||||
<View className="mt-6 flex-row gap-3">
|
||||
<Pressable
|
||||
onPress={closeEditor}
|
||||
className="min-h-[52px] flex-1 items-center justify-center rounded-2xl border-2"
|
||||
style={{ borderColor: UI.border }}
|
||||
>
|
||||
<Text className="text-lg font-bold" style={{ color: UI.text }}>
|
||||
Annuler
|
||||
</Text>
|
||||
</Pressable>
|
||||
<Pressable
|
||||
onPress={() => void submitEditor()}
|
||||
disabled={isMutating}
|
||||
className="min-h-[52px] flex-1 items-center justify-center rounded-2xl"
|
||||
style={{ backgroundColor: UI.primary }}
|
||||
>
|
||||
{isMutating ? (
|
||||
<ActivityIndicator color="#fff" />
|
||||
) : (
|
||||
<Text className="text-lg font-bold text-white">Enregistrer</Text>
|
||||
)}
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</Modal>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
284
app/components/recherche/OpportunitesTab.tsx
Normal file
284
app/components/recherche/OpportunitesTab.tsx
Normal file
@ -0,0 +1,284 @@
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import * as Clipboard from 'expo-clipboard';
|
||||
import type { ReactNode } from 'react';
|
||||
import { useCallback, useState } from 'react';
|
||||
import {
|
||||
ActivityIndicator,
|
||||
Alert,
|
||||
Linking,
|
||||
Pressable,
|
||||
ScrollView,
|
||||
Text,
|
||||
TextInput,
|
||||
View,
|
||||
} from 'react-native';
|
||||
|
||||
import {
|
||||
LEGI_L151_36,
|
||||
LEGI_L152_6,
|
||||
OFF_MARKET_KEYWORDS,
|
||||
PROSPECTION_CHECKLIST,
|
||||
} from '@/constants/rechercheMarche';
|
||||
import { UI } from '@/constants/uiTheme';
|
||||
import { useNotesProspectionRecherche } from '@/hooks/useNotesProspectionRecherche';
|
||||
import { formatPocketBaseError } from '@/utils/pocketbaseErrors';
|
||||
|
||||
const LBC = 'https://www.leboncoin.fr/';
|
||||
const MOTEUR = 'https://www.moteurimmo.fr/';
|
||||
|
||||
function Collapsible({
|
||||
title,
|
||||
children,
|
||||
defaultOpen,
|
||||
}: {
|
||||
title: string;
|
||||
children: ReactNode;
|
||||
defaultOpen?: boolean;
|
||||
}) {
|
||||
const [open, setOpen] = useState(Boolean(defaultOpen));
|
||||
return (
|
||||
<View className="mb-3 rounded-2xl border-2 bg-white" style={{ borderColor: UI.border }}>
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
onPress={() => setOpen((o) => !o)}
|
||||
className="min-h-[52px] flex-row items-center justify-between px-4 py-3"
|
||||
>
|
||||
<Text className="flex-1 pr-2 text-lg font-bold" style={{ color: UI.text }}>
|
||||
{title}
|
||||
</Text>
|
||||
<Ionicons name={open ? 'chevron-up' : 'chevron-down'} size={22} color={UI.textMuted} />
|
||||
</Pressable>
|
||||
{open ? <View className="border-t-2 px-4 pb-4 pt-2" style={{ borderColor: UI.border }}>{children}</View> : null}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
export function OpportunitesTab() {
|
||||
const { getState, saveChecklistItem, isLoading, isSaving } = useNotesProspectionRecherche();
|
||||
const [expandedNoteId, setExpandedNoteId] = useState<string | null>(null);
|
||||
const [noteDraft, setNoteDraft] = useState('');
|
||||
|
||||
const persistItem = useCallback(
|
||||
async (questionId: string, next: { done: boolean; note: string }) => {
|
||||
try {
|
||||
await saveChecklistItem({ questionId, data: next });
|
||||
} catch (e) {
|
||||
Alert.alert('Sauvegarde', formatPocketBaseError(e));
|
||||
}
|
||||
},
|
||||
[saveChecklistItem],
|
||||
);
|
||||
|
||||
const openNoteEditor = (questionId: string) => {
|
||||
const st = getState(questionId);
|
||||
setExpandedNoteId(questionId);
|
||||
setNoteDraft(st.note);
|
||||
};
|
||||
|
||||
const saveNoteFor = async (questionId: string) => {
|
||||
const st = getState(questionId);
|
||||
await persistItem(questionId, { done: st.done, note: noteDraft.trim() });
|
||||
setExpandedNoteId(null);
|
||||
};
|
||||
|
||||
const copyText = async (text: string) => {
|
||||
try {
|
||||
await Clipboard.setStringAsync(text);
|
||||
Alert.alert('Copié', 'Collage dans Leboncoin ou Moteur Immo.');
|
||||
} catch {
|
||||
Alert.alert('Presse-papiers', 'Copie impossible sur cet appareil.');
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<View className="flex-1 items-center justify-center py-16">
|
||||
<ActivityIndicator size="large" color={UI.primary} />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ScrollView className="flex-1 px-3 pt-2" contentContainerStyle={{ paddingBottom: 120 }}>
|
||||
<Collapsible title="1. Mots-clés off-market">
|
||||
{OFF_MARKET_KEYWORDS.map((k) => (
|
||||
<View key={k.id} className="mb-3 rounded-xl border-2 p-3" style={{ borderColor: UI.border }}>
|
||||
<Text className="text-base font-bold" style={{ color: UI.text }}>
|
||||
{k.label}
|
||||
</Text>
|
||||
<Text selectable className="mt-2 font-mono text-sm leading-5" style={{ color: UI.text }}>
|
||||
{k.text}
|
||||
</Text>
|
||||
<Pressable
|
||||
onPress={() => void copyText(k.text)}
|
||||
className="mt-3 min-h-[48px] items-center justify-center rounded-xl"
|
||||
style={{ backgroundColor: UI.primary }}
|
||||
>
|
||||
<Text className="text-base font-bold text-white">Copier</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
))}
|
||||
<View className="mt-2 flex-row flex-wrap gap-3">
|
||||
<Pressable
|
||||
onPress={() => void Linking.openURL(LBC)}
|
||||
className="min-h-[52px] flex-1 min-w-[140px] items-center justify-center rounded-2xl border-2"
|
||||
style={{ borderColor: UI.warning, backgroundColor: '#FFFBEB' }}
|
||||
>
|
||||
<Text className="text-lg font-bold" style={{ color: '#B45309' }}>
|
||||
Leboncoin
|
||||
</Text>
|
||||
</Pressable>
|
||||
<Pressable
|
||||
onPress={() => void Linking.openURL(MOTEUR)}
|
||||
className="min-h-[52px] flex-1 min-w-[140px] items-center justify-center rounded-2xl border-2"
|
||||
style={{ borderColor: UI.warning, backgroundColor: '#FFFBEB' }}
|
||||
>
|
||||
<Text className="text-lg font-bold" style={{ color: '#B45309' }}>
|
||||
Moteur Immo
|
||||
</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
<View className="mt-4 rounded-2xl border-2 p-4" style={{ borderColor: UI.primary, backgroundColor: '#EFF6FF' }}>
|
||||
<Text className="text-base font-bold" style={{ color: UI.primary }}>
|
||||
Astuce maison de ville
|
||||
</Text>
|
||||
<Text className="mt-2 text-base leading-6" style={{ color: UI.text }}>
|
||||
Surface terrain max 100 m² + surface habitable min 150 m² → maisons sans jardin, moins de concurrence,
|
||||
idéal division.
|
||||
</Text>
|
||||
</View>
|
||||
</Collapsible>
|
||||
|
||||
<Collapsible title='2. Biens "fatigués" — décotes'>
|
||||
<Text className="text-base leading-6" style={{ color: UI.text }}>
|
||||
Trier par ancienneté sur Moteur Immo. 40+ mois en ligne + baisses répétées = vendeur motivé. Décote possible
|
||||
sous prix marché.
|
||||
</Text>
|
||||
<View className="mt-3 self-start rounded-xl px-3 py-2" style={{ backgroundColor: '#FEE2E2' }}>
|
||||
<Text className="text-lg font-bold" style={{ color: UI.danger }}>
|
||||
−10 % à −24 %
|
||||
</Text>
|
||||
</View>
|
||||
<Pressable
|
||||
onPress={() => void Linking.openURL(MOTEUR)}
|
||||
className="mt-4 min-h-[52px] items-center justify-center rounded-2xl"
|
||||
style={{ backgroundColor: UI.danger }}
|
||||
>
|
||||
<Text className="text-center text-lg font-bold text-white">Moteur Immo — tri par ancienneté</Text>
|
||||
</Pressable>
|
||||
</Collapsible>
|
||||
|
||||
<Collapsible title="3. Vérifier la division — articles">
|
||||
<View className="mb-3 rounded-2xl border-2 p-4" style={{ borderColor: UI.primary, backgroundColor: '#EFF6FF' }}>
|
||||
<Text className="text-lg font-bold" style={{ color: UI.primary }}>
|
||||
Article L151-36
|
||||
</Text>
|
||||
<Text className="mt-2 text-base leading-6" style={{ color: UI.text }}>
|
||||
1 place de parking max par logement créé en zone bien desservie.
|
||||
</Text>
|
||||
<Pressable
|
||||
onPress={() => void Linking.openURL(LEGI_L151_36)}
|
||||
className="mt-3 min-h-[48px] items-center justify-center rounded-xl bg-white"
|
||||
>
|
||||
<Text className="text-base font-bold" style={{ color: UI.primary }}>
|
||||
Ouvrir sur Légifrance →
|
||||
</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
<View className="rounded-2xl border-2 p-4" style={{ borderColor: UI.success, backgroundColor: '#F0FDF4' }}>
|
||||
<Text className="text-lg font-bold" style={{ color: UI.success }}>
|
||||
Article L152-6
|
||||
</Text>
|
||||
<Text className="mt-2 text-base leading-6" style={{ color: UI.text }}>
|
||||
Dans 500 m d'une gare ou métro → division sans obligation parking.
|
||||
</Text>
|
||||
<Pressable
|
||||
onPress={() => void Linking.openURL(LEGI_L152_6)}
|
||||
className="mt-3 min-h-[48px] items-center justify-center rounded-xl bg-white"
|
||||
>
|
||||
<Text className="text-base font-bold" style={{ color: UI.success }}>
|
||||
Ouvrir sur Légifrance →
|
||||
</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
</Collapsible>
|
||||
|
||||
<Collapsible title="4. Confirmer avec les pros">
|
||||
<Text className="mb-2 text-base" style={{ color: UI.textMuted }}>
|
||||
Coche après échange ; note la réponse pour ton dossier.
|
||||
</Text>
|
||||
{PROSPECTION_CHECKLIST.map((item) => {
|
||||
const st = getState(item.id);
|
||||
const expanded = expandedNoteId === item.id;
|
||||
return (
|
||||
<View key={item.id} className="mb-3 rounded-xl border-2 px-3 py-3" style={{ borderColor: UI.border }}>
|
||||
<Pressable
|
||||
accessibilityRole="checkbox"
|
||||
accessibilityState={{ checked: st.done }}
|
||||
onPress={() => void persistItem(item.id, { done: !st.done, note: st.note })}
|
||||
className="min-h-[48px] flex-row items-start gap-3"
|
||||
>
|
||||
<View
|
||||
className="mt-0.5 h-8 w-8 items-center justify-center rounded-lg border-2"
|
||||
style={{
|
||||
borderColor: st.done ? UI.success : UI.border,
|
||||
backgroundColor: st.done ? UI.success : UI.card,
|
||||
}}
|
||||
>
|
||||
{st.done ? <Text className="font-bold text-white">✓</Text> : null}
|
||||
</View>
|
||||
<View className="min-w-0 flex-1">
|
||||
<Text className="text-base font-bold" style={{ color: UI.textMuted }}>
|
||||
{item.label}
|
||||
</Text>
|
||||
<Text className="mt-1 text-base leading-6" style={{ color: UI.text }}>
|
||||
{item.question}
|
||||
</Text>
|
||||
</View>
|
||||
</Pressable>
|
||||
<Pressable
|
||||
onPress={() => (expanded ? setExpandedNoteId(null) : openNoteEditor(item.id))}
|
||||
className="mt-3 min-h-[44px] justify-center rounded-xl px-3"
|
||||
style={{ backgroundColor: '#F1F5F9' }}
|
||||
>
|
||||
<Text className="text-base font-semibold" style={{ color: UI.primary }}>
|
||||
{expanded ? 'Fermer la note' : st.note ? 'Modifier la note' : 'Ajouter une note'}
|
||||
</Text>
|
||||
</Pressable>
|
||||
{expanded ? (
|
||||
<View className="mt-2">
|
||||
<TextInput
|
||||
className="min-h-[88px] rounded-xl border-2 px-3 py-2 text-base"
|
||||
style={{ borderColor: UI.border, color: UI.text }}
|
||||
multiline
|
||||
textAlignVertical="top"
|
||||
value={noteDraft}
|
||||
onChangeText={setNoteDraft}
|
||||
placeholder="Réponse du pro…"
|
||||
placeholderTextColor={UI.textMuted}
|
||||
/>
|
||||
<Pressable
|
||||
onPress={() => void saveNoteFor(item.id)}
|
||||
disabled={isSaving}
|
||||
className="mt-2 min-h-[48px] items-center justify-center rounded-xl"
|
||||
style={{ backgroundColor: UI.primary }}
|
||||
>
|
||||
{isSaving ? (
|
||||
<ActivityIndicator color="#fff" />
|
||||
) : (
|
||||
<Text className="text-base font-bold text-white">Enregistrer la note</Text>
|
||||
)}
|
||||
</Pressable>
|
||||
</View>
|
||||
) : st.note ? (
|
||||
<Text className="mt-2 text-base italic" style={{ color: UI.textMuted }}>
|
||||
{st.note}
|
||||
</Text>
|
||||
) : null}
|
||||
</View>
|
||||
);
|
||||
})}
|
||||
</Collapsible>
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
||||
168
app/components/recherche/SecteurTab.tsx
Normal file
168
app/components/recherche/SecteurTab.tsx
Normal file
@ -0,0 +1,168 @@
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import type { ComponentProps } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import {
|
||||
ActivityIndicator,
|
||||
Alert,
|
||||
Keyboard,
|
||||
Linking,
|
||||
Pressable,
|
||||
ScrollView,
|
||||
Text,
|
||||
TextInput,
|
||||
View,
|
||||
} from 'react-native';
|
||||
|
||||
import {
|
||||
dvfSearchUrl,
|
||||
meilleursAgentsUrlForVille,
|
||||
SECTOR_TOOLS,
|
||||
type SectorTool,
|
||||
} from '@/constants/rechercheMarche';
|
||||
import { UI } from '@/constants/uiTheme';
|
||||
import { useAnalyseSecteurForVille, useSaveAnalyseSecteur } from '@/hooks/useAnalysesSecteur';
|
||||
import { formatPocketBaseError } from '@/utils/pocketbaseErrors';
|
||||
|
||||
type IonName = ComponentProps<typeof Ionicons>['name'];
|
||||
|
||||
function resolveToolUrl(tool: SectorTool, ville: string): string {
|
||||
if (tool.id === 'ma') return meilleursAgentsUrlForVille(ville);
|
||||
if (tool.id === 'dvf') return dvfSearchUrl(ville);
|
||||
return tool.url;
|
||||
}
|
||||
|
||||
export function SecteurTab() {
|
||||
const [ville, setVille] = useState('');
|
||||
const [notes, setNotes] = useState('');
|
||||
const secteurQ = useAnalyseSecteurForVille(ville);
|
||||
const saveMut = useSaveAnalyseSecteur();
|
||||
|
||||
useEffect(() => {
|
||||
if (secteurQ.data?.notes != null) setNotes(secteurQ.data.notes);
|
||||
}, [secteurQ.data?.id, secteurQ.data?.notes]);
|
||||
|
||||
const onOpenTool = async (tool: SectorTool) => {
|
||||
const url = resolveToolUrl(tool, ville);
|
||||
const ok = await Linking.canOpenURL(url);
|
||||
if (!ok) {
|
||||
Alert.alert('Lien', 'Impossible d’ouvrir ce lien sur cet appareil.');
|
||||
return;
|
||||
}
|
||||
void Linking.openURL(url);
|
||||
};
|
||||
|
||||
const onAnalyser = () => {
|
||||
const v = ville.trim();
|
||||
if (!v) {
|
||||
Alert.alert('Ville', 'Indique une ville ou une commune pour cadrer l’analyse.');
|
||||
return;
|
||||
}
|
||||
Keyboard.dismiss();
|
||||
Alert.alert(
|
||||
'Secteur',
|
||||
`Analyse pour « ${v} » : utilise les outils ci-dessous (données externes), puis consigne tes notes en bas de page.`,
|
||||
);
|
||||
};
|
||||
|
||||
const onSaveNotes = async () => {
|
||||
const v = ville.trim();
|
||||
if (!v) {
|
||||
Alert.alert('Ville', 'Renseigne la ville avant de sauvegarder les notes.');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await saveMut.mutateAsync({ ville: v, notes });
|
||||
} catch (e) {
|
||||
Alert.alert('Erreur', formatPocketBaseError(e));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<ScrollView className="flex-1 px-3 pt-2" contentContainerStyle={{ paddingBottom: 120 }} keyboardShouldPersistTaps="handled">
|
||||
<Text className="text-base font-semibold" style={{ color: UI.text }}>
|
||||
Ville / commune
|
||||
</Text>
|
||||
<TextInput
|
||||
className="mt-2 rounded-2xl border-2 px-4 text-lg"
|
||||
style={{ borderColor: UI.border, color: UI.text, minHeight: 52, backgroundColor: UI.card }}
|
||||
placeholder="Ex. Lyon 3e, Bordeaux…"
|
||||
placeholderTextColor={UI.textMuted}
|
||||
value={ville}
|
||||
onChangeText={setVille}
|
||||
onSubmitEditing={onAnalyser}
|
||||
/>
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
onPress={onAnalyser}
|
||||
className="mt-3 min-h-[52px] items-center justify-center rounded-2xl active:opacity-90"
|
||||
style={{ backgroundColor: UI.primary }}
|
||||
>
|
||||
<Text className="text-lg font-bold text-white">Analyser</Text>
|
||||
</Pressable>
|
||||
|
||||
<Text className="mb-2 mt-8 text-xl font-bold" style={{ color: UI.text }}>
|
||||
Outils marché
|
||||
</Text>
|
||||
<Text className="mb-3 text-base" style={{ color: UI.textMuted }}>
|
||||
Données externes — ouverture dans le navigateur.
|
||||
</Text>
|
||||
{SECTOR_TOOLS.map((tool) => (
|
||||
<Pressable
|
||||
key={tool.id}
|
||||
accessibilityRole="button"
|
||||
onPress={() => void onOpenTool(tool)}
|
||||
className="mb-3 flex-row items-center rounded-2xl border-2 bg-white p-4 active:opacity-90"
|
||||
style={{ borderColor: UI.border }}
|
||||
>
|
||||
<View
|
||||
className="mr-3 h-12 w-12 items-center justify-center rounded-xl"
|
||||
style={{ backgroundColor: '#EFF6FF' }}
|
||||
>
|
||||
<Ionicons name={tool.icon as IonName} size={24} color={UI.primary} />
|
||||
</View>
|
||||
<View className="min-w-0 flex-1">
|
||||
<Text className="text-lg font-bold" style={{ color: UI.text }}>
|
||||
{tool.title}
|
||||
</Text>
|
||||
<Text className="mt-1 text-base leading-5" style={{ color: UI.textMuted }}>
|
||||
{tool.description}
|
||||
</Text>
|
||||
<View className="mt-2 self-start rounded-full px-2 py-1" style={{ backgroundColor: '#E0E7FF' }}>
|
||||
<Text className="text-xs font-bold" style={{ color: UI.primary }}>
|
||||
Externe →
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</Pressable>
|
||||
))}
|
||||
|
||||
<Text className="mb-2 mt-4 text-xl font-bold" style={{ color: UI.text }}>
|
||||
Notes secteur
|
||||
</Text>
|
||||
{secteurQ.isFetching ? <ActivityIndicator color={UI.primary} className="mb-2" /> : null}
|
||||
<TextInput
|
||||
className="min-h-[140px] rounded-2xl border-2 px-4 py-3 text-lg"
|
||||
style={{ borderColor: UI.border, color: UI.text, backgroundColor: UI.card }}
|
||||
multiline
|
||||
textAlignVertical="top"
|
||||
placeholder="Synthèse prix, tension, typologie…"
|
||||
placeholderTextColor={UI.textMuted}
|
||||
value={notes}
|
||||
onChangeText={setNotes}
|
||||
/>
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
onPress={() => void onSaveNotes()}
|
||||
disabled={saveMut.isPending}
|
||||
className="mt-3 min-h-[52px] items-center justify-center rounded-2xl active:opacity-90"
|
||||
style={{ backgroundColor: UI.success }}
|
||||
>
|
||||
{saveMut.isPending ? (
|
||||
<ActivityIndicator color="#fff" />
|
||||
) : (
|
||||
<Text className="text-lg font-bold text-white">Sauvegarder</Text>
|
||||
)}
|
||||
</Pressable>
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
||||
34
app/components/ui/DashboardSkeleton.tsx
Normal file
34
app/components/ui/DashboardSkeleton.tsx
Normal file
@ -0,0 +1,34 @@
|
||||
import { View } from 'react-native';
|
||||
|
||||
import { UI } from '@/constants/uiTheme';
|
||||
|
||||
export function DashboardSkeleton() {
|
||||
return (
|
||||
<View className="p-4" accessibilityLabel="Chargement du tableau de bord">
|
||||
<View className="mb-2 h-7 w-[55%] rounded-lg" style={{ backgroundColor: '#E2E8F0' }} />
|
||||
<View className="mb-4 h-24 rounded-2xl border" style={{ borderColor: UI.border, backgroundColor: UI.card }} />
|
||||
<View className="mb-2 h-7 w-[40%] rounded-lg" style={{ backgroundColor: '#E2E8F0' }} />
|
||||
<View className="mb-4 flex-row gap-3">
|
||||
{[1, 2, 3].map((k) => (
|
||||
<View
|
||||
key={k}
|
||||
className="h-24 flex-1 rounded-2xl border"
|
||||
style={{ borderColor: UI.border, backgroundColor: UI.card }}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
<View className="mb-2 h-7 w-[35%] rounded-lg" style={{ backgroundColor: '#E2E8F0' }} />
|
||||
<View className="mb-4 h-20 rounded-2xl border" style={{ borderColor: UI.border, backgroundColor: '#F8FAFC' }} />
|
||||
<View className="mb-2 h-7 w-[45%] rounded-lg" style={{ backgroundColor: '#E2E8F0' }} />
|
||||
<View className="gap-3">
|
||||
{[1, 2, 3].map((k) => (
|
||||
<View
|
||||
key={k}
|
||||
className="h-16 rounded-2xl border"
|
||||
style={{ borderColor: UI.border, backgroundColor: UI.card }}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
57
app/components/ui/EmptyState.tsx
Normal file
57
app/components/ui/EmptyState.tsx
Normal file
@ -0,0 +1,57 @@
|
||||
import { Link, type Href } from 'expo-router';
|
||||
import { Pressable, Text, View } from 'react-native';
|
||||
|
||||
import { UI } from '@/constants/uiTheme';
|
||||
|
||||
export type EmptyStateProps = {
|
||||
title: string;
|
||||
description?: string;
|
||||
actionLabel?: string;
|
||||
actionHref?: Href;
|
||||
onAction?: () => void;
|
||||
};
|
||||
|
||||
export function EmptyState({ title, description, actionLabel, actionHref, onAction }: EmptyStateProps) {
|
||||
const actionNode =
|
||||
actionLabel && actionHref != null ? (
|
||||
<Link href={actionHref} asChild>
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
className="mt-6 min-h-[52px] w-full max-w-sm items-center justify-center rounded-2xl px-5 active:opacity-90"
|
||||
style={{ backgroundColor: UI.primary }}
|
||||
>
|
||||
<Text className="text-center text-lg font-semibold text-white">{actionLabel}</Text>
|
||||
</Pressable>
|
||||
</Link>
|
||||
) : actionLabel && onAction ? (
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
onPress={onAction}
|
||||
className="mt-6 min-h-[52px] w-full max-w-sm items-center justify-center rounded-2xl px-5 active:opacity-90"
|
||||
style={{ backgroundColor: UI.primary }}
|
||||
>
|
||||
<Text className="text-center text-lg font-semibold text-white">{actionLabel}</Text>
|
||||
</Pressable>
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<View className="items-center justify-center px-5 py-10">
|
||||
<Text
|
||||
className="text-center text-xl font-bold leading-7"
|
||||
style={{ color: UI.text }}
|
||||
accessibilityRole="header"
|
||||
>
|
||||
{title}
|
||||
</Text>
|
||||
{description ? (
|
||||
<Text
|
||||
className="mt-3 max-w-sm text-center text-base leading-6"
|
||||
style={{ color: UI.textMuted }}
|
||||
>
|
||||
{description}
|
||||
</Text>
|
||||
) : null}
|
||||
{actionNode}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
39
app/components/ui/ListSkeleton.tsx
Normal file
39
app/components/ui/ListSkeleton.tsx
Normal file
@ -0,0 +1,39 @@
|
||||
import { View } from 'react-native';
|
||||
|
||||
import { UI } from '@/constants/uiTheme';
|
||||
|
||||
type Props = {
|
||||
rows?: number;
|
||||
/** Hauteur des cartes (liste verticale). */
|
||||
variant?: 'card' | 'compact';
|
||||
};
|
||||
|
||||
export function ListSkeleton({ rows = 6, variant = 'card' }: Props) {
|
||||
const gap = variant === 'compact' ? 10 : 14;
|
||||
const pad = variant === 'compact' ? 12 : 16;
|
||||
return (
|
||||
<View className="px-3 pt-3" accessibilityLabel="Chargement en cours">
|
||||
{Array.from({ length: rows }).map((_, i) => (
|
||||
<View
|
||||
key={i}
|
||||
className="mb-3 rounded-2xl border bg-white"
|
||||
style={{
|
||||
borderColor: UI.border,
|
||||
padding: pad,
|
||||
gap,
|
||||
}}
|
||||
>
|
||||
<View
|
||||
className="h-5 rounded-md"
|
||||
style={{ width: '62%', backgroundColor: '#E2E8F0' }}
|
||||
/>
|
||||
<View className="h-4 rounded-md" style={{ width: '92%', backgroundColor: '#F1F5F9' }} />
|
||||
<View className="h-4 rounded-md" style={{ width: '78%', backgroundColor: '#F1F5F9' }} />
|
||||
{variant === 'card' ? (
|
||||
<View className="h-4 rounded-md" style={{ width: '44%', backgroundColor: '#E2E8F0' }} />
|
||||
) : null}
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
37
app/components/ui/PipelineSkeleton.tsx
Normal file
37
app/components/ui/PipelineSkeleton.tsx
Normal file
@ -0,0 +1,37 @@
|
||||
import { ScrollView, View } from 'react-native';
|
||||
|
||||
import { UI } from '@/constants/uiTheme';
|
||||
|
||||
const COL_W = 216;
|
||||
|
||||
export function PipelineSkeleton() {
|
||||
return (
|
||||
<ScrollView
|
||||
horizontal
|
||||
className="flex-1"
|
||||
contentContainerStyle={{ padding: 12, paddingBottom: 96 }}
|
||||
accessibilityLabel="Chargement du pipeline"
|
||||
>
|
||||
{Array.from({ length: 4 }).map((_, col) => (
|
||||
<View
|
||||
key={col}
|
||||
className="mr-3 rounded-2xl border bg-white p-3"
|
||||
style={{ width: COL_W, borderColor: UI.border }}
|
||||
>
|
||||
<View className="mb-3 h-6 w-[85%] rounded-md" style={{ backgroundColor: '#E2E8F0' }} />
|
||||
<View className="mb-2 h-3 w-10 rounded" style={{ backgroundColor: '#CBD5E1' }} />
|
||||
{Array.from({ length: 3 }).map((__, row) => (
|
||||
<View
|
||||
key={row}
|
||||
className="mb-2 rounded-xl border p-3"
|
||||
style={{ borderColor: '#E2E8F0', backgroundColor: '#F8FAFC' }}
|
||||
>
|
||||
<View className="mb-2 h-4 w-[90%] rounded" style={{ backgroundColor: '#E2E8F0' }} />
|
||||
<View className="h-3 w-[55%] rounded" style={{ backgroundColor: '#F1F5F9' }} />
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
))}
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
||||
103
app/constants/rechercheMarche.ts
Normal file
103
app/constants/rechercheMarche.ts
Normal file
@ -0,0 +1,103 @@
|
||||
export type SectorTool = {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
url: string;
|
||||
/** Nom Ionicons (outline). */
|
||||
icon: string;
|
||||
};
|
||||
|
||||
export const SECTOR_TOOLS: SectorTool[] = [
|
||||
{
|
||||
id: 'ma',
|
||||
title: 'Prix au m² — Meilleurs Agents',
|
||||
description: 'Prix moyen, évolution, comparaison communes',
|
||||
url: 'https://www.meilleursagents.com/prix-immobilier/',
|
||||
icon: 'stats-chart-outline',
|
||||
},
|
||||
{
|
||||
id: 'dvf',
|
||||
title: 'Transactions réelles — DVF',
|
||||
description: 'Prix de vente réels, toutes transactions',
|
||||
url: 'https://dvf.etalab.gouv.fr/',
|
||||
icon: 'document-text-outline',
|
||||
},
|
||||
{
|
||||
id: 'moteur',
|
||||
title: 'Annonces actives — Moteur Immo',
|
||||
description: 'Agrège tous les sites, historique baisses de prix',
|
||||
url: 'https://www.moteurimmo.fr/',
|
||||
icon: 'search-outline',
|
||||
},
|
||||
{
|
||||
id: 'insee',
|
||||
title: 'Données INSEE',
|
||||
description: 'Revenus médians, démographie, vacance logements',
|
||||
url: 'https://www.insee.fr/fr/statistiques/zones/1405599',
|
||||
icon: 'business-outline',
|
||||
},
|
||||
{
|
||||
id: 'geo',
|
||||
title: 'Carte & PLU — Géoportail',
|
||||
description: 'Transports, écoles, zones PLU, foncier',
|
||||
url: 'https://www.geoportail.gouv.fr/',
|
||||
icon: 'map-outline',
|
||||
},
|
||||
{
|
||||
id: 'pappers',
|
||||
title: 'Concurrence MDB — Pappers Immo',
|
||||
description: 'Historique transactions, propriétaires, sociétés actives',
|
||||
url: 'https://immobilier.pappers.fr/',
|
||||
icon: 'people-outline',
|
||||
},
|
||||
];
|
||||
|
||||
export const OFF_MARKET_KEYWORDS: { id: string; label: string; text: string }[] = [
|
||||
{
|
||||
id: 'k1',
|
||||
label: 'Pack division studios',
|
||||
text: '"studio" + "lots réunis" + "vendu libre" + "deux studios"',
|
||||
},
|
||||
{
|
||||
id: 'k2',
|
||||
label: 'Multi-lots / réunion',
|
||||
text: '"appartements réunis" + "configuration possible" + "immeuble" + "multi-lots"',
|
||||
},
|
||||
];
|
||||
|
||||
export const PROSPECTION_CHECKLIST: { id: string; label: string; question: string }[] = [
|
||||
{ id: 'agent_immo', label: 'Agents immo', question: 'À combien ça part vraiment ? C’est rapide à vendre ?' },
|
||||
{ id: 'notaire', label: 'Notaires', question: 'Quelles tendances dans vos actes récents ?' },
|
||||
{ id: 'geometre', label: 'Géomètres', question: 'Divisions fréquentes sur ce secteur ?' },
|
||||
{ id: 'banquier', label: 'Banquiers', question: 'Vous financez souvent des projets ici ?' },
|
||||
{ id: 'mdb', label: 'Autres MDB', question: 'Tu trouves facilement sur ce secteur ?' },
|
||||
{ id: 'artisan', label: 'Artisans', question: 'Vous travaillez beaucoup dans ce quartier ?' },
|
||||
];
|
||||
|
||||
export const CATEGORIE_NOTES_PROSPECTION = 'recherche_opportunites';
|
||||
|
||||
export const LEGI_L151_36 =
|
||||
'https://www.legifrance.gouv.fr/codes/article_lc/LEGIARTI000031211239';
|
||||
export const LEGI_L152_6 =
|
||||
'https://www.legifrance.gouv.fr/codes/article_lc/LEGIARTI000043978020/2021-08-25';
|
||||
|
||||
export function meilleursAgentsUrlForVille(ville: string): string {
|
||||
const v = ville.trim();
|
||||
if (!v) return SECTOR_TOOLS[0].url;
|
||||
return `https://www.meilleursagents.com/prix-immobilier/${encodeURIComponent(v)}/`;
|
||||
}
|
||||
|
||||
export function dvfSearchUrl(ville: string): string {
|
||||
const v = ville.trim();
|
||||
if (!v) return 'https://dvf.etalab.gouv.fr/';
|
||||
return `https://dvf.etalab.gouv.fr/?q=${encodeURIComponent(v)}`;
|
||||
}
|
||||
|
||||
export function geoportailBienUrl(lat: number, lon: number): string {
|
||||
return `https://www.geoportail.gouv.fr/?lon=${lon}&lat=${lat}&z=17`;
|
||||
}
|
||||
|
||||
export function marginPctFromPrices(achat: number, revente: number): number | null {
|
||||
if (!Number.isFinite(achat) || !Number.isFinite(revente) || achat <= 0) return null;
|
||||
return ((revente - achat) / achat) * 100;
|
||||
}
|
||||
12
app/constants/uiTheme.ts
Normal file
12
app/constants/uiTheme.ts
Normal file
@ -0,0 +1,12 @@
|
||||
/** Palette terrain / extérieur — contraste élevé, actions distinctes. */
|
||||
export const UI = {
|
||||
primary: '#1D4ED8',
|
||||
success: '#16A34A',
|
||||
warning: '#D97706',
|
||||
danger: '#DC2626',
|
||||
screen: '#F1F5F9',
|
||||
card: '#FFFFFF',
|
||||
text: '#0F172A',
|
||||
textMuted: '#475569',
|
||||
border: '#CBD5E1',
|
||||
} as const;
|
||||
@ -16,6 +16,8 @@ export type AnalyseFormInput = {
|
||||
taxe_fonciere_annuelle?: number;
|
||||
charges_copropriete_mensuelle?: number;
|
||||
prix_revente_cible?: number;
|
||||
/** Prix de revente estimé au m² (marché). */
|
||||
prix_revente_m2?: number;
|
||||
frais_agence_vente_pct?: number;
|
||||
taux_impot?: number;
|
||||
};
|
||||
@ -98,6 +100,9 @@ function formToRecord(form: AnalyseFormInput, calc: AnalyseCalculated): Record<s
|
||||
taxe_fonciere_annuelle: form.taxe_fonciere_annuelle,
|
||||
charges_copropriete_mensuelle: form.charges_copropriete_mensuelle,
|
||||
prix_revente_cible: form.prix_revente_cible,
|
||||
...(form.prix_revente_m2 !== undefined && form.prix_revente_m2 !== null
|
||||
? { prix_revente_m2: form.prix_revente_m2 }
|
||||
: {}),
|
||||
frais_agence_vente_pct: form.frais_agence_vente_pct,
|
||||
taux_impot: form.taux_impot,
|
||||
marge_brute: calc.marge_brute,
|
||||
@ -153,6 +158,30 @@ export function useAnalyse(bienId: string | undefined) {
|
||||
},
|
||||
});
|
||||
|
||||
const patchMutation = useMutation({
|
||||
mutationFn: async (patch: { prix_revente_m2?: number | null }) => {
|
||||
if (!bienId || !uid) throw new Error('Données manquantes');
|
||||
const res = await pb.collection('analyses_financieres').getList<AnalyseFinanciereRecord>(1, 1, {
|
||||
filter: `bien="${bienId}" && user="${uid}"`,
|
||||
sort: '-id',
|
||||
});
|
||||
const existing = res.items[0];
|
||||
if (existing) {
|
||||
return pb.collection('analyses_financieres').update<AnalyseFinanciereRecord>(existing.id, patch);
|
||||
}
|
||||
return pb.collection('analyses_financieres').create<AnalyseFinanciereRecord>({
|
||||
user: uid,
|
||||
bien: bienId,
|
||||
type_bien_fiscal: 'ancien',
|
||||
...patch,
|
||||
});
|
||||
},
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({ queryKey: ['analyse_financiere', bienId, uid] });
|
||||
void queryClient.invalidateQueries({ queryKey: ['bien_detail', bienId] });
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
analyse: query.data ?? null,
|
||||
isLoading: query.isPending,
|
||||
@ -161,6 +190,8 @@ export function useAnalyse(bienId: string | undefined) {
|
||||
fetchAnalyse: query.refetch,
|
||||
saveAnalyse: saveMutation.mutateAsync,
|
||||
isSaving: saveMutation.isPending,
|
||||
patchAnalyse: patchMutation.mutateAsync,
|
||||
isPatching: patchMutation.isPending,
|
||||
calculateResults,
|
||||
};
|
||||
}
|
||||
|
||||
60
app/hooks/useAnalysesSecteur.ts
Normal file
60
app/hooks/useAnalysesSecteur.ts
Normal file
@ -0,0 +1,60 @@
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
|
||||
import { getCurrentUserId, pb } from '@/services/pocketbase';
|
||||
import type { AnalyseSecteurRecord } from '@/types/collections';
|
||||
|
||||
function escapeFilterValue(s: string): string {
|
||||
return s.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
|
||||
}
|
||||
|
||||
export function useAnalyseSecteurForVille(ville: string) {
|
||||
const uid = getCurrentUserId();
|
||||
const key = ville.trim().toLowerCase();
|
||||
|
||||
return useQuery({
|
||||
queryKey: ['analyse_secteur', uid, key],
|
||||
queryFn: async (): Promise<AnalyseSecteurRecord | null> => {
|
||||
if (!uid || !key) return null;
|
||||
const esc = escapeFilterValue(ville.trim());
|
||||
const list = await pb.collection('analyses_secteur').getFullList<AnalyseSecteurRecord>({
|
||||
filter: `user="${uid}" && ville="${esc}"`,
|
||||
sort: '-updated',
|
||||
});
|
||||
return list[0] ?? null;
|
||||
},
|
||||
enabled: Boolean(uid && key),
|
||||
});
|
||||
}
|
||||
|
||||
export function useSaveAnalyseSecteur() {
|
||||
const uid = getCurrentUserId();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (payload: { ville: string; notes: string }) => {
|
||||
if (!uid) throw new Error('Non connecté');
|
||||
const ville = payload.ville.trim();
|
||||
if (!ville) throw new Error('Ville requise');
|
||||
const esc = escapeFilterValue(ville);
|
||||
const existing = await pb.collection('analyses_secteur').getFullList<AnalyseSecteurRecord>({
|
||||
filter: `user="${uid}" && ville="${esc}"`,
|
||||
sort: '-updated',
|
||||
});
|
||||
const row = existing[0];
|
||||
if (row) {
|
||||
return pb.collection('analyses_secteur').update<AnalyseSecteurRecord>(row.id, {
|
||||
notes: payload.notes,
|
||||
});
|
||||
}
|
||||
return pb.collection('analyses_secteur').create<AnalyseSecteurRecord>({
|
||||
user: uid,
|
||||
ville,
|
||||
notes: payload.notes,
|
||||
});
|
||||
},
|
||||
onSuccess: (_, v) => {
|
||||
const key = v.ville.trim().toLowerCase();
|
||||
void queryClient.invalidateQueries({ queryKey: ['analyse_secteur', uid, key] });
|
||||
},
|
||||
});
|
||||
}
|
||||
79
app/hooks/useGrillePrix.ts
Normal file
79
app/hooks/useGrillePrix.ts
Normal file
@ -0,0 +1,79 @@
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
|
||||
import { marginPctFromPrices } from '@/constants/rechercheMarche';
|
||||
import { getCurrentUserId, pb } from '@/services/pocketbase';
|
||||
import type { GrillePrixEtat, GrillePrixRecord, GrillePrixTypeBien } from '@/types/collections';
|
||||
|
||||
export type GrillePrixInput = {
|
||||
type_bien: GrillePrixTypeBien;
|
||||
etat: GrillePrixEtat;
|
||||
prix_achat_m2: number;
|
||||
prix_revente_m2: number;
|
||||
ville?: string;
|
||||
};
|
||||
|
||||
function withMargin(input: GrillePrixInput): Record<string, unknown> {
|
||||
const marge = marginPctFromPrices(input.prix_achat_m2, input.prix_revente_m2);
|
||||
return {
|
||||
type_bien: input.type_bien,
|
||||
etat: input.etat,
|
||||
prix_achat_m2: input.prix_achat_m2,
|
||||
prix_revente_m2: input.prix_revente_m2,
|
||||
ville: input.ville?.trim() || undefined,
|
||||
marge_estimee_pct: marge != null ? Math.round(marge * 100) / 100 : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export function useGrillePrix() {
|
||||
const uid = getCurrentUserId();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: ['grille_prix', uid],
|
||||
queryFn: async () => {
|
||||
if (!uid) return [] as GrillePrixRecord[];
|
||||
return pb.collection('grille_prix').getFullList<GrillePrixRecord>({
|
||||
filter: `user="${uid}"`,
|
||||
sort: '-updated',
|
||||
});
|
||||
},
|
||||
enabled: Boolean(uid),
|
||||
});
|
||||
|
||||
const invalidate = () => {
|
||||
void queryClient.invalidateQueries({ queryKey: ['grille_prix', uid] });
|
||||
};
|
||||
|
||||
const createRow = useMutation({
|
||||
mutationFn: async (input: GrillePrixInput) => {
|
||||
if (!uid) throw new Error('Non connecté');
|
||||
return pb.collection('grille_prix').create<GrillePrixRecord>({
|
||||
user: uid,
|
||||
...withMargin(input),
|
||||
});
|
||||
},
|
||||
onSuccess: invalidate,
|
||||
});
|
||||
|
||||
const updateRow = useMutation({
|
||||
mutationFn: async ({ id, input }: { id: string; input: GrillePrixInput }) => {
|
||||
return pb.collection('grille_prix').update<GrillePrixRecord>(id, withMargin(input));
|
||||
},
|
||||
onSuccess: invalidate,
|
||||
});
|
||||
|
||||
const deleteRow = useMutation({
|
||||
mutationFn: async (id: string) => pb.collection('grille_prix').delete(id),
|
||||
onSuccess: invalidate,
|
||||
});
|
||||
|
||||
return {
|
||||
rows: query.data ?? [],
|
||||
isLoading: query.isPending,
|
||||
error: query.error,
|
||||
createRow: createRow.mutateAsync,
|
||||
updateRow: updateRow.mutateAsync,
|
||||
deleteRow: deleteRow.mutateAsync,
|
||||
isMutating: createRow.isPending || updateRow.isPending || deleteRow.isPending,
|
||||
};
|
||||
}
|
||||
90
app/hooks/useNotesProspectionRecherche.ts
Normal file
90
app/hooks/useNotesProspectionRecherche.ts
Normal file
@ -0,0 +1,90 @@
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
|
||||
import { CATEGORIE_NOTES_PROSPECTION } from '@/constants/rechercheMarche';
|
||||
import { getCurrentUserId, pb } from '@/services/pocketbase';
|
||||
import type { NoteProspectionRecord } from '@/types/collections';
|
||||
|
||||
function escapePb(s: string): string {
|
||||
return s.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
|
||||
}
|
||||
|
||||
export type ChecklistPersist = { done: boolean; note: string };
|
||||
|
||||
function encodeReponse(data: ChecklistPersist): string {
|
||||
return JSON.stringify(data);
|
||||
}
|
||||
|
||||
function decodeReponse(raw?: string | null): ChecklistPersist {
|
||||
if (!raw?.trim()) return { done: false, note: '' };
|
||||
try {
|
||||
const v = JSON.parse(raw) as unknown;
|
||||
if (v && typeof v === 'object' && 'done' in v) {
|
||||
const o = v as { done?: boolean; note?: string };
|
||||
return { done: Boolean(o.done), note: typeof o.note === 'string' ? o.note : '' };
|
||||
}
|
||||
} catch {
|
||||
return { done: false, note: raw };
|
||||
}
|
||||
return { done: false, note: '' };
|
||||
}
|
||||
|
||||
export function useNotesProspectionRecherche() {
|
||||
const uid = getCurrentUserId();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: ['notes_prospection', uid, CATEGORIE_NOTES_PROSPECTION],
|
||||
queryFn: async () => {
|
||||
if (!uid) return [] as NoteProspectionRecord[];
|
||||
return pb.collection('notes_prospection').getFullList<NoteProspectionRecord>({
|
||||
filter: `user="${uid}" && categorie="${CATEGORIE_NOTES_PROSPECTION}"`,
|
||||
sort: '-id',
|
||||
});
|
||||
},
|
||||
enabled: Boolean(uid),
|
||||
});
|
||||
|
||||
const byQuestionId = (questionId: string): NoteProspectionRecord | undefined =>
|
||||
query.data?.find((r) => r.question === questionId);
|
||||
|
||||
const upsert = useMutation({
|
||||
mutationFn: async (payload: { questionId: string; data: ChecklistPersist }) => {
|
||||
if (!uid) throw new Error('Non connecté');
|
||||
const reponse = encodeReponse(payload.data);
|
||||
const qe = escapePb(payload.questionId);
|
||||
const existing = await pb.collection('notes_prospection').getFullList<NoteProspectionRecord>({
|
||||
filter: `user="${uid}" && categorie="${escapePb(CATEGORIE_NOTES_PROSPECTION)}" && question="${qe}"`,
|
||||
});
|
||||
const row = existing[0];
|
||||
if (row) {
|
||||
return pb.collection('notes_prospection').update<NoteProspectionRecord>(row.id, {
|
||||
reponse,
|
||||
categorie: CATEGORIE_NOTES_PROSPECTION,
|
||||
});
|
||||
}
|
||||
return pb.collection('notes_prospection').create<NoteProspectionRecord>({
|
||||
user: uid,
|
||||
question: payload.questionId,
|
||||
reponse,
|
||||
categorie: CATEGORIE_NOTES_PROSPECTION,
|
||||
});
|
||||
},
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: ['notes_prospection', uid, CATEGORIE_NOTES_PROSPECTION],
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
rows: query.data ?? [],
|
||||
isLoading: query.isPending,
|
||||
error: query.error,
|
||||
getState(questionId: string): ChecklistPersist {
|
||||
const row = byQuestionId(questionId);
|
||||
return decodeReponse(row?.reponse);
|
||||
},
|
||||
saveChecklistItem: upsert.mutateAsync,
|
||||
isSaving: upsert.isPending,
|
||||
};
|
||||
}
|
||||
12
app/package-lock.json
generated
12
app/package-lock.json
generated
@ -14,6 +14,7 @@
|
||||
"@react-navigation/native": "^7.1.8",
|
||||
"@tanstack/react-query": "^5.90.0",
|
||||
"expo": "~54.0.0",
|
||||
"expo-clipboard": "~8.0.0",
|
||||
"expo-constants": "~18.0.13",
|
||||
"expo-document-picker": "~14.0.8",
|
||||
"expo-font": "~14.0.11",
|
||||
@ -4404,6 +4405,17 @@
|
||||
"react-native": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/expo-clipboard": {
|
||||
"version": "8.0.8",
|
||||
"resolved": "https://registry.npmjs.org/expo-clipboard/-/expo-clipboard-8.0.8.tgz",
|
||||
"integrity": "sha512-VKoBkHIpZZDJTB0jRO4/PZskHdMNOEz3P/41tmM6fDuODMpqhvyWK053X0ebspkxiawJX9lX33JXHBCvVsTTOA==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"expo": "*",
|
||||
"react": "*",
|
||||
"react-native": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/expo-constants": {
|
||||
"version": "18.0.13",
|
||||
"resolved": "https://registry.npmjs.org/expo-constants/-/expo-constants-18.0.13.tgz",
|
||||
|
||||
@ -16,6 +16,7 @@
|
||||
"@react-navigation/native": "^7.1.8",
|
||||
"@tanstack/react-query": "^5.90.0",
|
||||
"expo": "~54.0.0",
|
||||
"expo-clipboard": "~8.0.0",
|
||||
"expo-constants": "~18.0.13",
|
||||
"expo-document-picker": "~14.0.8",
|
||||
"expo-font": "~14.0.11",
|
||||
|
||||
@ -116,6 +116,34 @@ export type AnalyseFinanciereRecord = RecordModel & {
|
||||
marge_nette?: number;
|
||||
marge_nette_pct?: number;
|
||||
notes?: string;
|
||||
/** Prix de revente estimé au m² (référence marché / grille perso). */
|
||||
prix_revente_m2?: number;
|
||||
};
|
||||
|
||||
export type AnalyseSecteurRecord = RecordModel & {
|
||||
user: string;
|
||||
ville: string;
|
||||
notes?: string;
|
||||
};
|
||||
|
||||
export type NoteProspectionRecord = RecordModel & {
|
||||
user: string;
|
||||
question: string;
|
||||
reponse?: string;
|
||||
categorie?: string;
|
||||
};
|
||||
|
||||
export type GrillePrixTypeBien = 'appartement' | 'maison' | 'immeuble';
|
||||
export type GrillePrixEtat = 'bon_etat' | 'a_renover' | 'travaux_lourds';
|
||||
|
||||
export type GrillePrixRecord = RecordModel & {
|
||||
user: string;
|
||||
type_bien: GrillePrixTypeBien;
|
||||
etat: GrillePrixEtat;
|
||||
prix_achat_m2: number;
|
||||
prix_revente_m2: number;
|
||||
marge_estimee_pct?: number;
|
||||
ville?: string;
|
||||
};
|
||||
|
||||
export type VisiteRecord = RecordModel & {
|
||||
|
||||
@ -1,5 +1,3 @@
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
pocketbase:
|
||||
image: ghcr.io/muchobien/pocketbase:latest
|
||||
|
||||
@ -8,13 +8,42 @@ migrate(
|
||||
(app) => {
|
||||
const usersId = app.findCollectionByNameOrId("users").id;
|
||||
|
||||
function loadOrCreate(name, factory) {
|
||||
/** Retrouve une collection même si findCollectionByNameOrId échoue (casse, cache, image Docker). */
|
||||
function findExistingCollection(name) {
|
||||
try {
|
||||
return app.findCollectionByNameOrId(name);
|
||||
} catch {
|
||||
} catch (_) {}
|
||||
try {
|
||||
const all = app.findAllCollections();
|
||||
const want = String(name).toLowerCase();
|
||||
for (let i = 0; i < all.length; i++) {
|
||||
const c = all[i];
|
||||
if (c && c.name && String(c.name).toLowerCase() === want) {
|
||||
return c;
|
||||
}
|
||||
}
|
||||
} catch (_) {}
|
||||
return null;
|
||||
}
|
||||
|
||||
function loadOrCreate(name, factory) {
|
||||
const existing = findExistingCollection(name);
|
||||
if (existing != null) {
|
||||
return existing;
|
||||
}
|
||||
try {
|
||||
const col = factory();
|
||||
app.save(col);
|
||||
return col;
|
||||
} catch (err) {
|
||||
const msg = String(err && err.value ? err.value : err && err.message ? err.message : err);
|
||||
if (msg.includes("unique") || msg.includes("Unique")) {
|
||||
const again = findExistingCollection(name);
|
||||
if (again != null) {
|
||||
return again;
|
||||
}
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
176
pocketbase/pb_migrations/1746100000_module_recherche.js
Normal file
176
pocketbase/pb_migrations/1746100000_module_recherche.js
Normal file
@ -0,0 +1,176 @@
|
||||
/// <reference path="../pb_data/types.d.ts" />
|
||||
/**
|
||||
* Module Recherche & Analyse marché + champ prix revente au m² sur l'analyse financière.
|
||||
*
|
||||
* Les règles API doivent être assignées APRÈS fields.add(...): sinon le validateur ne voit
|
||||
* pas les champs (erreurs "unknown field user" / "failed to resolve field owner").
|
||||
*/
|
||||
migrate(
|
||||
(app) => {
|
||||
const usersCol = app.findCollectionByNameOrId("users");
|
||||
let usersId = "";
|
||||
if (usersCol) {
|
||||
const a = usersCol.id != null && String(usersCol.id) !== "" ? usersCol.id : null;
|
||||
const b = usersCol.Id != null && String(usersCol.Id) !== "" ? usersCol.Id : null;
|
||||
usersId = String(a != null ? a : b != null ? b : "").trim();
|
||||
}
|
||||
if (!usersId) {
|
||||
throw new Error("migration 1746100000: collection users introuvable ou id vide");
|
||||
}
|
||||
|
||||
function findExistingCollection(name) {
|
||||
try {
|
||||
return app.findCollectionByNameOrId(name);
|
||||
} catch (_) {}
|
||||
try {
|
||||
const all = app.findAllCollections();
|
||||
const want = String(name).toLowerCase();
|
||||
for (let i = 0; i < all.length; i++) {
|
||||
const c = all[i];
|
||||
if (c && c.name && String(c.name).toLowerCase() === want) {
|
||||
return c;
|
||||
}
|
||||
}
|
||||
} catch (_) {}
|
||||
return null;
|
||||
}
|
||||
|
||||
function loadOrCreate(name, factory) {
|
||||
const existing = findExistingCollection(name);
|
||||
if (existing != null) {
|
||||
return existing;
|
||||
}
|
||||
try {
|
||||
const col = factory();
|
||||
app.save(col);
|
||||
return col;
|
||||
} catch (err) {
|
||||
const msg = String(err && err.value ? err.value : err && err.message ? err.message : err);
|
||||
if (msg.includes("unique") || msg.includes("Unique")) {
|
||||
const again = findExistingCollection(name);
|
||||
if (again != null) {
|
||||
return again;
|
||||
}
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
const ownRecords = '@request.auth.id != "" && user.id = @request.auth.id';
|
||||
const authOnly = '@request.auth.id != ""';
|
||||
|
||||
function makeAnalysesSecteur() {
|
||||
const col = new Collection({ name: "analyses_secteur", type: "base" });
|
||||
col.fields.add(
|
||||
new RelationField({
|
||||
name: "user",
|
||||
required: true,
|
||||
collectionId: usersId,
|
||||
maxSelect: 1,
|
||||
cascadeDelete: true,
|
||||
}),
|
||||
);
|
||||
col.fields.add(new TextField({ name: "ville", required: true }));
|
||||
col.fields.add(new TextField({ name: "notes", required: false }));
|
||||
col.listRule = ownRecords;
|
||||
col.viewRule = ownRecords;
|
||||
col.createRule = authOnly;
|
||||
col.updateRule = ownRecords;
|
||||
col.deleteRule = ownRecords;
|
||||
return col;
|
||||
}
|
||||
|
||||
function makeNotesProspection() {
|
||||
const col = new Collection({ name: "notes_prospection", type: "base" });
|
||||
col.fields.add(
|
||||
new RelationField({
|
||||
name: "user",
|
||||
required: true,
|
||||
collectionId: usersId,
|
||||
maxSelect: 1,
|
||||
cascadeDelete: true,
|
||||
}),
|
||||
);
|
||||
col.fields.add(new TextField({ name: "question", required: true }));
|
||||
col.fields.add(new TextField({ name: "reponse", required: false }));
|
||||
col.fields.add(new TextField({ name: "categorie", required: false }));
|
||||
col.listRule = ownRecords;
|
||||
col.viewRule = ownRecords;
|
||||
col.createRule = authOnly;
|
||||
col.updateRule = ownRecords;
|
||||
col.deleteRule = ownRecords;
|
||||
return col;
|
||||
}
|
||||
|
||||
function makeGrillePrix() {
|
||||
const col = new Collection({ name: "grille_prix", type: "base" });
|
||||
col.fields.add(
|
||||
new RelationField({
|
||||
name: "user",
|
||||
required: true,
|
||||
collectionId: usersId,
|
||||
maxSelect: 1,
|
||||
cascadeDelete: true,
|
||||
}),
|
||||
);
|
||||
col.fields.add(
|
||||
new SelectField({
|
||||
name: "type_bien",
|
||||
required: true,
|
||||
maxSelect: 1,
|
||||
values: ["appartement", "maison", "immeuble"],
|
||||
}),
|
||||
);
|
||||
col.fields.add(
|
||||
new SelectField({
|
||||
name: "etat",
|
||||
required: true,
|
||||
maxSelect: 1,
|
||||
values: ["bon_etat", "a_renover", "travaux_lourds"],
|
||||
}),
|
||||
);
|
||||
col.fields.add(new NumberField({ name: "prix_achat_m2", required: true }));
|
||||
col.fields.add(new NumberField({ name: "prix_revente_m2", required: true }));
|
||||
col.fields.add(new NumberField({ name: "marge_estimee_pct", required: false }));
|
||||
col.fields.add(new TextField({ name: "ville", required: false }));
|
||||
col.listRule = ownRecords;
|
||||
col.viewRule = ownRecords;
|
||||
col.createRule = authOnly;
|
||||
col.updateRule = ownRecords;
|
||||
col.deleteRule = ownRecords;
|
||||
return col;
|
||||
}
|
||||
|
||||
loadOrCreate("analyses_secteur", makeAnalysesSecteur);
|
||||
loadOrCreate("notes_prospection", makeNotesProspection);
|
||||
loadOrCreate("grille_prix", makeGrillePrix);
|
||||
|
||||
try {
|
||||
const af = findExistingCollection("analyses_financieres");
|
||||
if (af == null) {
|
||||
/* rien à faire */
|
||||
} else {
|
||||
let has = false;
|
||||
for (let i = 0; i < af.fields.length; i++) {
|
||||
if (af.fields.get(i).name === "prix_revente_m2") {
|
||||
has = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!has && typeof NumberField !== "undefined") {
|
||||
af.fields.add(new NumberField({ name: "prix_revente_m2", min: 0 }));
|
||||
app.save(af);
|
||||
}
|
||||
}
|
||||
} catch (_) {
|
||||
/* schéma déjà à jour ou API différente */
|
||||
}
|
||||
},
|
||||
(app) => {
|
||||
for (const name of ["grille_prix", "notes_prospection", "analyses_secteur"]) {
|
||||
try {
|
||||
app.delete(app.findCollectionByNameOrId(name));
|
||||
} catch (_) {}
|
||||
}
|
||||
},
|
||||
);
|
||||
@ -14,6 +14,9 @@ migrate(
|
||||
"notes_biens",
|
||||
"documents_biens",
|
||||
"devis_travaux",
|
||||
"analyses_secteur",
|
||||
"notes_prospection",
|
||||
"grille_prix",
|
||||
];
|
||||
const ruleKeys = ["listRule", "viewRule", "createRule", "updateRule", "deleteRule"];
|
||||
for (const name of names) {
|
||||
|
||||
Reference in New Issue
Block a user