feat: app complète - tous les modules

This commit is contained in:
Bastien COIGNOUX
2026-05-04 09:09:10 +02:00
parent 695d4e76d0
commit 432f8ce176
15 changed files with 1355 additions and 108 deletions

View File

@ -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+

View File

@ -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<string | undefined>(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 (
<>
<Stack.Screen options={{ title: 'Agenda', headerShown: true }} />
<View className="flex-1 items-center justify-center bg-slate-50 p-6">
<Text className="text-center text-slate-600">Agenda (tâches) à brancher sur la collection `taches`.</Text>
<View className="flex-1 bg-slate-50">
{error ? (
<Text className="p-4 text-red-700">{formatPocketBaseError(error)}</Text>
) : null}
{isLoading ? (
<View className="flex-1 items-center justify-center">
<ActivityIndicator color="#1D4ED8" />
</View>
) : (
<SectionList
sections={sections}
keyExtractor={(item) => item.id}
contentContainerStyle={{ padding: 12, paddingBottom: 96 }}
renderSectionHeader={({ section }) => (
<Text
className={`pb-2 pt-3 text-xs font-semibold uppercase ${section.tint === 'red' ? 'text-red-600' : 'text-slate-500'}`}
>
{section.title}
</Text>
)}
renderItem={({ item: t }) => {
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">
<Pressable
onPress={() => void toggleDone(t)}
className={`mt-0.5 h-6 w-6 items-center justify-center rounded border ${done ? 'border-green-600 bg-green-600' : 'border-slate-300 bg-white'}`}
>
{done ? <Text className="text-xs font-bold text-white"></Text> : null}
</Pressable>
<View className="min-w-0 flex-1">
<Text
className={`font-semibold text-slate-900 ${done ? 'text-slate-400 line-through' : ''}`}
>
{t.titre}
</Text>
{badge ? (
<Text className="mt-1 text-xs font-medium text-blue-700">{badge}</Text>
) : null}
{t.date_echeance ? (
<Text className="mt-1 text-xs text-slate-500">{t.date_echeance}</Text>
) : null}
</View>
</View>
<View className="mt-3 flex-row flex-wrap gap-2">
<Pressable
onPress={() => void snoozeOneDay(t)}
className="rounded-lg bg-slate-100 px-3 py-2"
>
<Text className="text-sm font-medium text-slate-800">Snooze +1 j</Text>
</Pressable>
<Pressable
onPress={() => confirmDelete(t)}
className="rounded-lg bg-red-50 px-3 py-2"
>
<Text className="text-sm font-medium text-red-700">Supprimer</Text>
</Pressable>
</View>
</View>
);
}}
ListEmptyComponent={
<Text className="py-8 text-center text-slate-600">Aucune tâche à afficher.</Text>
}
/>
)}
<Pressable
onPress={openCreate}
className="absolute bottom-6 right-5 rounded-full bg-blue-700 px-4 py-3"
>
<Text className="font-semibold 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>
<TextInput
className="mt-1 rounded-xl border border-slate-200 px-3 py-2 text-base text-slate-900"
value={newTitre}
onChangeText={setNewTitre}
placeholder="Appeler le notaire…"
placeholderTextColor="#94a3b8"
/>
<Text className="mt-3 text-sm text-slate-500">É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"
value={newDate}
onChangeText={setNewDate}
placeholder="2026-04-29"
placeholderTextColor="#94a3b8"
/>
<Text className="mt-4 text-sm text-slate-500">Bien (optionnel)</Text>
<ScrollView horizontal className="mt-2" keyboardShouldPersistTaps="handled">
<Pressable
onPress={() => setNewBienId(undefined)}
className={`mr-2 rounded-full px-3 py-2 ${newBienId == null ? 'bg-slate-800' : 'bg-slate-100'}`}
>
<Text className={`text-sm ${newBienId == null ? 'text-white' : 'text-slate-800'}`}>
Aucun
</Text>
</Pressable>
{biens.map((b) => (
<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'}`}
>
<Text
numberOfLines={1}
className={`text-sm ${newBienId === b.id ? 'text-white' : 'text-slate-800'}`}
>
{b.titre?.trim() || b.ville || b.id}
</Text>
</Pressable>
))}
</ScrollView>
<View className="mt-6 flex-row gap-3">
<Pressable
onPress={() => setModalOpen(false)}
className="flex-1 items-center rounded-xl border border-slate-200 py-3"
>
<Text className="font-semibold text-slate-800">Annuler</Text>
</Pressable>
<Pressable
onPress={() => void submitCreate()}
disabled={isCreatePending}
className="flex-1 items-center rounded-xl bg-blue-700 py-3"
>
{isCreatePending ? (
<ActivityIndicator color="#fff" />
) : (
<Text className="font-semibold text-white">Créer</Text>
)}
</Pressable>
</View>
</View>
</View>
</Modal>
</View>
</>
);

