recherche
This commit is contained in:
329
app/components/recherche/GrillePrixTab.tsx
Normal file
329
app/components/recherche/GrillePrixTab.tsx
Normal file
@ -0,0 +1,329 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import {
|
||||
ActivityIndicator,
|
||||
Alert,
|
||||
Modal,
|
||||
Pressable,
|
||||
ScrollView,
|
||||
Text,
|
||||
TextInput,
|
||||
View,
|
||||
} from 'react-native';
|
||||
|
||||
import { UI } from '@/constants/uiTheme';
|
||||
import { useGrillePrix } from '@/hooks/useGrillePrix';
|
||||
import type { GrillePrixEtat, GrillePrixRecord, GrillePrixTypeBien } from '@/types/collections';
|
||||
import { formatPocketBaseError } from '@/utils/pocketbaseErrors';
|
||||
|
||||
const TYPES: GrillePrixTypeBien[] = ['appartement', 'maison', 'immeuble'];
|
||||
const ETATS: GrillePrixEtat[] = ['bon_etat', 'a_renover', 'travaux_lourds'];
|
||||
|
||||
const TYPE_LABEL: Record<GrillePrixTypeBien, string> = {
|
||||
appartement: 'Appartement',
|
||||
maison: 'Maison',
|
||||
immeuble: 'Immeuble',
|
||||
};
|
||||
|
||||
const ETAT_LABEL: Record<GrillePrixEtat, string> = {
|
||||
bon_etat: 'Bon état',
|
||||
a_renover: 'À rénover',
|
||||
travaux_lourds: 'Travaux lourds',
|
||||
};
|
||||
|
||||
type EditorState =
|
||||
| { mode: 'create' }
|
||||
| { mode: 'edit'; row: GrillePrixRecord };
|
||||
|
||||
function parseNum(raw: string): number | null {
|
||||
const n = Number(String(raw).replace(',', '.').trim());
|
||||
return Number.isFinite(n) && n >= 0 ? n : null;
|
||||
}
|
||||
|
||||
export function GrillePrixTab() {
|
||||
const { rows, isLoading, error, createRow, updateRow, deleteRow, isMutating } = useGrillePrix();
|
||||
const [editor, setEditor] = useState<EditorState | null>(null);
|
||||
const [typeBien, setTypeBien] = useState<GrillePrixTypeBien>('appartement');
|
||||
const [etat, setEtat] = useState<GrillePrixEtat>('bon_etat');
|
||||
const [pa, setPa] = useState('');
|
||||
const [pr, setPr] = useState('');
|
||||
const [ville, setVille] = useState('');
|
||||
|
||||
const moyenneLabel = useMemo(() => {
|
||||
const vals = rows
|
||||
.map((r) => r.marge_estimee_pct)
|
||||
.filter((x): x is number => typeof x === 'number' && !Number.isNaN(x));
|
||||
if (vals.length === 0) return '—';
|
||||
const m = vals.reduce((a, b) => a + b, 0) / vals.length;
|
||||
return `${m.toFixed(1)} %`;
|
||||
}, [rows]);
|
||||
|
||||
const openCreate = () => {
|
||||
setTypeBien('appartement');
|
||||
setEtat('bon_etat');
|
||||
setPa('');
|
||||
setPr('');
|
||||
setVille('');
|
||||
setEditor({ mode: 'create' });
|
||||
};
|
||||
|
||||
const openEdit = (row: GrillePrixRecord) => {
|
||||
setTypeBien(row.type_bien);
|
||||
setEtat(row.etat);
|
||||
setPa(String(row.prix_achat_m2));
|
||||
setPr(String(row.prix_revente_m2));
|
||||
setVille(row.ville ?? '');
|
||||
setEditor({ mode: 'edit', row });
|
||||
};
|
||||
|
||||
const closeEditor = () => setEditor(null);
|
||||
|
||||
const submitEditor = async () => {
|
||||
const achat = parseNum(pa);
|
||||
const revente = parseNum(pr);
|
||||
if (achat == null || revente == null) {
|
||||
Alert.alert('Saisie', 'Indique des prix au m² valides (≥ 0).');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const payload = {
|
||||
type_bien: typeBien,
|
||||
etat,
|
||||
prix_achat_m2: achat,
|
||||
prix_revente_m2: revente,
|
||||
ville: ville.trim() || undefined,
|
||||
};
|
||||
if (editor?.mode === 'create') {
|
||||
await createRow(payload);
|
||||
} else if (editor?.mode === 'edit') {
|
||||
await updateRow({ id: editor.row.id, input: payload });
|
||||
}
|
||||
closeEditor();
|
||||
} catch (e) {
|
||||
Alert.alert('Erreur', formatPocketBaseError(e));
|
||||
}
|
||||
};
|
||||
|
||||
const confirmDelete = (row: GrillePrixRecord) => {
|
||||
Alert.alert('Supprimer cette ligne ?', `${TYPE_LABEL[row.type_bien]} · ${ETAT_LABEL[row.etat]}`, [
|
||||
{ text: 'Annuler', style: 'cancel' },
|
||||
{
|
||||
text: 'Supprimer',
|
||||
style: 'destructive',
|
||||
onPress: () =>
|
||||
void deleteRow(row.id).catch((e) => Alert.alert('Erreur', formatPocketBaseError(e))),
|
||||
},
|
||||
]);
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<View className="flex-1 items-center justify-center py-16">
|
||||
<ActivityIndicator size="large" color={UI.primary} />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<View className="flex-1">
|
||||
{error ? (
|
||||
<Text className="px-3 py-2 text-base text-red-700">{formatPocketBaseError(error)}</Text>
|
||||
) : null}
|
||||
<ScrollView horizontal className="flex-1" contentContainerStyle={{ paddingBottom: 120 }}>
|
||||
<View>
|
||||
<View className="min-w-[720px] flex-row border-b-2 bg-white px-2 py-3" style={{ borderColor: UI.border }}>
|
||||
<Text className="w-28 text-xs font-bold uppercase" style={{ color: UI.textMuted }}>
|
||||
Type
|
||||
</Text>
|
||||
<Text className="w-32 text-xs font-bold uppercase" style={{ color: UI.textMuted }}>
|
||||
État
|
||||
</Text>
|
||||
<Text className="w-28 text-xs font-bold uppercase" style={{ color: UI.textMuted }}>
|
||||
Achat €/m²
|
||||
</Text>
|
||||
<Text className="w-28 text-xs font-bold uppercase" style={{ color: UI.textMuted }}>
|
||||
Revente €/m²
|
||||
</Text>
|
||||
<Text className="w-24 text-xs font-bold uppercase" style={{ color: UI.textMuted }}>
|
||||
Marge %
|
||||
</Text>
|
||||
<Text className="w-28 text-xs font-bold uppercase" style={{ color: UI.textMuted }}>
|
||||
Ville
|
||||
</Text>
|
||||
<Text className="w-20" />
|
||||
</View>
|
||||
{rows.length === 0 ? (
|
||||
<Text className="p-6 text-base" style={{ color: UI.textMuted }}>
|
||||
Aucune ligne. Appuie sur + pour créer ton référentiel.
|
||||
</Text>
|
||||
) : (
|
||||
rows.map((r) => (
|
||||
<View
|
||||
key={r.id}
|
||||
className="min-w-[720px] flex-row border-b px-2 py-4"
|
||||
style={{ borderColor: UI.border }}
|
||||
>
|
||||
<Pressable
|
||||
onPress={() => openEdit(r)}
|
||||
className="min-w-0 flex-1 flex-row active:bg-slate-100"
|
||||
>
|
||||
<Text className="w-28 text-base font-semibold" style={{ color: UI.text }}>
|
||||
{TYPE_LABEL[r.type_bien]}
|
||||
</Text>
|
||||
<Text className="w-32 text-base" style={{ color: UI.text }}>
|
||||
{ETAT_LABEL[r.etat]}
|
||||
</Text>
|
||||
<Text className="w-28 text-base" style={{ color: UI.text }}>
|
||||
{r.prix_achat_m2}
|
||||
</Text>
|
||||
<Text className="w-28 text-base" style={{ color: UI.text }}>
|
||||
{r.prix_revente_m2}
|
||||
</Text>
|
||||
<Text className="w-24 text-base font-bold" style={{ color: UI.success }}>
|
||||
{r.marge_estimee_pct != null ? `${r.marge_estimee_pct.toFixed(1)} %` : '—'}
|
||||
</Text>
|
||||
<Text className="w-28 text-base" style={{ color: UI.textMuted }} numberOfLines={1}>
|
||||
{r.ville ?? '—'}
|
||||
</Text>
|
||||
</Pressable>
|
||||
<Pressable onPress={() => confirmDelete(r)} className="w-20 items-center justify-center">
|
||||
<Text className="font-bold" style={{ color: UI.danger }}>
|
||||
✕
|
||||
</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
))
|
||||
)}
|
||||
</View>
|
||||
</ScrollView>
|
||||
|
||||
<View
|
||||
className="border-t-2 px-4 py-4"
|
||||
style={{ borderColor: UI.border, backgroundColor: UI.card }}
|
||||
>
|
||||
<Text className="text-lg font-bold" style={{ color: UI.text }}>
|
||||
Marge moyenne du référentiel
|
||||
</Text>
|
||||
<Text className="mt-1 text-3xl font-bold" style={{ color: UI.primary }}>
|
||||
{moyenneLabel}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<Pressable
|
||||
accessibilityLabel="Ajouter une ligne"
|
||||
onPress={openCreate}
|
||||
className="absolute bottom-24 right-5 h-16 w-16 items-center justify-center rounded-2xl"
|
||||
style={{ backgroundColor: UI.primary, elevation: 8 }}
|
||||
>
|
||||
<Text className="text-3xl font-light text-white">+</Text>
|
||||
</Pressable>
|
||||
|
||||
<Modal visible={editor != null} animationType="slide" transparent>
|
||||
<View className="flex-1 justify-end bg-black/50">
|
||||
<View className="rounded-t-3xl bg-white p-5">
|
||||
<Text className="text-2xl font-bold" style={{ color: UI.text }}>
|
||||
{editor?.mode === 'edit' ? 'Modifier la ligne' : 'Nouvelle ligne'}
|
||||
</Text>
|
||||
<Text className="mt-4 text-base font-semibold" style={{ color: UI.textMuted }}>
|
||||
Type de bien
|
||||
</Text>
|
||||
<View className="mt-2 flex-row flex-wrap gap-2">
|
||||
{TYPES.map((t) => (
|
||||
<Pressable
|
||||
key={t}
|
||||
onPress={() => setTypeBien(t)}
|
||||
className="min-h-[48px] rounded-xl border-2 px-4 py-2"
|
||||
style={{
|
||||
borderColor: typeBien === t ? UI.primary : UI.border,
|
||||
backgroundColor: typeBien === t ? '#EFF6FF' : UI.card,
|
||||
}}
|
||||
>
|
||||
<Text className="text-base font-bold" style={{ color: UI.text }}>
|
||||
{TYPE_LABEL[t]}
|
||||
</Text>
|
||||
</Pressable>
|
||||
))}
|
||||
</View>
|
||||
<Text className="mt-4 text-base font-semibold" style={{ color: UI.textMuted }}>
|
||||
État
|
||||
</Text>
|
||||
<View className="mt-2 flex-row flex-wrap gap-2">
|
||||
{ETATS.map((t) => (
|
||||
<Pressable
|
||||
key={t}
|
||||
onPress={() => setEtat(t)}
|
||||
className="min-h-[48px] rounded-xl border-2 px-4 py-2"
|
||||
style={{
|
||||
borderColor: etat === t ? UI.primary : UI.border,
|
||||
backgroundColor: etat === t ? '#EFF6FF' : UI.card,
|
||||
}}
|
||||
>
|
||||
<Text className="text-base font-bold" style={{ color: UI.text }}>
|
||||
{ETAT_LABEL[t]}
|
||||
</Text>
|
||||
</Pressable>
|
||||
))}
|
||||
</View>
|
||||
<Text className="mt-4 text-base font-semibold" style={{ color: UI.textMuted }}>
|
||||
Prix achat (€/m²)
|
||||
</Text>
|
||||
<TextInput
|
||||
className="mt-1 rounded-xl border-2 px-4 py-3 text-lg"
|
||||
style={{ borderColor: UI.border, color: UI.text }}
|
||||
keyboardType="decimal-pad"
|
||||
value={pa}
|
||||
onChangeText={setPa}
|
||||
placeholder="4500"
|
||||
placeholderTextColor={UI.textMuted}
|
||||
/>
|
||||
<Text className="mt-4 text-base font-semibold" style={{ color: UI.textMuted }}>
|
||||
Prix revente (€/m²)
|
||||
</Text>
|
||||
<TextInput
|
||||
className="mt-1 rounded-xl border-2 px-4 py-3 text-lg"
|
||||
style={{ borderColor: UI.border, color: UI.text }}
|
||||
keyboardType="decimal-pad"
|
||||
value={pr}
|
||||
onChangeText={setPr}
|
||||
placeholder="5200"
|
||||
placeholderTextColor={UI.textMuted}
|
||||
/>
|
||||
<Text className="mt-4 text-base font-semibold" style={{ color: UI.textMuted }}>
|
||||
Ville (optionnel)
|
||||
</Text>
|
||||
<TextInput
|
||||
className="mt-1 rounded-xl border-2 px-4 py-3 text-lg"
|
||||
style={{ borderColor: UI.border, color: UI.text }}
|
||||
value={ville}
|
||||
onChangeText={setVille}
|
||||
placeholder="Lyon"
|
||||
placeholderTextColor={UI.textMuted}
|
||||
/>
|
||||
<View className="mt-6 flex-row gap-3">
|
||||
<Pressable
|
||||
onPress={closeEditor}
|
||||
className="min-h-[52px] flex-1 items-center justify-center rounded-2xl border-2"
|
||||
style={{ borderColor: UI.border }}
|
||||
>
|
||||
<Text className="text-lg font-bold" style={{ color: UI.text }}>
|
||||
Annuler
|
||||
</Text>
|
||||
</Pressable>
|
||||
<Pressable
|
||||
onPress={() => void submitEditor()}
|
||||
disabled={isMutating}
|
||||
className="min-h-[52px] flex-1 items-center justify-center rounded-2xl"
|
||||
style={{ backgroundColor: UI.primary }}
|
||||
>
|
||||
{isMutating ? (
|
||||
<ActivityIndicator color="#fff" />
|
||||
) : (
|
||||
<Text className="text-lg font-bold text-white">Enregistrer</Text>
|
||||
)}
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</Modal>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
284
app/components/recherche/OpportunitesTab.tsx
Normal file
284
app/components/recherche/OpportunitesTab.tsx
Normal file
@ -0,0 +1,284 @@
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import * as Clipboard from 'expo-clipboard';
|
||||
import type { ReactNode } from 'react';
|
||||
import { useCallback, useState } from 'react';
|
||||
import {
|
||||
ActivityIndicator,
|
||||
Alert,
|
||||
Linking,
|
||||
Pressable,
|
||||
ScrollView,
|
||||
Text,
|
||||
TextInput,
|
||||
View,
|
||||
} from 'react-native';
|
||||
|
||||
import {
|
||||
LEGI_L151_36,
|
||||
LEGI_L152_6,
|
||||
OFF_MARKET_KEYWORDS,
|
||||
PROSPECTION_CHECKLIST,
|
||||
} from '@/constants/rechercheMarche';
|
||||
import { UI } from '@/constants/uiTheme';
|
||||
import { useNotesProspectionRecherche } from '@/hooks/useNotesProspectionRecherche';
|
||||
import { formatPocketBaseError } from '@/utils/pocketbaseErrors';
|
||||
|
||||
const LBC = 'https://www.leboncoin.fr/';
|
||||
const MOTEUR = 'https://www.moteurimmo.fr/';
|
||||
|
||||
function Collapsible({
|
||||
title,
|
||||
children,
|
||||
defaultOpen,
|
||||
}: {
|
||||
title: string;
|
||||
children: ReactNode;
|
||||
defaultOpen?: boolean;
|
||||
}) {
|
||||
const [open, setOpen] = useState(Boolean(defaultOpen));
|
||||
return (
|
||||
<View className="mb-3 rounded-2xl border-2 bg-white" style={{ borderColor: UI.border }}>
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
onPress={() => setOpen((o) => !o)}
|
||||
className="min-h-[52px] flex-row items-center justify-between px-4 py-3"
|
||||
>
|
||||
<Text className="flex-1 pr-2 text-lg font-bold" style={{ color: UI.text }}>
|
||||
{title}
|
||||
</Text>
|
||||
<Ionicons name={open ? 'chevron-up' : 'chevron-down'} size={22} color={UI.textMuted} />
|
||||
</Pressable>
|
||||
{open ? <View className="border-t-2 px-4 pb-4 pt-2" style={{ borderColor: UI.border }}>{children}</View> : null}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
export function OpportunitesTab() {
|
||||
const { getState, saveChecklistItem, isLoading, isSaving } = useNotesProspectionRecherche();
|
||||
const [expandedNoteId, setExpandedNoteId] = useState<string | null>(null);
|
||||
const [noteDraft, setNoteDraft] = useState('');
|
||||
|
||||
const persistItem = useCallback(
|
||||
async (questionId: string, next: { done: boolean; note: string }) => {
|
||||
try {
|
||||
await saveChecklistItem({ questionId, data: next });
|
||||
} catch (e) {
|
||||
Alert.alert('Sauvegarde', formatPocketBaseError(e));
|
||||
}
|
||||
},
|
||||
[saveChecklistItem],
|
||||
);
|
||||
|
||||
const openNoteEditor = (questionId: string) => {
|
||||
const st = getState(questionId);
|
||||
setExpandedNoteId(questionId);
|
||||
setNoteDraft(st.note);
|
||||
};
|
||||
|
||||
const saveNoteFor = async (questionId: string) => {
|
||||
const st = getState(questionId);
|
||||
await persistItem(questionId, { done: st.done, note: noteDraft.trim() });
|
||||
setExpandedNoteId(null);
|
||||
};
|
||||
|
||||
const copyText = async (text: string) => {
|
||||
try {
|
||||
await Clipboard.setStringAsync(text);
|
||||
Alert.alert('Copié', 'Collage dans Leboncoin ou Moteur Immo.');
|
||||
} catch {
|
||||
Alert.alert('Presse-papiers', 'Copie impossible sur cet appareil.');
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<View className="flex-1 items-center justify-center py-16">
|
||||
<ActivityIndicator size="large" color={UI.primary} />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ScrollView className="flex-1 px-3 pt-2" contentContainerStyle={{ paddingBottom: 120 }}>
|
||||
<Collapsible title="1. Mots-clés off-market">
|
||||
{OFF_MARKET_KEYWORDS.map((k) => (
|
||||
<View key={k.id} className="mb-3 rounded-xl border-2 p-3" style={{ borderColor: UI.border }}>
|
||||
<Text className="text-base font-bold" style={{ color: UI.text }}>
|
||||
{k.label}
|
||||
</Text>
|
||||
<Text selectable className="mt-2 font-mono text-sm leading-5" style={{ color: UI.text }}>
|
||||
{k.text}
|
||||
</Text>
|
||||
<Pressable
|
||||
onPress={() => void copyText(k.text)}
|
||||
className="mt-3 min-h-[48px] items-center justify-center rounded-xl"
|
||||
style={{ backgroundColor: UI.primary }}
|
||||
>
|
||||
<Text className="text-base font-bold text-white">Copier</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
))}
|
||||
<View className="mt-2 flex-row flex-wrap gap-3">
|
||||
<Pressable
|
||||
onPress={() => void Linking.openURL(LBC)}
|
||||
className="min-h-[52px] flex-1 min-w-[140px] items-center justify-center rounded-2xl border-2"
|
||||
style={{ borderColor: UI.warning, backgroundColor: '#FFFBEB' }}
|
||||
>
|
||||
<Text className="text-lg font-bold" style={{ color: '#B45309' }}>
|
||||
Leboncoin
|
||||
</Text>
|
||||
</Pressable>
|
||||
<Pressable
|
||||
onPress={() => void Linking.openURL(MOTEUR)}
|
||||
className="min-h-[52px] flex-1 min-w-[140px] items-center justify-center rounded-2xl border-2"
|
||||
style={{ borderColor: UI.warning, backgroundColor: '#FFFBEB' }}
|
||||
>
|
||||
<Text className="text-lg font-bold" style={{ color: '#B45309' }}>
|
||||
Moteur Immo
|
||||
</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
<View className="mt-4 rounded-2xl border-2 p-4" style={{ borderColor: UI.primary, backgroundColor: '#EFF6FF' }}>
|
||||
<Text className="text-base font-bold" style={{ color: UI.primary }}>
|
||||
Astuce maison de ville
|
||||
</Text>
|
||||
<Text className="mt-2 text-base leading-6" style={{ color: UI.text }}>
|
||||
Surface terrain max 100 m² + surface habitable min 150 m² → maisons sans jardin, moins de concurrence,
|
||||
idéal division.
|
||||
</Text>
|
||||
</View>
|
||||
</Collapsible>
|
||||
|
||||
<Collapsible title='2. Biens "fatigués" — décotes'>
|
||||
<Text className="text-base leading-6" style={{ color: UI.text }}>
|
||||
Trier par ancienneté sur Moteur Immo. 40+ mois en ligne + baisses répétées = vendeur motivé. Décote possible
|
||||
sous prix marché.
|
||||
</Text>
|
||||
<View className="mt-3 self-start rounded-xl px-3 py-2" style={{ backgroundColor: '#FEE2E2' }}>
|
||||
<Text className="text-lg font-bold" style={{ color: UI.danger }}>
|
||||
−10 % à −24 %
|
||||
</Text>
|
||||
</View>
|
||||
<Pressable
|
||||
onPress={() => void Linking.openURL(MOTEUR)}
|
||||
className="mt-4 min-h-[52px] items-center justify-center rounded-2xl"
|
||||
style={{ backgroundColor: UI.danger }}
|
||||
>
|
||||
<Text className="text-center text-lg font-bold text-white">Moteur Immo — tri par ancienneté</Text>
|
||||
</Pressable>
|
||||
</Collapsible>
|
||||
|
||||
<Collapsible title="3. Vérifier la division — articles">
|
||||
<View className="mb-3 rounded-2xl border-2 p-4" style={{ borderColor: UI.primary, backgroundColor: '#EFF6FF' }}>
|
||||
<Text className="text-lg font-bold" style={{ color: UI.primary }}>
|
||||
Article L151-36
|
||||
</Text>
|
||||
<Text className="mt-2 text-base leading-6" style={{ color: UI.text }}>
|
||||
1 place de parking max par logement créé en zone bien desservie.
|
||||
</Text>
|
||||
<Pressable
|
||||
onPress={() => void Linking.openURL(LEGI_L151_36)}
|
||||
className="mt-3 min-h-[48px] items-center justify-center rounded-xl bg-white"
|
||||
>
|
||||
<Text className="text-base font-bold" style={{ color: UI.primary }}>
|
||||
Ouvrir sur Légifrance →
|
||||
</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
<View className="rounded-2xl border-2 p-4" style={{ borderColor: UI.success, backgroundColor: '#F0FDF4' }}>
|
||||
<Text className="text-lg font-bold" style={{ color: UI.success }}>
|
||||
Article L152-6
|
||||
</Text>
|
||||
<Text className="mt-2 text-base leading-6" style={{ color: UI.text }}>
|
||||
Dans 500 m d'une gare ou métro → division sans obligation parking.
|
||||
</Text>
|
||||
<Pressable
|
||||
onPress={() => void Linking.openURL(LEGI_L152_6)}
|
||||
className="mt-3 min-h-[48px] items-center justify-center rounded-xl bg-white"
|
||||
>
|
||||
<Text className="text-base font-bold" style={{ color: UI.success }}>
|
||||
Ouvrir sur Légifrance →
|
||||
</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
</Collapsible>
|
||||
|
||||
<Collapsible title="4. Confirmer avec les pros">
|
||||
<Text className="mb-2 text-base" style={{ color: UI.textMuted }}>
|
||||
Coche après échange ; note la réponse pour ton dossier.
|
||||
</Text>
|
||||
{PROSPECTION_CHECKLIST.map((item) => {
|
||||
const st = getState(item.id);
|
||||
const expanded = expandedNoteId === item.id;
|
||||
return (
|
||||
<View key={item.id} className="mb-3 rounded-xl border-2 px-3 py-3" style={{ borderColor: UI.border }}>
|
||||
<Pressable
|
||||
accessibilityRole="checkbox"
|
||||
accessibilityState={{ checked: st.done }}
|
||||
onPress={() => void persistItem(item.id, { done: !st.done, note: st.note })}
|
||||
className="min-h-[48px] flex-row items-start gap-3"
|
||||
>
|
||||
<View
|
||||
className="mt-0.5 h-8 w-8 items-center justify-center rounded-lg border-2"
|
||||
style={{
|
||||
borderColor: st.done ? UI.success : UI.border,
|
||||
backgroundColor: st.done ? UI.success : UI.card,
|
||||
}}
|
||||
>
|
||||
{st.done ? <Text className="font-bold text-white">✓</Text> : null}
|
||||
</View>
|
||||
<View className="min-w-0 flex-1">
|
||||
<Text className="text-base font-bold" style={{ color: UI.textMuted }}>
|
||||
{item.label}
|
||||
</Text>
|
||||
<Text className="mt-1 text-base leading-6" style={{ color: UI.text }}>
|
||||
{item.question}
|
||||
</Text>
|
||||
</View>
|
||||
</Pressable>
|
||||
<Pressable
|
||||
onPress={() => (expanded ? setExpandedNoteId(null) : openNoteEditor(item.id))}
|
||||
className="mt-3 min-h-[44px] justify-center rounded-xl px-3"
|
||||
style={{ backgroundColor: '#F1F5F9' }}
|
||||
>
|
||||
<Text className="text-base font-semibold" style={{ color: UI.primary }}>
|
||||
{expanded ? 'Fermer la note' : st.note ? 'Modifier la note' : 'Ajouter une note'}
|
||||
</Text>
|
||||
</Pressable>
|
||||
{expanded ? (
|
||||
<View className="mt-2">
|
||||
<TextInput
|
||||
className="min-h-[88px] rounded-xl border-2 px-3 py-2 text-base"
|
||||
style={{ borderColor: UI.border, color: UI.text }}
|
||||
multiline
|
||||
textAlignVertical="top"
|
||||
value={noteDraft}
|
||||
onChangeText={setNoteDraft}
|
||||
placeholder="Réponse du pro…"
|
||||
placeholderTextColor={UI.textMuted}
|
||||
/>
|
||||
<Pressable
|
||||
onPress={() => void saveNoteFor(item.id)}
|
||||
disabled={isSaving}
|
||||
className="mt-2 min-h-[48px] items-center justify-center rounded-xl"
|
||||
style={{ backgroundColor: UI.primary }}
|
||||
>
|
||||
{isSaving ? (
|
||||
<ActivityIndicator color="#fff" />
|
||||
) : (
|
||||
<Text className="text-base font-bold text-white">Enregistrer la note</Text>
|
||||
)}
|
||||
</Pressable>
|
||||
</View>
|
||||
) : st.note ? (
|
||||
<Text className="mt-2 text-base italic" style={{ color: UI.textMuted }}>
|
||||
{st.note}
|
||||
</Text>
|
||||
) : null}
|
||||
</View>
|
||||
);
|
||||
})}
|
||||
</Collapsible>
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
||||
168
app/components/recherche/SecteurTab.tsx
Normal file
168
app/components/recherche/SecteurTab.tsx
Normal file
@ -0,0 +1,168 @@
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import type { ComponentProps } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import {
|
||||
ActivityIndicator,
|
||||
Alert,
|
||||
Keyboard,
|
||||
Linking,
|
||||
Pressable,
|
||||
ScrollView,
|
||||
Text,
|
||||
TextInput,
|
||||
View,
|
||||
} from 'react-native';
|
||||
|
||||
import {
|
||||
dvfSearchUrl,
|
||||
meilleursAgentsUrlForVille,
|
||||
SECTOR_TOOLS,
|
||||
type SectorTool,
|
||||
} from '@/constants/rechercheMarche';
|
||||
import { UI } from '@/constants/uiTheme';
|
||||
import { useAnalyseSecteurForVille, useSaveAnalyseSecteur } from '@/hooks/useAnalysesSecteur';
|
||||
import { formatPocketBaseError } from '@/utils/pocketbaseErrors';
|
||||
|
||||
type IonName = ComponentProps<typeof Ionicons>['name'];
|
||||
|
||||
function resolveToolUrl(tool: SectorTool, ville: string): string {
|
||||
if (tool.id === 'ma') return meilleursAgentsUrlForVille(ville);
|
||||
if (tool.id === 'dvf') return dvfSearchUrl(ville);
|
||||
return tool.url;
|
||||
}
|
||||
|
||||
export function SecteurTab() {
|
||||
const [ville, setVille] = useState('');
|
||||
const [notes, setNotes] = useState('');
|
||||
const secteurQ = useAnalyseSecteurForVille(ville);
|
||||
const saveMut = useSaveAnalyseSecteur();
|
||||
|
||||
useEffect(() => {
|
||||
if (secteurQ.data?.notes != null) setNotes(secteurQ.data.notes);
|
||||
}, [secteurQ.data?.id, secteurQ.data?.notes]);
|
||||
|
||||
const onOpenTool = async (tool: SectorTool) => {
|
||||
const url = resolveToolUrl(tool, ville);
|
||||
const ok = await Linking.canOpenURL(url);
|
||||
if (!ok) {
|
||||
Alert.alert('Lien', 'Impossible d’ouvrir ce lien sur cet appareil.');
|
||||
return;
|
||||
}
|
||||
void Linking.openURL(url);
|
||||
};
|
||||
|
||||
const onAnalyser = () => {
|
||||
const v = ville.trim();
|
||||
if (!v) {
|
||||
Alert.alert('Ville', 'Indique une ville ou une commune pour cadrer l’analyse.');
|
||||
return;
|
||||
}
|
||||
Keyboard.dismiss();
|
||||
Alert.alert(
|
||||
'Secteur',
|
||||
`Analyse pour « ${v} » : utilise les outils ci-dessous (données externes), puis consigne tes notes en bas de page.`,
|
||||
);
|
||||
};
|
||||
|
||||
const onSaveNotes = async () => {
|
||||
const v = ville.trim();
|
||||
if (!v) {
|
||||
Alert.alert('Ville', 'Renseigne la ville avant de sauvegarder les notes.');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await saveMut.mutateAsync({ ville: v, notes });
|
||||
} catch (e) {
|
||||
Alert.alert('Erreur', formatPocketBaseError(e));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<ScrollView className="flex-1 px-3 pt-2" contentContainerStyle={{ paddingBottom: 120 }} keyboardShouldPersistTaps="handled">
|
||||
<Text className="text-base font-semibold" style={{ color: UI.text }}>
|
||||
Ville / commune
|
||||
</Text>
|
||||
<TextInput
|
||||
className="mt-2 rounded-2xl border-2 px-4 text-lg"
|
||||
style={{ borderColor: UI.border, color: UI.text, minHeight: 52, backgroundColor: UI.card }}
|
||||
placeholder="Ex. Lyon 3e, Bordeaux…"
|
||||
placeholderTextColor={UI.textMuted}
|
||||
value={ville}
|
||||
onChangeText={setVille}
|
||||
onSubmitEditing={onAnalyser}
|
||||
/>
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
onPress={onAnalyser}
|
||||
className="mt-3 min-h-[52px] items-center justify-center rounded-2xl active:opacity-90"
|
||||
style={{ backgroundColor: UI.primary }}
|
||||
>
|
||||
<Text className="text-lg font-bold text-white">Analyser</Text>
|
||||
</Pressable>
|
||||
|
||||
<Text className="mb-2 mt-8 text-xl font-bold" style={{ color: UI.text }}>
|
||||
Outils marché
|
||||
</Text>
|
||||
<Text className="mb-3 text-base" style={{ color: UI.textMuted }}>
|
||||
Données externes — ouverture dans le navigateur.
|
||||
</Text>
|
||||
{SECTOR_TOOLS.map((tool) => (
|
||||
<Pressable
|
||||
key={tool.id}
|
||||
accessibilityRole="button"
|
||||
onPress={() => void onOpenTool(tool)}
|
||||
className="mb-3 flex-row items-center rounded-2xl border-2 bg-white p-4 active:opacity-90"
|
||||
style={{ borderColor: UI.border }}
|
||||
>
|
||||
<View
|
||||
className="mr-3 h-12 w-12 items-center justify-center rounded-xl"
|
||||
style={{ backgroundColor: '#EFF6FF' }}
|
||||
>
|
||||
<Ionicons name={tool.icon as IonName} size={24} color={UI.primary} />
|
||||
</View>
|
||||
<View className="min-w-0 flex-1">
|
||||
<Text className="text-lg font-bold" style={{ color: UI.text }}>
|
||||
{tool.title}
|
||||
</Text>
|
||||
<Text className="mt-1 text-base leading-5" style={{ color: UI.textMuted }}>
|
||||
{tool.description}
|
||||
</Text>
|
||||
<View className="mt-2 self-start rounded-full px-2 py-1" style={{ backgroundColor: '#E0E7FF' }}>
|
||||
<Text className="text-xs font-bold" style={{ color: UI.primary }}>
|
||||
Externe →
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</Pressable>
|
||||
))}
|
||||
|
||||
<Text className="mb-2 mt-4 text-xl font-bold" style={{ color: UI.text }}>
|
||||
Notes secteur
|
||||
</Text>
|
||||
{secteurQ.isFetching ? <ActivityIndicator color={UI.primary} className="mb-2" /> : null}
|
||||
<TextInput
|
||||
className="min-h-[140px] rounded-2xl border-2 px-4 py-3 text-lg"
|
||||
style={{ borderColor: UI.border, color: UI.text, backgroundColor: UI.card }}
|
||||
multiline
|
||||
textAlignVertical="top"
|
||||
placeholder="Synthèse prix, tension, typologie…"
|
||||
placeholderTextColor={UI.textMuted}
|
||||
value={notes}
|
||||
onChangeText={setNotes}
|
||||
/>
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
onPress={() => void onSaveNotes()}
|
||||
disabled={saveMut.isPending}
|
||||
className="mt-3 min-h-[52px] items-center justify-center rounded-2xl active:opacity-90"
|
||||
style={{ backgroundColor: UI.success }}
|
||||
>
|
||||
{saveMut.isPending ? (
|
||||
<ActivityIndicator color="#fff" />
|
||||
) : (
|
||||
<Text className="text-lg font-bold text-white">Sauvegarder</Text>
|
||||
)}
|
||||
</Pressable>
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user