feat: app complète - tous les modules
This commit is contained in:
@ -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>
|
||||
</>
|
||||
);
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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 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">
|
||||
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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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 (
|
||||
<>
|
||||
|
||||
@ -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>
|
||||
</>
|
||||
);
|
||||
|
||||
@ -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é (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;
|
||||
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
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 };
|
||||
}
|
||||
Reference in New Issue
Block a user