View File

@ -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<ContactRecord>({
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<string, ContactRecord[]>();
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 ? (
<Text className="p-4 text-red-700">{formatPocketBaseError(q.error)}</Text>
) : null}
<TextInput
className="mx-3 mt-2 rounded-xl border border-slate-200 bg-white px-3 py-2 text-base text-slate-900"
placeholder="Rechercher…"
placeholderTextColor="#94a3b8"
value={search}
onChangeText={setSearch}
/>
{q.isPending ? (
<View className="flex-1 items-center justify-center">
<ActivityIndicator color="#1D4ED8" />
</View>
) : (
<ScrollView className="flex-1 p-3" contentContainerStyle={{ paddingBottom: 80 }}>
{q.data?.map((c) => (
<Link key={c.id} href={`/contact/${c.id}`} asChild>
<Pressable className="mb-2 rounded-xl border border-slate-200 bg-white px-3 py-3">
<Text className="font-semibold text-slate-900">
{c.prenom ? `${c.prenom} ` : ''}
{c.nom}
</Text>
{c.societe ? <Text className="text-sm text-slate-500">{c.societe}</Text> : null}
</Pressable>
</Link>
))}
</ScrollView>
<SectionList
className="flex-1 px-3 pt-2"
sections={sections}
keyExtractor={(item) => item.id}
contentContainerStyle={{ paddingBottom: 88 }}
renderSectionHeader={({ section: { title } }) => (
<Text className="pb-1 pt-3 text-xs font-semibold uppercase text-slate-500">{title}</Text>
)}
renderItem={({ item: c }) => (
<View className="mb-2 rounded-xl border border-slate-200 bg-white px-3 py-3">
<Link href={`/contact/${c.id}`} asChild>
<Pressable>
<Text className="font-semibold text-slate-900">
{c.prenom ? `${c.prenom} ` : ''}
{c.nom}
</Text>
{c.societe ? <Text className="text-sm text-slate-500">{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>
) : null}
</View>
)}
ListEmptyComponent={
<Text className="py-6 text-center text-slate-600">Aucun contact.</Text>
}
/>
)}
<Link href="/contact/nouveau" asChild>
<Pressable className="absolute bottom-6 right-5 rounded-full bg-blue-700 px-4 py-3">

View File

@ -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<string, number>();
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 (
<>
<Stack.Screen options={{ title: 'Dashboard', headerShown: true }} />
<View className="flex-1 bg-slate-50 p-4">
<Text className="mb-2 text-lg font-bold text-slate-900">Bienvenue</Text>
<Text className="mb-4 text-slate-600">Raccourcis :</Text>
<Link href="/(tabs)/biens" className="mb-2 text-base font-semibold text-blue-700">
Voir les biens (pipeline)
</Link>
<ScrollView className="flex-1 bg-slate-50 p-4" contentContainerStyle={{ paddingBottom: 32 }}>
{biensError ? (
<Text className="mb-2 text-red-700">{formatPocketBaseError(biensError)}</Text>
) : null}
{tachesError ? (
<Text className="mb-2 text-red-700">{formatPocketBaseError(tachesError)}</Text>
) : 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>
) : (
<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>
<Text className="text-xs text-slate-500">Tâches ouvertes</Text>
</View>
</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>
</View>
) : null}
</View>
</ScrollView>
<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 ?? ''}
</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 aujourdhui.</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>
))
)}
</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>
</View>
<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>
</ScrollView>
</>
);
}

