330 lines
12 KiB
TypeScript
330 lines
12 KiB
TypeScript
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>
|
|
);
|
|
}
|