feat: app complète - tous les modules
This commit is contained in:
23
AGENTS.md
23
AGENTS.md
@ -2,22 +2,23 @@
|
|||||||
|
|
||||||
## État actuel
|
## État actuel
|
||||||
- [x] Docker + PocketBase configuré et lancé
|
- [x] Docker + PocketBase configuré et lancé
|
||||||
- [x] Migration collections créée
|
- [x] Migration collections (fichiers `pb_migrations`) — à appliquer au démarrage serveur si besoin
|
||||||
- [ ] Migration appliquée avec succès
|
- [x] App Expo initialisée
|
||||||
- [ ] App Expo initialisée
|
- [x] Auth fonctionnelle
|
||||||
- [ ] Auth fonctionnelle
|
- [x] Navigation complète
|
||||||
- [ ] Navigation complète
|
- [x] Module Prospection (pipeline / biens)
|
||||||
- [ ] Module Prospection
|
- [x] Module Fiche bien + Calculateur
|
||||||
- [ ] Module Fiche bien + Calculateur
|
- [x] Module Contacts (liste par catégorie, recherche, fiche + biens liés)
|
||||||
- [ ] Module Contacts
|
- [x] Module Visites + IA (`pb_hooks/generate_rapport.pb.js`, route `POST /api/mdb/generate-rapport`)
|
||||||
- [ ] Module Visites + IA
|
- [x] Module Agenda (tâches, snooze, création modal)
|
||||||
- [ ] Module Agenda
|
- [x] Dashboard (alertes, KPIs, pipeline, derniers biens, tâches du jour)
|
||||||
- [ ] Dashboard
|
|
||||||
|
|
||||||
## Infos techniques
|
## Infos techniques
|
||||||
- PocketBase : http://localhost:8090
|
- PocketBase : http://localhost:8090
|
||||||
- Admin : http://localhost:8090/_/ (admin@mdb.fr)
|
- Admin : http://localhost:8090/_/ (admin@mdb.fr)
|
||||||
- Binaire : /usr/local/bin/pocketbase
|
- Binaire : /usr/local/bin/pocketbase
|
||||||
- Données : /pb_data
|
- Données : /pb_data
|
||||||
|
- Hooks JS : volume `pb_hooks` → `--hooksDir=/pb_hooks` (image muchobien)
|
||||||
|
- Variable IA : `ANTHROPIC_API_KEY` dans `.env.local` (chargée par Docker pour PocketBase)
|
||||||
- OS : Windows Git Bash (MSYS_NO_PATHCONV=1)
|
- OS : Windows Git Bash (MSYS_NO_PATHCONV=1)
|
||||||
- PocketBase : v0.23+
|
- PocketBase : v0.23+
|
||||||
|
|||||||
@ -1,12 +1,242 @@
|
|||||||
|
import { useMemo, useState } from 'react';
|
||||||
import { Stack } from 'expo-router';
|
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() {
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<Stack.Screen options={{ title: 'Agenda', headerShown: true }} />
|
<Stack.Screen options={{ title: 'Agenda', headerShown: true }} />
|
||||||
<View className="flex-1 items-center justify-center bg-slate-50 p-6">
|
<View className="flex-1 bg-slate-50">
|
||||||
<Text className="text-center text-slate-600">Agenda (tâches) — à brancher sur la collection `taches`.</Text>
|
{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>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -1,24 +1,55 @@
|
|||||||
import { useQuery } from '@tanstack/react-query';
|
import { useMemo, useState } from 'react';
|
||||||
import { Link, Stack } from 'expo-router';
|
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 type { ContactRecord } from '@/types/collections';
|
||||||
import { formatPocketBaseError } from '@/utils/pocketbaseErrors';
|
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() {
|
export default function ContactsTab() {
|
||||||
const uid = getCurrentUserId();
|
const [search, setSearch] = useState('');
|
||||||
const q = useQuery({
|
const q = useContactsList();
|
||||||
queryKey: ['contacts_list', uid],
|
|
||||||
queryFn: async () => {
|
const sections = useMemo(() => {
|
||||||
if (!uid) return [];
|
const list = q.data ?? [];
|
||||||
return pb.collection('contacts').getFullList<ContactRecord>({
|
const s = search.trim().toLowerCase();
|
||||||
filter: `user="${uid}"`,
|
const filtered =
|
||||||
sort: 'nom',
|
s.length === 0
|
||||||
});
|
? list
|
||||||
},
|
: list.filter((c) =>
|
||||||
enabled: Boolean(uid),
|
[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 (
|
return (
|
||||||
<>
|
<>
|
||||||
@ -27,24 +58,48 @@ export default function ContactsTab() {
|
|||||||
{q.error ? (
|
{q.error ? (
|
||||||
<Text className="p-4 text-red-700">{formatPocketBaseError(q.error)}</Text>
|
<Text className="p-4 text-red-700">{formatPocketBaseError(q.error)}</Text>
|
||||||
) : null}
|
) : 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 ? (
|
{q.isPending ? (
|
||||||
<View className="flex-1 items-center justify-center">
|
<View className="flex-1 items-center justify-center">
|
||||||
<ActivityIndicator color="#1D4ED8" />
|
<ActivityIndicator color="#1D4ED8" />
|
||||||
</View>
|
</View>
|
||||||
) : (
|
) : (
|
||||||
<ScrollView className="flex-1 p-3" contentContainerStyle={{ paddingBottom: 80 }}>
|
<SectionList
|
||||||
{q.data?.map((c) => (
|
className="flex-1 px-3 pt-2"
|
||||||
<Link key={c.id} href={`/contact/${c.id}`} asChild>
|
sections={sections}
|
||||||
<Pressable className="mb-2 rounded-xl border border-slate-200 bg-white px-3 py-3">
|
keyExtractor={(item) => item.id}
|
||||||
<Text className="font-semibold text-slate-900">
|
contentContainerStyle={{ paddingBottom: 88 }}
|
||||||
{c.prenom ? `${c.prenom} ` : ''}
|
renderSectionHeader={({ section: { title } }) => (
|
||||||
{c.nom}
|
<Text className="pb-1 pt-3 text-xs font-semibold uppercase text-slate-500">{title}</Text>
|
||||||
</Text>
|
)}
|
||||||
{c.societe ? <Text className="text-sm text-slate-500">{c.societe}</Text> : null}
|
renderItem={({ item: c }) => (
|
||||||
</Pressable>
|
<View className="mb-2 rounded-xl border border-slate-200 bg-white px-3 py-3">
|
||||||
</Link>
|
<Link href={`/contact/${c.id}`} asChild>
|
||||||
))}
|
<Pressable>
|
||||||
</ScrollView>
|
<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>
|
<Link href="/contact/nouveau" asChild>
|
||||||
<Pressable className="absolute bottom-6 right-5 rounded-full bg-blue-700 px-4 py-3">
|
<Pressable className="absolute bottom-6 right-5 rounded-full bg-blue-700 px-4 py-3">
|
||||||
|
|||||||
@ -1,20 +1,193 @@
|
|||||||
|
import { useMemo } from 'react';
|
||||||
import { Link, Stack } from 'expo-router';
|
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() {
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<Stack.Screen options={{ title: 'Dashboard', headerShown: true }} />
|
<Stack.Screen options={{ title: 'Dashboard', headerShown: true }} />
|
||||||
<View className="flex-1 bg-slate-50 p-4">
|
<ScrollView className="flex-1 bg-slate-50 p-4" contentContainerStyle={{ paddingBottom: 32 }}>
|
||||||
<Text className="mb-2 text-lg font-bold text-slate-900">Bienvenue</Text>
|
{biensError ? (
|
||||||
<Text className="mb-4 text-slate-600">Raccourcis :</Text>
|
<Text className="mb-2 text-red-700">{formatPocketBaseError(biensError)}</Text>
|
||||||
<Link href="/(tabs)/biens" className="mb-2 text-base font-semibold text-blue-700">
|
) : null}
|
||||||
Voir les biens (pipeline)
|
{tachesError ? (
|
||||||
</Link>
|
<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 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>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</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">
|
<Link href="/bien/nouveau" className="mb-2 text-base font-semibold text-blue-700">
|
||||||
Nouveau bien
|
Nouveau bien
|
||||||
</Link>
|
</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>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,26 +1,13 @@
|
|||||||
import { useQuery } from '@tanstack/react-query';
|
|
||||||
import { Stack, useRouter } from 'expo-router';
|
import { Stack, useRouter } from 'expo-router';
|
||||||
import { ActivityIndicator, Pressable, ScrollView, Text, View } from 'react-native';
|
import { ActivityIndicator, Pressable, ScrollView, Text, View } from 'react-native';
|
||||||
|
|
||||||
import { AVIS_VISITE } from '@/constants/metier';
|
import { AVIS_VISITE } from '@/constants/metier';
|
||||||
import { getCurrentUserId, pb } from '@/services/pocketbase';
|
import { useVisitesList } from '@/hooks/useVisites';
|
||||||
import type { VisiteRecord } from '@/types/collections';
|
|
||||||
import { formatPocketBaseError } from '@/utils/pocketbaseErrors';
|
import { formatPocketBaseError } from '@/utils/pocketbaseErrors';
|
||||||
|
|
||||||
export default function VisitesTab() {
|
export default function VisitesTab() {
|
||||||
const uid = getCurrentUserId();
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const q = useQuery({
|
const q = useVisitesList();
|
||||||
queryKey: ['visites_list', uid],
|
|
||||||
queryFn: async () => {
|
|
||||||
if (!uid) return [];
|
|
||||||
return pb.collection('visites').getFullList<VisiteRecord>({
|
|
||||||
filter: `user="${uid}"`,
|
|
||||||
sort: '-date_visite',
|
|
||||||
});
|
|
||||||
},
|
|
||||||
enabled: Boolean(uid),
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@ -1,9 +1,16 @@
|
|||||||
import { useQuery } from '@tanstack/react-query';
|
import { Link, Stack, useLocalSearchParams } from 'expo-router';
|
||||||
import { Stack, useLocalSearchParams } from 'expo-router';
|
import {
|
||||||
import { ActivityIndicator, ScrollView, Text, View } from 'react-native';
|
ActivityIndicator,
|
||||||
|
Linking,
|
||||||
|
Pressable,
|
||||||
|
ScrollView,
|
||||||
|
Text,
|
||||||
|
View,
|
||||||
|
} from 'react-native';
|
||||||
|
|
||||||
import { getCurrentUserId, pb } from '@/services/pocketbase';
|
import { labelContactCategorie } from '@/constants/contactCategories';
|
||||||
import type { ContactRecord } from '@/types/collections';
|
import { useContactBiens, useContactDetail } from '@/hooks/useContacts';
|
||||||
|
import { getCurrentUserId } from '@/services/pocketbase';
|
||||||
import { formatPocketBaseError } from '@/utils/pocketbaseErrors';
|
import { formatPocketBaseError } from '@/utils/pocketbaseErrors';
|
||||||
|
|
||||||
function routeParamId(raw: string | string[] | undefined): string | undefined {
|
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;
|
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() {
|
export default function ContactDetailScreen() {
|
||||||
const { id: rawId } = useLocalSearchParams<{ id?: string | string[] }>();
|
const { id: rawId } = useLocalSearchParams<{ id?: string | string[] }>();
|
||||||
const id = routeParamId(rawId);
|
const id = routeParamId(rawId);
|
||||||
const uid = getCurrentUserId();
|
const uid = getCurrentUserId();
|
||||||
|
|
||||||
const q = useQuery({
|
const q = useContactDetail(id);
|
||||||
queryKey: ['contact', id],
|
const biensQ = useContactBiens(id);
|
||||||
queryFn: async () => {
|
|
||||||
if (!id) throw new Error('id');
|
|
||||||
return pb.collection('contacts').getOne<ContactRecord>(id);
|
|
||||||
},
|
|
||||||
enabled: Boolean(id),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!id) {
|
if (!id) {
|
||||||
return (
|
return (
|
||||||
@ -82,19 +93,71 @@ export default function ContactDetailScreen() {
|
|||||||
</Text>
|
</Text>
|
||||||
{c.societe ? <Text className="mt-1 text-slate-600">{c.societe}</Text> : null}
|
{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="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 ? (
|
{c.email ? (
|
||||||
<>
|
<>
|
||||||
<Text className="mt-3 text-sm text-slate-500">Email</Text>
|
<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}
|
) : null}
|
||||||
{c.telephone ? (
|
{c.telephone ? (
|
||||||
<>
|
<>
|
||||||
<Text className="mt-3 text-sm text-slate-500">Téléphone</Text>
|
<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}
|
) : 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>
|
</ScrollView>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -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 { 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 { AVIS_VISITE } from '@/constants/metier';
|
||||||
import { getCurrentUserId, pb } from '@/services/pocketbase';
|
import {
|
||||||
import type { VisiteRecord } from '@/types/collections';
|
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';
|
import { formatPocketBaseError } from '@/utils/pocketbaseErrors';
|
||||||
|
|
||||||
function routeParamId(raw: string | string[] | undefined): string | undefined {
|
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;
|
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() {
|
export default function VisiteDetailScreen() {
|
||||||
const { id: rawId } = useLocalSearchParams<{ id?: string | string[] }>();
|
const { id: rawId } = useLocalSearchParams<{ id?: string | string[] }>();
|
||||||
const id = routeParamId(rawId);
|
const id = routeParamId(rawId);
|
||||||
const uid = getCurrentUserId();
|
const uid = getCurrentUserId();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const q = useVisiteDetail(id);
|
||||||
|
const updateVisite = useVisiteUpdate();
|
||||||
|
|
||||||
const q = useQuery({
|
const [tab, setTab] = useState<TabKey>(0);
|
||||||
queryKey: ['visite_detail', id],
|
const [notesLocal, setNotesLocal] = useState('');
|
||||||
queryFn: async () => {
|
const [minLocal, setMinLocal] = useState('');
|
||||||
if (!id) throw new Error('id');
|
const [maxLocal, setMaxLocal] = useState('');
|
||||||
return pb.collection('visites').getOne<VisiteRecord>(id, { expand: 'bien' });
|
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) {
|
if (!id) {
|
||||||
return (
|
return (
|
||||||
@ -48,7 +191,7 @@ export default function VisiteDetailScreen() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (q.error || !q.data) {
|
if (q.error || !v) {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Stack.Screen options={{ title: 'Erreur', headerShown: true }} />
|
<Stack.Screen options={{ title: 'Erreur', headerShown: true }} />
|
||||||
@ -61,7 +204,6 @@ export default function VisiteDetailScreen() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const v = q.data;
|
|
||||||
if (uid && v.user !== uid) {
|
if (uid && v.user !== uid) {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@ -74,24 +216,172 @@ export default function VisiteDetailScreen() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const titre = v.date_visite?.slice(0, 10) ?? 'Visite';
|
const titre = v.date_visite?.slice(0, 10) ?? 'Visite';
|
||||||
|
const photoCount = Array.isArray(v.photos) ? v.photos.length : v.photos ? 1 : 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Stack.Screen options={{ title: titre, headerShown: true }} />
|
<Stack.Screen options={{ title: titre, headerShown: true }} />
|
||||||
<ScrollView className="flex-1 bg-slate-50 p-4">
|
<View className="flex-1 bg-slate-50">
|
||||||
<Text className="text-lg font-bold text-slate-900">Date</Text>
|
<View className="flex-row border-b border-slate-200 bg-white px-1">
|
||||||
<Text className="text-slate-800">{v.date_visite ?? '—'}</Text>
|
{(['Check-liste', 'Notes', 'Estimation'] as const).map((label, i) => (
|
||||||
<Text className="mt-4 text-lg font-bold text-slate-900">Avis</Text>
|
<Pressable
|
||||||
<Text className="text-slate-800">
|
key={label}
|
||||||
{v.avis_global ? AVIS_VISITE[v.avis_global]?.label ?? v.avis_global : '—'}
|
onPress={() => setTab(i as TabKey)}
|
||||||
</Text>
|
className={`flex-1 py-3 ${tab === i ? 'border-b-2 border-blue-700' : ''}`}
|
||||||
{v.notes_brutes ? (
|
>
|
||||||
<>
|
<Text
|
||||||
<Text className="mt-4 text-lg font-bold text-slate-900">Notes</Text>
|
className={`text-center text-sm font-semibold ${tab === i ? 'text-blue-800' : 'text-slate-600'}`}
|
||||||
<Text className="text-slate-800">{v.notes_brutes}</Text>
|
>
|
||||||
</>
|
{label}
|
||||||
) : null}
|
</Text>
|
||||||
</ScrollView>
|
</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é (1–10)</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>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
20
app/constants/contactCategories.ts
Normal file
20
app/constants/contactCategories.ts
Normal 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;
|
||||||
|
}
|
||||||
23
app/constants/visiteChecklist.ts
Normal file
23
app/constants/visiteChecklist.ts
Normal 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
50
app/hooks/useContacts.ts
Normal 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
78
app/hooks/useTaches.ts
Normal 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
91
app/hooks/useVisites.ts
Normal 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);
|
||||||
|
}
|
||||||
@ -82,8 +82,17 @@ export type ContactRecord = RecordModel & {
|
|||||||
prenom?: string;
|
prenom?: string;
|
||||||
societe?: string;
|
societe?: string;
|
||||||
categorie: string;
|
categorie: string;
|
||||||
|
specialite?: string;
|
||||||
email?: string;
|
email?: string;
|
||||||
telephone?: 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 & {
|
export type AnalyseFinanciereRecord = RecordModel & {
|
||||||
@ -113,9 +122,34 @@ export type VisiteRecord = RecordModel & {
|
|||||||
user: string;
|
user: string;
|
||||||
bien: string;
|
bien: string;
|
||||||
date_visite: string;
|
date_visite: string;
|
||||||
|
duree_minutes?: number;
|
||||||
type_visite?: string;
|
type_visite?: string;
|
||||||
avis_global?: string;
|
avis_global?: string;
|
||||||
notes_brutes?: 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 & {
|
export type NoteRecord = RecordModel & {
|
||||||
|
|||||||
72
app/utils/agendaDates.ts
Normal file
72
app/utils/agendaDates.ts
Normal 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, 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 };
|
||||||
|
}
|
||||||
80
pocketbase/pb_hooks/generate_rapport.pb.js
Normal file
80
pocketbase/pb_hooks/generate_rapport.pb.js
Normal 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(),
|
||||||
|
);
|
||||||
Reference in New Issue
Block a user