View File

@ -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<VisiteRecord>({
filter: `user="${uid}"`,
sort: '-date_visite',
});
},
enabled: Boolean(uid),
});
const q = useVisitesList();
return (
<>

View File

@ -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<ContactRecord>(id);
},
enabled: Boolean(id),
});
const q = useContactDetail(id);
const biensQ = useContactBiens(id);
if (!id) {
return (
@ -82,19 +93,71 @@ export default function ContactDetailScreen() {
</Text>
{c.societe ? <Text className="mt-1 text-slate-600">{c.societe}</Text> : null}
<Text className="mt-3 text-sm text-slate-500">Catégorie</Text>
<Text className="text-base text-slate-900">{c.categorie}</Text>
<Text className="text-base text-slate-900">{labelContactCategorie(c.categorie)}</Text>
{c.email ? (
<>
<Text className="mt-3 text-sm text-slate-500">Email</Text>
<Text className="text-base text-slate-900">{c.email}</Text>
<Pressable onPress={() => openMail(c.email)}>
<Text className="text-base text-blue-700">{c.email}</Text>
</Pressable>
</>
) : null}
{c.telephone ? (
<>
<Text className="mt-3 text-sm text-slate-500">Téléphone</Text>
<Text className="text-base text-slate-900">{c.telephone}</Text>
<Pressable onPress={() => openTel(c.telephone)}>
<Text className="text-base text-blue-700">{c.telephone}</Text>
</Pressable>
</>
) : null}
{c.telephone_2 ? (
<>
<Text className="mt-3 text-sm text-slate-500">Téléphone 2</Text>
<Pressable onPress={() => openTel(c.telephone_2)}>
<Text className="text-base text-blue-700">{c.telephone_2}</Text>
</Pressable>
</>
) : null}
{(c.ville || c.zone_intervention) ? (
<>
<Text className="mt-3 text-sm text-slate-500">Localisation</Text>
<Text className="text-base text-slate-900">
{[c.ville, c.zone_intervention].filter(Boolean).join(' · ')}
</Text>
</>
) : null}
{c.notes ? (
<>
<Text className="mt-5 text-lg font-bold text-slate-900">Notes</Text>
<Text className="mt-1 text-slate-800">{c.notes}</Text>
</>
) : null}
<Text className="mt-6 text-lg font-bold text-slate-900">Biens associés</Text>
{biensQ.isPending ? (
<ActivityIndicator className="mt-2" color="#1D4ED8" />
) : biensQ.error ? (
<Text className="mt-2 text-red-700">{formatPocketBaseError(biensQ.error)}</Text>
) : (biensQ.data?.length ?? 0) === 0 ? (
<Text className="mt-2 text-slate-600">Aucun bien lié (source contact).</Text>
) : (
<View className="mt-2 gap-2">
{biensQ.data!.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">
{b.titre?.trim() || `${b.ville ?? ''} (${b.type_bien ?? 'bien'})`.trim()}
</Text>
<Text className="text-sm text-slate-500">
{[b.adresse, b.code_postal, b.ville].filter(Boolean).join(', ')}
</Text>
</Pressable>
</Link>
))}
</View>
)}
</ScrollView>
</>
);

View File

