diff --git a/AGENTS.md b/AGENTS.md index d3ac295..2c4d267 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,22 +2,23 @@ ## État actuel - [x] Docker + PocketBase configuré et lancé -- [x] Migration collections créée -- [ ] Migration appliquée avec succès -- [ ] App Expo initialisée -- [ ] Auth fonctionnelle -- [ ] Navigation complète -- [ ] Module Prospection -- [ ] Module Fiche bien + Calculateur -- [ ] Module Contacts -- [ ] Module Visites + IA -- [ ] Module Agenda -- [ ] Dashboard +- [x] Migration collections (fichiers `pb_migrations`) — à appliquer au démarrage serveur si besoin +- [x] App Expo initialisée +- [x] Auth fonctionnelle +- [x] Navigation complète +- [x] Module Prospection (pipeline / biens) +- [x] Module Fiche bien + Calculateur +- [x] Module Contacts (liste par catégorie, recherche, fiche + biens liés) +- [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) ## Infos techniques - PocketBase : http://localhost:8090 - Admin : http://localhost:8090/_/ (admin@mdb.fr) - Binaire : /usr/local/bin/pocketbase - Données : /pb_data +- Hooks JS : volume `pb_hooks` → `--hooksDir=/pb_hooks` (image muchobien) +- Variable IA : `ANTHROPIC_API_KEY` dans `.env.local` (chargée par Docker pour PocketBase) - OS : Windows Git Bash (MSYS_NO_PATHCONV=1) - PocketBase : v0.23+ diff --git a/app/app/(tabs)/agenda.tsx b/app/app/(tabs)/agenda.tsx index c91bb6e..18d665c 100644 --- a/app/app/(tabs)/agenda.tsx +++ b/app/app/(tabs)/agenda.tsx @@ -1,12 +1,242 @@ +import { useMemo, useState } from 'react'; import { Stack } from 'expo-router'; -import { Text, View } from 'react-native'; +import { + ActivityIndicator, + Alert, + Modal, + Pressable, + ScrollView, + SectionList, + Text, + TextInput, + View, +} from 'react-native'; + +import { useBiens } from '@/hooks/useBiens'; +import { useTachesList } from '@/hooks/useTaches'; +import type { TacheExpanded } from '@/types/collections'; +import { + addDays, + formatPbDateOnly, + parsePbDateOnly, + partitionTachesForAgenda, +} from '@/utils/agendaDates'; +import { formatPocketBaseError } from '@/utils/pocketbaseErrors'; + +function bienLabel(t: TacheExpanded): string | null { + const b = t.expand?.bien; + if (!b) return null; + return b.titre?.trim() || b.ville || 'Bien'; +} export default function AgendaTab() { + const { taches, isLoading, error, createTache, updateTache, deleteTache, isCreatePending } = + useTachesList(); + const { biens } = useBiens(); + const [modalOpen, setModalOpen] = useState(false); + const [newTitre, setNewTitre] = useState(''); + const [newDate, setNewDate] = useState(() => formatPbDateOnly(new Date())); + const [newBienId, setNewBienId] = useState(undefined); + + const sections = useMemo(() => { + const part = partitionTachesForAgenda(taches); + const out: { title: string; data: TacheExpanded[]; tint?: 'red' }[] = []; + if (part.overdue.length) out.push({ title: 'En retard', data: part.overdue, tint: 'red' }); + if (part.today.length) out.push({ title: "Aujourd'hui", data: part.today }); + if (part.week.length) out.push({ title: 'Cette semaine', data: part.week }); + if (part.nodate.length) out.push({ title: 'Sans date', data: part.nodate }); + return out; + }, [taches]); + + const openCreate = () => { + setNewTitre(''); + setNewDate(formatPbDateOnly(new Date())); + setNewBienId(undefined); + setModalOpen(true); + }; + + const submitCreate = async () => { + const titre = newTitre.trim(); + if (!titre) { + Alert.alert('Titre requis'); + return; + } + await createTache({ + titre, + date_echeance: newDate.trim() || undefined, + bien: newBienId, + }); + setModalOpen(false); + }; + + const toggleDone = async (t: TacheExpanded) => { + const next = t.statut === 'fait' ? 'a_faire' : 'fait'; + await updateTache({ id: t.id, patch: { statut: next } }); + }; + + const snoozeOneDay = async (t: TacheExpanded) => { + const base = parsePbDateOnly(t.date_echeance) ?? new Date(); + const next = addDays(base, 1); + await updateTache({ id: t.id, patch: { date_echeance: formatPbDateOnly(next) } }); + }; + + const confirmDelete = (t: TacheExpanded) => { + Alert.alert('Supprimer la tâche ?', t.titre, [ + { text: 'Annuler', style: 'cancel' }, + { + text: 'Supprimer', + style: 'destructive', + onPress: () => void deleteTache(t.id), + }, + ]); + }; + return ( <> - - Agenda (tâches) — à brancher sur la collection `taches`. + + {error ? ( + {formatPocketBaseError(error)} + ) : null} + {isLoading ? ( + + + + ) : ( + item.id} + contentContainerStyle={{ padding: 12, paddingBottom: 96 }} + renderSectionHeader={({ section }) => ( + + {section.title} + + )} + renderItem={({ item: t }) => { + const done = t.statut === 'fait'; + const badge = bienLabel(t); + return ( + + + void toggleDone(t)} + className={`mt-0.5 h-6 w-6 items-center justify-center rounded border ${done ? 'border-green-600 bg-green-600' : 'border-slate-300 bg-white'}`} + > + {done ? : null} + + + + {t.titre} + + {badge ? ( + {badge} + ) : null} + {t.date_echeance ? ( + {t.date_echeance} + ) : null} + + + + void snoozeOneDay(t)} + className="rounded-lg bg-slate-100 px-3 py-2" + > + Snooze +1 j + + confirmDelete(t)} + className="rounded-lg bg-red-50 px-3 py-2" + > + Supprimer + + + + ); + }} + ListEmptyComponent={ + Aucune tâche à afficher. + } + /> + )} + + + + Tâche + + + + + + Nouvelle tâche + Titre + + Échéance (AAAA-MM-JJ) + + Bien (optionnel) + + setNewBienId(undefined)} + className={`mr-2 rounded-full px-3 py-2 ${newBienId == null ? 'bg-slate-800' : 'bg-slate-100'}`} + > + + Aucun + + + {biens.map((b) => ( + setNewBienId(b.id)} + className={`mr-2 max-w-[200px] rounded-full px-3 py-2 ${newBienId === b.id ? 'bg-blue-700' : 'bg-slate-100'}`} + > + + {b.titre?.trim() || b.ville || b.id} + + + ))} + + + setModalOpen(false)} + className="flex-1 items-center rounded-xl border border-slate-200 py-3" + > + Annuler + + void submitCreate()} + disabled={isCreatePending} + className="flex-1 items-center rounded-xl bg-blue-700 py-3" + > + {isCreatePending ? ( + + ) : ( + Créer + )} + + + + + ); diff --git a/app/app/(tabs)/contacts.tsx b/app/app/(tabs)/contacts.tsx index 3d1c152..6442bdc 100644 --- a/app/app/(tabs)/contacts.tsx +++ b/app/app/(tabs)/contacts.tsx @@ -1,24 +1,55 @@ -import { useQuery } from '@tanstack/react-query'; +import { useMemo, useState } from 'react'; import { Link, Stack } from 'expo-router'; -import { ActivityIndicator, Pressable, ScrollView, Text, View } from 'react-native'; +import { + ActivityIndicator, + Linking, + Pressable, + SectionList, + Text, + TextInput, + View, +} from 'react-native'; -import { getCurrentUserId, pb } from '@/services/pocketbase'; +import { labelContactCategorie } from '@/constants/contactCategories'; +import { useContactsList } from '@/hooks/useContacts'; import type { ContactRecord } from '@/types/collections'; import { formatPocketBaseError } from '@/utils/pocketbaseErrors'; +function openTel(raw?: string | null) { + if (!raw?.trim()) return; + const n = raw.replace(/\s/g, ''); + void Linking.openURL(`tel:${n}`); +} + export default function ContactsTab() { - const uid = getCurrentUserId(); - const q = useQuery({ - queryKey: ['contacts_list', uid], - queryFn: async () => { - if (!uid) return []; - return pb.collection('contacts').getFullList({ - filter: `user="${uid}"`, - sort: 'nom', - }); - }, - enabled: Boolean(uid), - }); + const [search, setSearch] = useState(''); + const q = useContactsList(); + + const sections = useMemo(() => { + const list = q.data ?? []; + const s = search.trim().toLowerCase(); + const filtered = + s.length === 0 + ? list + : list.filter((c) => + [c.nom, c.prenom, c.societe, c.email, c.telephone, c.telephone_2] + .some((f) => f?.toLowerCase().includes(s)), + ); + const byCat = new Map(); + for (const c of filtered) { + const k = c.categorie || 'autre'; + if (!byCat.has(k)) byCat.set(k, []); + byCat.get(k)!.push(c); + } + return [...byCat.entries()] + .map(([key, data]) => ({ + title: labelContactCategorie(key), + data: [...data].sort((a, b) => + `${a.prenom ?? ''} ${a.nom}`.localeCompare(`${b.prenom ?? ''} ${b.nom}`, 'fr'), + ), + })) + .sort((a, b) => a.title.localeCompare(b.title, 'fr')); + }, [q.data, search]); return ( <> @@ -27,24 +58,48 @@ export default function ContactsTab() { {q.error ? ( {formatPocketBaseError(q.error)} ) : null} + {q.isPending ? ( ) : ( - - {q.data?.map((c) => ( - - - - {c.prenom ? `${c.prenom} ` : ''} - {c.nom} - - {c.societe ? {c.societe} : null} - - - ))} - + item.id} + contentContainerStyle={{ paddingBottom: 88 }} + renderSectionHeader={({ section: { title } }) => ( + {title} + )} + renderItem={({ item: c }) => ( + + + + + {c.prenom ? `${c.prenom} ` : ''} + {c.nom} + + {c.societe ? {c.societe} : null} + + + {c.telephone ? ( + openTel(c.telephone)} className="mt-2 self-start"> + {c.telephone} + + ) : null} + + )} + ListEmptyComponent={ + Aucun contact. + } + /> )} diff --git a/app/app/(tabs)/index.tsx b/app/app/(tabs)/index.tsx index 89e81e7..7b97509 100644 --- a/app/app/(tabs)/index.tsx +++ b/app/app/(tabs)/index.tsx @@ -1,20 +1,193 @@ +import { useMemo } from 'react'; import { Link, Stack } from 'expo-router'; -import { Text, View } from 'react-native'; +import { ActivityIndicator, Pressable, ScrollView, Text, View } from 'react-native'; + +import { useBiens } 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, + partitionTachesForAgenda, + startOfLocalDay, +} from '@/utils/agendaDates'; +import { formatPocketBaseError } from '@/utils/pocketbaseErrors'; + +function bienTitre(b: BienExpanded): string { + return b.titre?.trim() || `${b.ville ?? ''} · ${TYPES_BIENS[b.type_bien ?? 'autre'] ?? 'Bien'}`.trim(); +} export default function DashboardScreen() { + const { biens, isLoading: biensLoading, error: biensError } = useBiens(); + const { etapes, isLoading: etapesLoading } = useEtapes(); + const { taches, isLoading: tachesLoading, error: tachesError } = useTachesList(); + + const loading = biensLoading || etapesLoading || tachesLoading; + + const actifs = useMemo( + () => biens.filter((b) => !b.statut || b.statut === 'actif'), + [biens], + ); + + const urgent = useMemo(() => { + const start = startOfLocalDay(new Date()); + return taches.filter((t) => { + if (!isTaskActive(t.statut)) return false; + if (t.is_urgent) return true; + const d = parsePbDateOnly(t.date_echeance); + return d != null && d < start; + }); + }, [taches]); + + const part = useMemo(() => partitionTachesForAgenda(taches), [taches]); + + const etapeCounts = useMemo(() => { + const m = new Map(); + for (const b of biens) { + const k = b.etape ?? ''; + m.set(k, (m.get(k) ?? 0) + 1); + } + return m; + }, [biens]); + + const derniers = useMemo(() => [...biens].slice(0, 5), [biens]); + return ( <> - - Bienvenue - Raccourcis : - - Voir les biens (pipeline) - + + {biensError ? ( + {formatPocketBaseError(biensError)} + ) : null} + {tachesError ? ( + {formatPocketBaseError(tachesError)} + ) : null} + + {loading ? ( + + + + ) : null} + + Alertes urgentes + {urgent.length === 0 ? ( + Aucune alerte. + ) : ( + + {urgent.slice(0, 6).map((t) => ( + + + {t.titre} + {t.date_echeance ? ( + {t.date_echeance} + ) : null} + + + ))} + + )} + + Indicateurs + + + {biens.length} + Biens + + + {actifs.length} + Actifs + + + + {taches.filter(isTaskActive).length} + + Tâches ouvertes + + + + Pipeline + + + {etapes.map((e) => { + const n = etapeCounts.get(e.id) ?? 0; + return ( + + + + {e.nom} + + {n} + + ); + })} + {(etapeCounts.get('') ?? 0) > 0 ? ( + + Sans étape + + {etapeCounts.get('') ?? 0} + + + ) : null} + + + + + Derniers biens + + Voir tout + + + + {derniers.length === 0 ? ( + Aucun bien. + ) : ( + derniers.map((b) => ( + + + {bienTitre(b)} + + {b.expand?.etape?.nom ?? '—'} · {b.ville ?? ''} + + + + )) + )} + + + + Tâches du jour + + Agenda + + + + {part.today.length === 0 ? ( + Rien de prévu aujourd’hui. + ) : ( + part.today.map((t) => ( + + {t.titre} + + )) + )} + + + Raccourcis Nouveau bien - + + Contacts + + + Visites + + ); } diff --git a/app/app/(tabs)/visites.tsx b/app/app/(tabs)/visites.tsx index ddd2a1d..042af93 100644 --- a/app/app/(tabs)/visites.tsx +++ b/app/app/(tabs)/visites.tsx @@ -1,26 +1,13 @@ -import { useQuery } from '@tanstack/react-query'; import { Stack, useRouter } from 'expo-router'; import { ActivityIndicator, Pressable, ScrollView, Text, View } from 'react-native'; import { AVIS_VISITE } from '@/constants/metier'; -import { getCurrentUserId, pb } from '@/services/pocketbase'; -import type { VisiteRecord } from '@/types/collections'; +import { useVisitesList } from '@/hooks/useVisites'; import { formatPocketBaseError } from '@/utils/pocketbaseErrors'; export default function VisitesTab() { - const uid = getCurrentUserId(); const router = useRouter(); - const q = useQuery({ - queryKey: ['visites_list', uid], - queryFn: async () => { - if (!uid) return []; - return pb.collection('visites').getFullList({ - filter: `user="${uid}"`, - sort: '-date_visite', - }); - }, - enabled: Boolean(uid), - }); + const q = useVisitesList(); return ( <> diff --git a/app/app/contact/[id].tsx b/app/app/contact/[id].tsx index 933ab08..731717a 100644 --- a/app/app/contact/[id].tsx +++ b/app/app/contact/[id].tsx @@ -1,9 +1,16 @@ -import { useQuery } from '@tanstack/react-query'; -import { Stack, useLocalSearchParams } from 'expo-router'; -import { ActivityIndicator, ScrollView, Text, View } from 'react-native'; +import { Link, Stack, useLocalSearchParams } from 'expo-router'; +import { + ActivityIndicator, + Linking, + Pressable, + ScrollView, + Text, + View, +} from 'react-native'; -import { getCurrentUserId, pb } from '@/services/pocketbase'; -import type { ContactRecord } from '@/types/collections'; +import { labelContactCategorie } from '@/constants/contactCategories'; +import { useContactBiens, useContactDetail } from '@/hooks/useContacts'; +import { getCurrentUserId } from '@/services/pocketbase'; import { formatPocketBaseError } from '@/utils/pocketbaseErrors'; function routeParamId(raw: string | string[] | undefined): string | undefined { @@ -11,19 +18,23 @@ function routeParamId(raw: string | string[] | undefined): string | undefined { return Array.isArray(raw) ? raw[0] : raw; } +function openTel(raw?: string | null) { + if (!raw?.trim()) return; + void Linking.openURL(`tel:${raw.replace(/\s/g, '')}`); +} + +function openMail(raw?: string | null) { + if (!raw?.trim()) return; + void Linking.openURL(`mailto:${raw.trim()}`); +} + export default function ContactDetailScreen() { const { id: rawId } = useLocalSearchParams<{ id?: string | string[] }>(); const id = routeParamId(rawId); const uid = getCurrentUserId(); - const q = useQuery({ - queryKey: ['contact', id], - queryFn: async () => { - if (!id) throw new Error('id'); - return pb.collection('contacts').getOne(id); - }, - enabled: Boolean(id), - }); + const q = useContactDetail(id); + const biensQ = useContactBiens(id); if (!id) { return ( @@ -82,19 +93,71 @@ export default function ContactDetailScreen() { {c.societe ? {c.societe} : null} Catégorie - {c.categorie} + {labelContactCategorie(c.categorie)} + {c.email ? ( <> Email - {c.email} + openMail(c.email)}> + {c.email} + ) : null} {c.telephone ? ( <> Téléphone - {c.telephone} + openTel(c.telephone)}> + {c.telephone} + ) : null} + {c.telephone_2 ? ( + <> + Téléphone 2 + openTel(c.telephone_2)}> + {c.telephone_2} + + + ) : null} + {(c.ville || c.zone_intervention) ? ( + <> + Localisation + + {[c.ville, c.zone_intervention].filter(Boolean).join(' · ')} + + + ) : null} + + {c.notes ? ( + <> + Notes + {c.notes} + + ) : null} + + Biens associés + {biensQ.isPending ? ( + + ) : biensQ.error ? ( + {formatPocketBaseError(biensQ.error)} + ) : (biensQ.data?.length ?? 0) === 0 ? ( + Aucun bien lié (source contact). + ) : ( + + {biensQ.data!.map((b) => ( + + + + {b.titre?.trim() || `${b.ville ?? ''} (${b.type_bien ?? 'bien'})`.trim()} + + + {[b.adresse, b.code_postal, b.ville].filter(Boolean).join(', ')} + + + + ))} + + )} ); diff --git a/app/app/visite/[id].tsx b/app/app/visite/[id].tsx index 4bbbe58..db81055 100644 --- a/app/app/visite/[id].tsx +++ b/app/app/visite/[id].tsx @@ -1,10 +1,29 @@ -import { useQuery } from '@tanstack/react-query'; +import * as ImagePicker from 'expo-image-picker'; +import { useQueryClient } from '@tanstack/react-query'; import { Stack, useLocalSearchParams } from 'expo-router'; -import { ActivityIndicator, ScrollView, Text, View } from 'react-native'; +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { + ActivityIndicator, + Pressable, + ScrollView, + Text, + TextInput, + View, +} from 'react-native'; import { AVIS_VISITE } from '@/constants/metier'; -import { getCurrentUserId, pb } from '@/services/pocketbase'; -import type { VisiteRecord } from '@/types/collections'; +import { + CHECKLIST_ETATS, + CHECKLIST_ITEMS, + type ChecklistEtat, +} from '@/constants/visiteChecklist'; +import { + appendVisitePhoto, + requestGenerateRapport, + useVisiteDetail, + useVisiteUpdate, +} from '@/hooks/useVisites'; +import { getCurrentUserId } from '@/services/pocketbase'; import { formatPocketBaseError } from '@/utils/pocketbaseErrors'; function routeParamId(raw: string | string[] | undefined): string | undefined { @@ -12,19 +31,143 @@ function routeParamId(raw: string | string[] | undefined): string | undefined { return Array.isArray(raw) ? raw[0] : raw; } +function mergeChecklist(raw?: Record | null): Record { + const out: Record = {}; + for (const { id } of CHECKLIST_ITEMS) { + const v = raw?.[id]; + out[id] = + v === 'ok' || v === 'attention' || v === 'probleme' || v === 'non' ? v : 'non'; + } + return out; +} + +type TabKey = 0 | 1 | 2; + export default function VisiteDetailScreen() { const { id: rawId } = useLocalSearchParams<{ id?: string | string[] }>(); const id = routeParamId(rawId); const uid = getCurrentUserId(); + const queryClient = useQueryClient(); + const q = useVisiteDetail(id); + const updateVisite = useVisiteUpdate(); - const q = useQuery({ - queryKey: ['visite_detail', id], - queryFn: async () => { - if (!id) throw new Error('id'); - return pb.collection('visites').getOne(id, { expand: 'bien' }); + const [tab, setTab] = useState(0); + const [notesLocal, setNotesLocal] = useState(''); + const [minLocal, setMinLocal] = useState(''); + const [maxLocal, setMaxLocal] = useState(''); + const [rapportLocal, setRapportLocal] = useState(null); + const [iaPending, setIaPending] = useState(false); + const [photoPending, setPhotoPending] = useState(false); + const [saveNotesPending, setSaveNotesPending] = useState(false); + + const v = q.data; + + useEffect(() => { + if (!v) return; + setNotesLocal(v.notes_brutes ?? ''); + setMinLocal( + v.estimation_travaux_min != null && !Number.isNaN(v.estimation_travaux_min) + ? String(v.estimation_travaux_min) + : '', + ); + setMaxLocal( + v.estimation_travaux_max != null && !Number.isNaN(v.estimation_travaux_max) + ? String(v.estimation_travaux_max) + : '', + ); + setRapportLocal(v.rapport_genere ?? null); + }, [v?.id, v?.notes_brutes, v?.estimation_travaux_min, v?.estimation_travaux_max, v?.rapport_genere]); + + const checklist = useMemo(() => mergeChecklist(v?.checklist_reponses), [v?.checklist_reponses]); + + const setChecklistItem = useCallback( + async (itemId: string, etat: ChecklistEtat) => { + if (!id || !v) return; + const next = { ...checklist, [itemId]: etat }; + await updateVisite(id, { checklist_reponses: next }); }, - enabled: Boolean(id), - }); + [checklist, id, updateVisite, v], + ); + + const saveNotes = useCallback(async () => { + if (!id) return; + setSaveNotesPending(true); + try { + await updateVisite(id, { notes_brutes: notesLocal }); + } finally { + setSaveNotesPending(false); + } + }, [id, notesLocal, updateVisite]); + + const saveEstimation = useCallback(async () => { + if (!id) return; + const minN = minLocal.trim() === '' ? undefined : Number(minLocal.replace(',', '.')); + const maxN = maxLocal.trim() === '' ? undefined : Number(maxLocal.replace(',', '.')); + await updateVisite(id, { + estimation_travaux_min: minN != null && !Number.isNaN(minN) ? minN : undefined, + estimation_travaux_max: maxN != null && !Number.isNaN(maxN) ? maxN : undefined, + }); + }, [id, maxLocal, minLocal, updateVisite]); + + const setAvis = useCallback( + async (avis: string) => { + if (!id) return; + await updateVisite(id, { avis_global: avis }); + }, + [id, updateVisite], + ); + + const setScore = useCallback( + async (score: number) => { + if (!id) return; + await updateVisite(id, { score_opportunite: score }); + }, + [id, updateVisite], + ); + + const pickPhoto = useCallback(async () => { + if (!id) return; + setPhotoPending(true); + try { + const perm = await ImagePicker.requestMediaLibraryPermissionsAsync(); + if (!perm.granted) return; + const result = await ImagePicker.launchImageLibraryAsync({ + mediaTypes: ImagePicker.MediaTypeOptions.Images, + quality: 0.85, + }); + if (result.canceled || !result.assets[0]?.uri) return; + await appendVisitePhoto(id, result.assets[0].uri); + void queryClient.invalidateQueries({ queryKey: ['visite_detail', id] }); + } finally { + setPhotoPending(false); + } + }, [id, queryClient]); + + const generateRapport = useCallback(async () => { + if (!id || !v) return; + const bien = v.expand?.bien; + const bien_info: Record = { + titre: bien?.titre, + ville: bien?.ville, + type_bien: bien?.type_bien, + adresse: bien?.adresse, + code_postal: bien?.code_postal, + }; + setIaPending(true); + try { + const rapport = await requestGenerateRapport({ + notes_brutes: notesLocal, + checklist_reponses: checklist as Record, + bien_info, + }); + setRapportLocal(rapport); + await updateVisite(id, { rapport_genere: rapport }); + } catch (e) { + setRapportLocal(`Erreur: ${e instanceof Error ? e.message : String(e)}`); + } finally { + setIaPending(false); + } + }, [checklist, id, notesLocal, updateVisite, v]); if (!id) { return ( @@ -48,7 +191,7 @@ export default function VisiteDetailScreen() { ); } - if (q.error || !q.data) { + if (q.error || !v) { return ( <> @@ -61,7 +204,6 @@ export default function VisiteDetailScreen() { ); } - const v = q.data; if (uid && v.user !== uid) { return ( <> @@ -74,24 +216,172 @@ export default function VisiteDetailScreen() { } const titre = v.date_visite?.slice(0, 10) ?? 'Visite'; + const photoCount = Array.isArray(v.photos) ? v.photos.length : v.photos ? 1 : 0; return ( <> - - Date - {v.date_visite ?? '—'} - Avis - - {v.avis_global ? AVIS_VISITE[v.avis_global]?.label ?? v.avis_global : '—'} - - {v.notes_brutes ? ( - <> - Notes - {v.notes_brutes} - - ) : null} - + + + {(['Check-liste', 'Notes', 'Estimation'] as const).map((label, i) => ( + setTab(i as TabKey)} + className={`flex-1 py-3 ${tab === i ? 'border-b-2 border-blue-700' : ''}`} + > + + {label} + + + ))} + + + + {tab === 0 ? ( + + {CHECKLIST_ITEMS.map((item) => ( + + {item.label} + + {CHECKLIST_ETATS.map((e) => ( + void setChecklistItem(item.id, e.id)} + className={`rounded-lg px-3 py-2 ${checklist[item.id] === e.id ? 'bg-slate-800' : 'bg-slate-100'}`} + > + + {e.label} + + + ))} + + + ))} + + ) : null} + + {tab === 1 ? ( + + Notes de visite + + void saveNotes()} + disabled={saveNotesPending} + className="mt-3 items-center rounded-xl bg-blue-700 py-3" + > + {saveNotesPending ? ( + + ) : ( + Enregistrer les notes + )} + + Photos ({photoCount}) + void pickPhoto()} + disabled={photoPending} + className="mt-2 items-center rounded-xl border border-slate-300 bg-white py-3" + > + {photoPending ? ( + + ) : ( + Ajouter une photo + )} + + + ) : null} + + {tab === 2 ? ( + + Budget travaux (€) + + + + + void saveEstimation()} + className="mt-2 items-center rounded-xl bg-slate-800 py-2" + > + Enregistrer le budget + + + Avis global + + {Object.entries(AVIS_VISITE).map(([key, { label }]) => ( + void setAvis(key)} + className={`rounded-lg px-3 py-2 ${v.avis_global === key ? 'bg-violet-700' : 'bg-slate-100'}`} + > + + {label} + + + ))} + + + Score opportunité (1–10) + + {Array.from({ length: 10 }, (_, i) => i + 1).map((n) => ( + void setScore(n)} + className={`h-10 w-10 items-center justify-center rounded-full ${v.score_opportunite === n ? 'bg-amber-500' : 'bg-slate-200'}`} + > + {n} + + ))} + + + void generateRapport()} + disabled={iaPending} + className="mt-8 items-center rounded-xl bg-indigo-700 py-3" + > + {iaPending ? ( + + ) : ( + Générer rapport IA + )} + + + ) : null} + + {rapportLocal ? ( + + Rapport + {rapportLocal} + + ) : null} + + ); } diff --git a/app/constants/contactCategories.ts b/app/constants/contactCategories.ts new file mode 100644 index 0000000..0279027 --- /dev/null +++ b/app/constants/contactCategories.ts @@ -0,0 +1,20 @@ +export const CONTACT_CATEGORIE_LABELS: Record = { + notaire: 'Notaire', + agent_immo: 'Agent immobilier', + artisan_gros_oeuvre: 'Artisan gros œuvre', + artisan_second_oeuvre: 'Artisan second œuvre', + artisan_finitions: 'Artisan finitions', + banquier: 'Banquier', + courtier: 'Courtier', + diagnostiqueur: 'Diagnostiqueur', + geometre: 'Géomètre', + avocat: 'Avocat', + comptable: 'Comptable', + vendeur: 'Vendeur', + acheteur: 'Acheteur', + autre: 'Autre', +}; + +export function labelContactCategorie(key: string): string { + return CONTACT_CATEGORIE_LABELS[key] ?? key; +} diff --git a/app/constants/visiteChecklist.ts b/app/constants/visiteChecklist.ts new file mode 100644 index 0000000..0a6cfc8 --- /dev/null +++ b/app/constants/visiteChecklist.ts @@ -0,0 +1,23 @@ +export type ChecklistEtat = 'ok' | 'attention' | 'probleme' | 'non'; + +export const CHECKLIST_ETATS: { id: ChecklistEtat; label: string; color: string }[] = [ + { id: 'ok', label: 'OK', color: '#16A34A' }, + { id: 'attention', label: 'Attention', color: '#CA8A04' }, + { id: 'probleme', label: 'Problème', color: '#DC2626' }, + { id: 'non', label: 'Non vérifié', color: '#64748B' }, +]; + +export const CHECKLIST_ITEMS: { id: string; label: string }[] = [ + { id: 'facade', label: 'Façade / extérieur' }, + { id: 'toiture', label: 'Toiture / couverture' }, + { id: 'humidite', label: 'Humidité / traces' }, + { id: 'menuiseries', label: 'Menuiseries' }, + { id: 'chauffage', label: 'Chauffage / ECS' }, + { id: 'electricite', label: 'Électricité' }, + { id: 'plomberie', label: 'Plomberie / évacuations' }, + { id: 'copropriete', label: 'Parties communes / copro' }, + { id: 'bruit', label: 'Nuisances (bruit, odeurs)' }, + { id: 'stationnement', label: 'Stationnement / accès' }, +]; + +export const GENERATE_RAPPORT_PATH = '/api/mdb/generate-rapport'; diff --git a/app/hooks/useContacts.ts b/app/hooks/useContacts.ts new file mode 100644 index 0000000..08eaf4c --- /dev/null +++ b/app/hooks/useContacts.ts @@ -0,0 +1,50 @@ +import { useQuery } from '@tanstack/react-query'; + +import { getCurrentUserId, pb } from '@/services/pocketbase'; +import type { BienRecord, ContactRecord } from '@/types/collections'; + +export function useContactDetail(id: string | undefined) { + return useQuery({ + queryKey: ['contact', id], + queryFn: async () => { + if (!id) throw new Error('id'); + return pb.collection('contacts').getOne(id); + }, + enabled: Boolean(id), + }); +} + +export function useContactsList() { + const uid = getCurrentUserId(); + return useQuery({ + queryKey: ['contacts_list', uid], + queryFn: async () => { + if (!uid) return [] as ContactRecord[]; + const list = await pb.collection('contacts').getFullList({ + filter: `user="${uid}"`, + sort: '-id', + }); + return [...list].sort((a, b) => { + const an = `${a.prenom ?? ''} ${a.nom}`.trim().toLowerCase(); + const bn = `${b.prenom ?? ''} ${b.nom}`.trim().toLowerCase(); + return an.localeCompare(bn, 'fr'); + }); + }, + enabled: Boolean(uid), + }); +} + +export function useContactBiens(contactId: string | undefined) { + const uid = getCurrentUserId(); + return useQuery({ + queryKey: ['contact_biens', uid, contactId], + queryFn: async () => { + if (!uid || !contactId) return [] as BienRecord[]; + return pb.collection('biens').getFullList({ + filter: `user="${uid}" && source_contact="${contactId}"`, + sort: '-id', + }); + }, + enabled: Boolean(uid && contactId), + }); +} diff --git a/app/hooks/useTaches.ts b/app/hooks/useTaches.ts new file mode 100644 index 0000000..f7927f2 --- /dev/null +++ b/app/hooks/useTaches.ts @@ -0,0 +1,78 @@ +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; + +import { getCurrentUserId, pb } from '@/services/pocketbase'; +import type { TacheExpanded, TacheRecord } from '@/types/collections'; + +export type TacheCreateInput = { + titre: string; + description?: string; + date_echeance?: string; + bien?: string; + type_tache?: string; + priorite?: number; + is_urgent?: boolean; + statut?: string; +}; + +export function useTachesList() { + const uid = getCurrentUserId(); + const queryClient = useQueryClient(); + + const query = useQuery({ + queryKey: ['taches_list', uid], + queryFn: async () => { + if (!uid) return [] as TacheExpanded[]; + return pb.collection('taches').getFullList({ + filter: `user="${uid}"`, + sort: '-id', + expand: 'bien', + }); + }, + enabled: Boolean(uid), + }); + + const invalidate = () => { + void queryClient.invalidateQueries({ queryKey: ['taches_list', uid] }); + }; + + const createTache = useMutation({ + mutationFn: async (input: TacheCreateInput) => { + if (!uid) throw new Error('Utilisateur non connecté'); + return pb.collection('taches').create({ + user: uid, + titre: input.titre, + description: input.description, + date_echeance: input.date_echeance, + bien: input.bien || undefined, + type_tache: input.type_tache ?? 'autre', + priorite: input.priorite ?? 2, + is_urgent: input.is_urgent ?? false, + statut: input.statut ?? 'a_faire', + }); + }, + onSuccess: invalidate, + }); + + const updateTache = useMutation({ + mutationFn: async ({ id, patch }: { id: string; patch: Partial }) => { + return pb.collection('taches').update(id, patch); + }, + onSuccess: invalidate, + }); + + const deleteTache = useMutation({ + mutationFn: async (id: string) => pb.collection('taches').delete(id), + onSuccess: invalidate, + }); + + return { + taches: query.data ?? [], + isLoading: query.isPending, + error: query.error, + refetch: query.refetch, + createTache: createTache.mutateAsync, + updateTache: updateTache.mutateAsync, + deleteTache: deleteTache.mutateAsync, + isCreatePending: createTache.isPending, + }; +} diff --git a/app/hooks/useVisites.ts b/app/hooks/useVisites.ts new file mode 100644 index 0000000..b142d04 --- /dev/null +++ b/app/hooks/useVisites.ts @@ -0,0 +1,91 @@ +import { useQuery, useQueryClient } from '@tanstack/react-query'; + +import { GENERATE_RAPPORT_PATH } from '@/constants/visiteChecklist'; +import { getCurrentUserId, pb } from '@/services/pocketbase'; +import type { BienRecord, VisiteRecord } from '@/types/collections'; + +export type VisiteExpanded = VisiteRecord & { + expand?: { bien?: BienRecord }; +}; + +export function useVisitesList() { + const uid = getCurrentUserId(); + return useQuery({ + queryKey: ['visites_list', uid], + queryFn: async () => { + if (!uid) return [] as VisiteRecord[]; + return pb.collection('visites').getFullList({ + filter: `user="${uid}"`, + sort: '-date_visite', + }); + }, + enabled: Boolean(uid), + }); +} + +export function useVisiteDetail(id: string | undefined) { + return useQuery({ + queryKey: ['visite_detail', id], + queryFn: async () => { + if (!id) throw new Error('id'); + return pb.collection('visites').getOne(id, { expand: 'bien' }); + }, + enabled: Boolean(id), + }); +} + +export type VisitePatch = Partial< + Pick< + VisiteRecord, + | 'notes_brutes' + | 'checklist_reponses' + | 'estimation_travaux_min' + | 'estimation_travaux_max' + | 'avis_global' + | 'score_opportunite' + | 'rapport_genere' + > +>; + +export function useVisiteUpdate() { + const queryClient = useQueryClient(); + const uid = getCurrentUserId(); + return async (visiteId: string, patch: VisitePatch) => { + const updated = await pb.collection('visites').update(visiteId, patch); + void queryClient.invalidateQueries({ queryKey: ['visite_detail', visiteId] }); + void queryClient.invalidateQueries({ queryKey: ['visites_list', uid] }); + return updated; + }; +} + +export type GenerateRapportInput = { + notes_brutes: string; + checklist_reponses: Record; + bien_info: Record; +}; + +export async function requestGenerateRapport(body: GenerateRapportInput): Promise { + const res = await pb.send<{ rapport?: string; message?: string }>(GENERATE_RAPPORT_PATH, { + method: 'POST', + body: JSON.stringify(body), + headers: { 'Content-Type': 'application/json' }, + }); + if (res && typeof res === 'object' && 'rapport' in res && typeof res.rapport === 'string') { + return res.rapport; + } + const msg = + res && typeof res === 'object' && 'message' in res && typeof res.message === 'string' + ? res.message + : 'Réponse serveur inattendue'; + throw new Error(msg); +} + +export async function appendVisitePhoto(visiteId: string, localUri: string): Promise { + const form = new FormData(); + form.append('photos', { + uri: localUri, + name: 'photo.jpg', + type: 'image/jpeg', + } as unknown as Blob); + return pb.collection('visites').update(visiteId, form); +} diff --git a/app/types/collections.ts b/app/types/collections.ts index 3b90f6b..cb48f0c 100644 --- a/app/types/collections.ts +++ b/app/types/collections.ts @@ -82,8 +82,17 @@ export type ContactRecord = RecordModel & { prenom?: string; societe?: string; categorie: string; + specialite?: string; email?: string; telephone?: string; + telephone_2?: string; + ville?: string; + zone_intervention?: string; + note?: number; + recommande?: boolean; + taux_horaire?: number; + notes?: string; + is_favori?: boolean; }; export type AnalyseFinanciereRecord = RecordModel & { @@ -113,9 +122,34 @@ export type VisiteRecord = RecordModel & { user: string; bien: string; date_visite: string; + duree_minutes?: number; type_visite?: string; avis_global?: string; notes_brutes?: string; + rapport_genere?: string; + checklist_reponses?: Record; + estimation_travaux_min?: number; + estimation_travaux_max?: number; + score_opportunite?: number; + photos?: string[]; +}; + +export type TacheRecord = RecordModel & { + user: string; + bien?: string; + contact?: string; + titre: string; + description?: string; + type_tache?: string; + priorite?: number; + statut?: string; + date_echeance?: string; + date_rappel?: string; + is_urgent?: boolean; +}; + +export type TacheExpanded = TacheRecord & { + expand?: { bien?: BienRecord }; }; export type NoteRecord = RecordModel & { diff --git a/app/utils/agendaDates.ts b/app/utils/agendaDates.ts new file mode 100644 index 0000000..a51628c --- /dev/null +++ b/app/utils/agendaDates.ts @@ -0,0 +1,72 @@ +import type { TacheRecord } from '@/types/collections'; + +/** Parse PocketBase `date` (YYYY-MM-DD) en date locale minuit. */ +export function parsePbDateOnly(raw?: string | null): Date | null { + if (!raw) return null; + const m = /^(\d{4})-(\d{2})-(\d{2})/.exec(raw.trim()); + if (!m) return null; + return new Date(Number(m[1]), Number(m[2]) - 1, Number(m[3])); +} + +export function startOfLocalDay(d: Date): Date { + return new Date(d.getFullYear(), d.getMonth(), d.getDate()); +} + +export function addDays(d: Date, n: number): Date { + const x = new Date(d); + x.setDate(x.getDate() + n); + return x; +} + +export function formatPbDateOnly(d: Date): string { + const y = d.getFullYear(); + const m = String(d.getMonth() + 1).padStart(2, '0'); + const day = String(d.getDate()).padStart(2, '0'); + return `${y}-${m}-${day}`; +} + +export function isTaskActive(statut?: string): boolean { + return statut !== 'fait' && statut !== 'annule'; +} + +export type AgendaPartition = { + overdue: TacheRecord[]; + today: TacheRecord[]; + week: TacheRecord[]; + nodate: TacheRecord[]; +}; + +/** Tâches actives : en retard, aujourd’hui, dans les 7 jours (excl. aujourd’hui), sans date. */ +export function partitionTachesForAgenda(taches: TacheRecord[]): AgendaPartition { + const now = new Date(); + const startToday = startOfLocalDay(now); + const startTomorrow = addDays(startToday, 1); + const endWeek = addDays(startToday, 7); + + const overdue: TacheRecord[] = []; + const today: TacheRecord[] = []; + const week: TacheRecord[] = []; + const nodate: TacheRecord[] = []; + + for (const t of taches) { + if (!isTaskActive(t.statut)) continue; + const d = parsePbDateOnly(t.date_echeance); + if (!d) { + nodate.push(t); + continue; + } + if (d < startToday) overdue.push(t); + else if (d < startTomorrow) today.push(t); + else if (d < endWeek) week.push(t); + } + + const byDue = (a: TacheRecord, b: TacheRecord) => { + const da = parsePbDateOnly(a.date_echeance)?.getTime() ?? 0; + const db = parsePbDateOnly(b.date_echeance)?.getTime() ?? 0; + return da - db; + }; + overdue.sort(byDue); + today.sort(byDue); + week.sort(byDue); + return { overdue, today, week, nodate }; +} diff --git a/pocketbase/pb_hooks/generate_rapport.pb.js b/pocketbase/pb_hooks/generate_rapport.pb.js new file mode 100644 index 0000000..04c2fff --- /dev/null +++ b/pocketbase/pb_hooks/generate_rapport.pb.js @@ -0,0 +1,80 @@ +/// + +routerAdd( + "POST", + "/api/mdb/generate-rapport", + (e) => { + if (!e.auth) { + return e.json(401, { message: "Non autorisé" }); + } + + const body = e.requestInfo().body || {}; + const notes_brutes = typeof body.notes_brutes === "string" ? body.notes_brutes : ""; + const checklist_reponses = + typeof body.checklist_reponses === "object" && body.checklist_reponses != null + ? body.checklist_reponses + : {}; + const bien_info = + typeof body.bien_info === "object" && body.bien_info != null ? body.bien_info : {}; + + const key = $os.getenv("ANTHROPIC_API_KEY"); + if (!key) { + return e.json(500, { message: "ANTHROPIC_API_KEY manquante sur le serveur" }); + } + + const payload = { + model: "claude-sonnet-4-20250514", + max_tokens: 1500, + messages: [ + { + role: "user", + content: + "Génère un compte-rendu de visite professionnel en français (titres courts, listes à puces si utile). Bien: " + + JSON.stringify(bien_info) + + "\n\nNotes libres:\n" + + notes_brutes + + "\n\nChecklist (états: ok, attention, probleme, non):\n" + + JSON.stringify(checklist_reponses), + }, + ], + }; + + const res = $http.send({ + url: "https://api.anthropic.com/v1/messages", + method: "POST", + headers: { + "x-api-key": key, + "anthropic-version": "2023-06-01", + "content-type": "application/json", + }, + body: JSON.stringify(payload), + timeout: 90, + }); + + if (res.statusCode >= 400) { + const errText = typeof res.raw === "string" ? res.raw : ""; + return e.json(502, { + message: "Réponse Anthropic invalide", + statusCode: res.statusCode, + detail: errText.slice(0, 2000), + }); + } + + let parsed = res.json; + if (parsed == null && typeof res.raw === "string" && res.raw.length > 0) { + try { + parsed = JSON.parse(res.raw); + } catch (_) { + parsed = null; + } + } + + const text = + parsed && Array.isArray(parsed.content) && parsed.content[0] && parsed.content[0].text + ? parsed.content[0].text + : ""; + + return e.json(200, { rapport: text }); + }, + $apis.requireAuth(), +);