@ -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<string, string> | null): Record<string, ChecklistEtat> {
const out: Record<string, ChecklistEtat> = {};
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<VisiteRecord>(id, { expand: 'bien' });
const [tab, setTab] = useState<TabKey>(0);
const [notesLocal, setNotesLocal] = useState('');
const [minLocal, setMinLocal] = useState('');
const [maxLocal, setMaxLocal] = useState('');
const [rapportLocal, setRapportLocal] = useState<string | null>(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<string, unknown> = {
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<string, string>,
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 (
<>
<Stack.Screen options={{ title: 'Erreur', headerShown: true }} />
@ -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 (
<>
<Stack.Screen options={{ title: titre, headerShown: true }} />
<ScrollView className="flex-1 bg-slate-50 p-4">
<Text className="text-lg font-bold text-slate-900">Date</Text>
<Text className="text-slate-800">{v.date_visite ?? '—'}</Text>
<Text className="mt-4 text-lg font-bold text-slate-900">Avis</Text>
<Text className="text-slate-800">
{v.avis_global ? AVIS_VISITE[v.avis_global]?.label ?? v.avis_global : '—'}
</Text>
{v.notes_brutes ? (
<>
<Text className="mt-4 text-lg font-bold text-slate-900">Notes</Text>
<Text className="text-slate-800">{v.notes_brutes}</Text>
</>
) : null}
</ScrollView>
<View className="flex-1 bg-slate-50">
<View className="flex-row border-b border-slate-200 bg-white px-1">
{(['Check-liste', 'Notes', 'Estimation'] as const).map((label, i) => (
<Pressable
key={label}
onPress={() => setTab(i as TabKey)}
className={`flex-1 py-3 ${tab === i ? 'border-b-2 border-blue-700' : ''}`}
>
<Text
className={`text-center text-sm font-semibold ${tab === i ? 'text-blue-800' : 'text-slate-600'}`}
>
{label}
</Text>
</Pressable>
))}
</View>
<ScrollView className="flex-1 px-3 pt-3" contentContainerStyle={{ paddingBottom: 32 }}>
{tab === 0 ? (
<View>
{CHECKLIST_ITEMS.map((item) => (
<View key={item.id} className="mb-4 rounded-xl border border-slate-200 bg-white p-3">
<Text className="font-medium text-slate-900">{item.label}</Text>
<View className="mt-2 flex-row flex-wrap gap-2">
{CHECKLIST_ETATS.map((e) => (
<Pressable
key={e.id}
onPress={() => void setChecklistItem(item.id, e.id)}
className={`rounded-lg px-3 py-2 ${checklist[item.id] === e.id ? 'bg-slate-800' : 'bg-slate-100'}`}
>
<Text
className={`text-sm font-medium ${checklist[item.id] === e.id ? 'text-white' : 'text-slate-700'}`}
>
{e.label}
</Text>
</Pressable>
))}
</View>
</View>
))}
</View>
) : null}
{tab === 1 ? (
<View>
<Text className="mb-1 text-sm text-slate-500">Notes de visite</Text>
<TextInput
className="min-h-[140px] rounded-xl border border-slate-200 bg-white p-3 text-base text-slate-900"
multiline
textAlignVertical="top"
value={notesLocal}
onChangeText={setNotesLocal}
placeholder="Observations, impressions…"
placeholderTextColor="#94a3b8"
/>
<Pressable
onPress={() => void saveNotes()}
disabled={saveNotesPending}
className="mt-3 items-center rounded-xl bg-blue-700 py-3"
>
{saveNotesPending ? (
<ActivityIndicator color="#fff" />
) : (
<Text className="font-semibold text-white">Enregistrer les notes</Text>
)}
</Pressable>
<Text className="mt-6 text-sm text-slate-500">Photos ({photoCount})</Text>
<Pressable
onPress={() => void pickPhoto()}
disabled={photoPending}
className="mt-2 items-center rounded-xl border border-slate-300 bg-white py-3"
>
{photoPending ? (
<ActivityIndicator color="#1D4ED8" />
) : (
<Text className="font-semibold text-slate-800">Ajouter une photo</Text>
)}
</Pressable>
</View>
) : null}
{tab === 2 ? (
<View>
<Text className="text-sm text-slate-500">Budget travaux ()</Text>
<View className="mt-2 flex-row gap-2">
<TextInput
className="flex-1 rounded-xl border border-slate-200 bg-white px-3 py-2 text-base text-slate-900"
placeholder="Min"
keyboardType="decimal-pad"
value={minLocal}
onChangeText={setMinLocal}
placeholderTextColor="#94a3b8"
/>
<TextInput
className="flex-1 rounded-xl border border-slate-200 bg-white px-3 py-2 text-base text-slate-900"
placeholder="Max"
keyboardType="decimal-pad"
value={maxLocal}
onChangeText={setMaxLocal}
placeholderTextColor="#94a3b8"
/>
</View>
<Pressable
onPress={() => void saveEstimation()}
className="mt-2 items-center rounded-xl bg-slate-800 py-2"
>
<Text className="font-semibold text-white">Enregistrer le budget</Text>
</Pressable>
<Text className="mt-6 text-sm text-slate-500">Avis global</Text>
<View className="mt-2 flex-row flex-wrap gap-2">
{Object.entries(AVIS_VISITE).map(([key, { label }]) => (
<Pressable
key={key}
onPress={() => void setAvis(key)}
className={`rounded-lg px-3 py-2 ${v.avis_global === key ? 'bg-violet-700' : 'bg-slate-100'}`}
>
<Text
className={`text-sm font-medium ${v.avis_global === key ? 'text-white' : 'text-slate-800'}`}
>
{label}
</Text>
</Pressable>
))}
</View>
<Text className="mt-6 text-sm text-slate-500">Score opportunité (110)</Text>
<View className="mt-2 flex-row flex-wrap gap-2">
{Array.from({ length: 10 }, (_, i) => i + 1).map((n) => (
<Pressable
key={n}
onPress={() => void setScore(n)}
className={`h-10 w-10 items-center justify-center rounded-full ${v.score_opportunite === n ? 'bg-amber-500' : 'bg-slate-200'}`}
>
<Text className="font-bold text-slate-900">{n}</Text>
</Pressable>
))}
</View>
<Pressable
onPress={() => void generateRapport()}
disabled={iaPending}
className="mt-8 items-center rounded-xl bg-indigo-700 py-3"
>
{iaPending ? (
<ActivityIndicator color="#fff" />
) : (
<Text className="font-semibold text-white">Générer rapport IA</Text>
)}
</Pressable>
</View>
) : null}
{rapportLocal ? (
<View className="mt-6 rounded-xl border border-slate-200 bg-white p-3">
<Text className="mb-2 font-bold text-slate-900">Rapport</Text>
<Text className="font-mono text-sm leading-6 text-slate-800">{rapportLocal}</Text>
</View>
) : null}
</ScrollView>
</View>
</>
);
}

View File

@ -0,0 +1,20 @@
export const CONTACT_CATEGORIE_LABELS: Record<string, string> = {
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;
}

View File

@ -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';

50
app/hooks/useContacts.ts Normal file
View File

@ -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<ContactRecord>(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<ContactRecord>({
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<BienRecord>({
filter: `user="${uid}" && source_contact="${contactId}"`,
sort: '-id',
});
},
enabled: Boolean(uid && contactId),
});
}

78
app/hooks/useTaches.ts Normal file
View File

@ -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<TacheExpanded>({
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<TacheRecord>({
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<TacheRecord> }) => {
return pb.collection('taches').update<TacheRecord>(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,
};
}

91
app/hooks/useVisites.ts Normal file
View File

@ -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<VisiteRecord>({
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<VisiteExpanded>(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<VisiteRecord>(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<string, string>;
bien_info: Record<string, unknown>;
};
export async function requestGenerateRapport(body: GenerateRapportInput): Promise<string> {
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<VisiteRecord> {
const form = new FormData();
form.append('photos', {
uri: localUri,
name: 'photo.jpg',
type: 'image/jpeg',
} as unknown as Blob);
return pb.collection('visites').update<VisiteRecord>(visiteId, form);
}

View File

@ -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<string, string>;
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 & {

72
app/utils/agendaDates.ts Normal file
View File

@ -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, aujourdhui, dans les 7 jours (excl. aujourdhui), 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 };
}

View File

@ -0,0 +1,80 @@
/// <reference path="../pb_data/types.d.ts" />
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(),
);