This commit is contained in:
193
GUIDE_COMPLET.md
Normal file
193
GUIDE_COMPLET.md
Normal file
@ -0,0 +1,193 @@
|
|||||||
|
# GUIDE COMPLET — Prompts Cursor
|
||||||
|
# App Marchand de Biens — Expo + PocketBase
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## PROMPT 1 — Fondation (Setup + Auth + Navigation)
|
||||||
|
|
||||||
|
Lis .cursorrules et AGENTS.md.
|
||||||
|
|
||||||
|
Je crée une app React Native Expo pour marchand de biens immobiliers.
|
||||||
|
Backend : PocketBase sur http://localhost:8090 (déjà lancé, collections déjà créées).
|
||||||
|
|
||||||
|
Collections existantes dans PocketBase :
|
||||||
|
users, etapes_pipeline, contacts, biens, analyses_financieres,
|
||||||
|
visites, taches, notes_biens, documents_biens, devis_travaux
|
||||||
|
|
||||||
|
PARTIE A — Initialisation :
|
||||||
|
Dans le dossier actuel, initialise l'app Expo :
|
||||||
|
npx create-expo-app@latest app --template tabs
|
||||||
|
cd app
|
||||||
|
|
||||||
|
Installe ces dépendances dans app/ :
|
||||||
|
pocketbase
|
||||||
|
@tanstack/react-query
|
||||||
|
zustand
|
||||||
|
nativewind
|
||||||
|
tailwindcss
|
||||||
|
@react-native-async-storage/async-storage
|
||||||
|
expo-image-picker
|
||||||
|
expo-document-picker
|
||||||
|
expo-haptics
|
||||||
|
|
||||||
|
PARTIE B — Fichier .env.local à la racine de app/ :
|
||||||
|
EXPO_PUBLIC_PB_URL=http://localhost:8090
|
||||||
|
|
||||||
|
PARTIE C — Service PocketBase app/services/pocketbase.ts :
|
||||||
|
- Client singleton PocketBase
|
||||||
|
- Persistance session avec AsyncStorage
|
||||||
|
- Export : pb, getCurrentUserId(), isAuthenticated()
|
||||||
|
|
||||||
|
PARTIE D — Types TypeScript app/types/collections.ts :
|
||||||
|
Interfaces pour toutes les collections (étendent RecordModel de pocketbase) :
|
||||||
|
UserRecord, BienRecord, ContactRecord, VisiteRecord, TacheRecord,
|
||||||
|
EtapePipelineRecord, AnalyseFinanciereRecord, NoteRecord, DocumentRecord, DevisRecord
|
||||||
|
+ types BienCreate, BienUpdate (Omit + Partial)
|
||||||
|
|
||||||
|
PARTIE E — Constantes app/constants/metier.ts :
|
||||||
|
ETAPES_DEFAUT (9 étapes avec couleurs)
|
||||||
|
CATEGORIES_CONTACTS avec labels français
|
||||||
|
TYPES_BIENS avec labels français
|
||||||
|
AVIS_VISITE avec labels et couleurs
|
||||||
|
|
||||||
|
PARTIE F — Auth :
|
||||||
|
app/context/AuthContext.tsx : login, logout, user courant, redirect auto
|
||||||
|
app/app/auth/login.tsx : email + password, couleur primaire #1D4ED8
|
||||||
|
app/app/auth/register.tsx : email, password, nom, prénom
|
||||||
|
|
||||||
|
PARTIE G — Navigation :
|
||||||
|
5 onglets dans app/app/(tabs)/ :
|
||||||
|
- index.tsx → Dashboard (icône grid)
|
||||||
|
- biens.tsx → Biens (icône home)
|
||||||
|
- visites.tsx → Visites (icône clipboard)
|
||||||
|
- contacts.tsx → Contacts (icône people)
|
||||||
|
- agenda.tsx → Agenda (icône calendar)
|
||||||
|
|
||||||
|
Écrans de détail :
|
||||||
|
- app/bien/[id].tsx
|
||||||
|
- app/bien/nouveau.tsx
|
||||||
|
- app/contact/[id].tsx
|
||||||
|
- app/visite/[id].tsx
|
||||||
|
- app/calculateur/[bienId].tsx
|
||||||
|
|
||||||
|
Chaque écran de détail = placeholder avec titre pour l'instant.
|
||||||
|
FAB "+" sur les onglets Biens et Contacts.
|
||||||
|
|
||||||
|
L'app doit se lancer avec : cd app && npx expo start
|
||||||
|
L'auth doit fonctionner avec un compte créé sur PocketBase.
|
||||||
|
Mets à jour AGENTS.md quand c'est terminé.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## PROMPT 2 — Pipeline + Fiche Bien + Calculateur
|
||||||
|
## Lancer SEULEMENT après que le Prompt 1 tourne
|
||||||
|
|
||||||
|
Lis .cursorrules et AGENTS.md.
|
||||||
|
L'auth et la navigation fonctionnent. Je construis le cœur de l'app.
|
||||||
|
|
||||||
|
HOOK app/hooks/useEtapes.ts :
|
||||||
|
- fetchEtapes() : étapes du user triées par ordre
|
||||||
|
- initEtapesDefaut() : crée les 9 étapes si l'user n'en a pas encore
|
||||||
|
|
||||||
|
HOOK app/hooks/useBiens.ts :
|
||||||
|
- fetchBiens(filters?) : avec expand etape
|
||||||
|
- fetchBienDetail(id) : avec expand etape, visites, notes
|
||||||
|
- createBien(data), updateBien(id, data), deleteBien(id)
|
||||||
|
- moveBienToEtape(bienId, etapeId)
|
||||||
|
|
||||||
|
ONGLET BIENS app/app/(tabs)/biens.tsx :
|
||||||
|
Switch Kanban / Liste :
|
||||||
|
MODE KANBAN : ScrollView horizontal, une colonne par étape
|
||||||
|
Header colonne : nom + couleur + nombre de biens
|
||||||
|
Card bien : titre, ville, surface, prix achat formaté, badge priorité
|
||||||
|
Long press → bottom sheet : changer étape | supprimer
|
||||||
|
MODE LISTE : FlatList triable, barre de recherche
|
||||||
|
FAB "+" → /bien/nouveau
|
||||||
|
|
||||||
|
FORMULAIRE app/app/bien/nouveau.tsx :
|
||||||
|
3 étapes avec barre de progression :
|
||||||
|
1. type_bien, adresse, ville, code_postal
|
||||||
|
2. surface_habitable, nb_pieces, prix estimé, source, is_off_market
|
||||||
|
3. Résumé + Créer → PocketBase → redirect /bien/[id]
|
||||||
|
|
||||||
|
FICHE BIEN app/app/bien/[id].tsx :
|
||||||
|
Sections : Header | Infos | Finances | Visites | Notes | Documents
|
||||||
|
Auto-save notes debounce 500ms.
|
||||||
|
|
||||||
|
HOOK app/hooks/useAnalyse.ts :
|
||||||
|
- fetchAnalyse(bienId), saveAnalyse(bienId, data)
|
||||||
|
- calculateResults(data) : toutes les formules de .cursorrules
|
||||||
|
|
||||||
|
CALCULATEUR app/app/calculateur/[bienId].tsx :
|
||||||
|
Recalcul temps réel. Sections : Acquisition | Travaux | Portage | Revente
|
||||||
|
Résultats colorés : vert >15% | orange 8-15% | rouge <8%
|
||||||
|
Scénarios -10%/réaliste/+10%. Bouton Enregistrer.
|
||||||
|
|
||||||
|
Mets à jour AGENTS.md.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## PROMPT 3 — Contacts + Visites IA + Agenda + Dashboard
|
||||||
|
## Lancer SEULEMENT après que le Prompt 2 tourne
|
||||||
|
|
||||||
|
Lis .cursorrules et AGENTS.md.
|
||||||
|
Pipeline, fiche bien et calculateur fonctionnent.
|
||||||
|
|
||||||
|
CONTACTS app/app/(tabs)/contacts.tsx :
|
||||||
|
SectionList par catégorie, recherche live, appel direct Linking.openURL tel:
|
||||||
|
Fiche contact : coordonnées, biens associés, notes
|
||||||
|
|
||||||
|
VISITES app/app/(tabs)/visites.tsx :
|
||||||
|
Écran visite app/app/visite/[id].tsx avec 3 tabs :
|
||||||
|
Tab 1 Check-liste : 4 états par item (OK/Attention/Problème/Non vérifié)
|
||||||
|
Tab 2 Notes : zone texte + bouton photo
|
||||||
|
Tab 3 Estimation : sliders travaux, avis global, score 1-10
|
||||||
|
Bouton "Générer rapport IA" → pb.send('/api/generate-rapport') → affiche markdown
|
||||||
|
|
||||||
|
Hook serveur pocketbase/pb_hooks/generate_rapport.pb.js :
|
||||||
|
routerAdd("POST", "/api/generate-rapport", (c) => {
|
||||||
|
const info = $apis.requestInfo(c);
|
||||||
|
if (!info.authRecord) return c.json(401, {error: "Non autorisé"});
|
||||||
|
const { notes_brutes, checklist_reponses, bien_info } = info.data;
|
||||||
|
const response = $http.send({
|
||||||
|
url: "https://api.anthropic.com/v1/messages",
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"x-api-key": $os.getenv("ANTHROPIC_API_KEY"),
|
||||||
|
"anthropic-version": "2023-06-01",
|
||||||
|
"content-type": "application/json"
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
model: "claude-sonnet-4-20250514",
|
||||||
|
max_tokens: 1500,
|
||||||
|
messages: [{ role: "user", content: "Génère un compte-rendu de visite professionnel en français. Bien: " + JSON.stringify(bien_info) + " Notes: " + notes_brutes + " Checklist: " + JSON.stringify(checklist_reponses) }]
|
||||||
|
}),
|
||||||
|
timeout: 30
|
||||||
|
});
|
||||||
|
const result = JSON.parse(response.raw);
|
||||||
|
return c.json(200, { rapport: result.content[0].text });
|
||||||
|
}, $apis.requireRecordAuth());
|
||||||
|
|
||||||
|
AGENDA app/app/(tabs)/agenda.tsx :
|
||||||
|
Vue Aujourd'hui : En retard (rouge) + Aujourd'hui + Cette semaine
|
||||||
|
Card tâche : checkbox, titre, badge bien, swipe snooze/supprimer
|
||||||
|
Création : bottom sheet
|
||||||
|
|
||||||
|
DASHBOARD app/app/(tabs)/index.tsx :
|
||||||
|
Alertes urgentes | KPIs | Mini pipeline | Derniers biens | Tâches du jour
|
||||||
|
|
||||||
|
Mets à jour AGENTS.md : tous modules terminés.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## PROMPT DEBUG
|
||||||
|
Lis .cursorrules.
|
||||||
|
Erreur dans [MODULE] :
|
||||||
|
ERREUR : [message exact]
|
||||||
|
FICHIER : [nom]
|
||||||
|
Stack : Expo + PocketBase v0.23+. Diagnostique et corrige.
|
||||||
|
|
||||||
|
## PROMPT UI
|
||||||
|
Lis .cursorrules.
|
||||||
|
L'écran [NOM] fonctionne. Améliore l'UI pour usage pro en extérieur.
|
||||||
|
Couleurs : #1D4ED8 | #16A34A | #D97706 | #DC2626
|
||||||
6
app/.gitignore
vendored
Normal file
6
app/.gitignore
vendored
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
|
||||||
|
# @generated expo-cli sync-2b81b286409207a5da26e14c78851eb30d8ccbdb
|
||||||
|
# The following patterns were generated by expo-cli
|
||||||
|
|
||||||
|
expo-env.d.ts
|
||||||
|
# @end expo-cli
|
||||||
37
app/app.config.js
Normal file
37
app/app.config.js
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
/**
|
||||||
|
* Charge les variables d'environnement depuis `app/.env*` puis `../.env*`
|
||||||
|
* (le repo a souvent `.env.local` à la racine `mdb/`, pas dans `mdb/app/`).
|
||||||
|
*/
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
function loadEnvFiles() {
|
||||||
|
const dirs = [__dirname, path.join(__dirname, '..')];
|
||||||
|
const names = ['.env.local', '.env'];
|
||||||
|
for (const dir of dirs) {
|
||||||
|
for (const name of names) {
|
||||||
|
const full = path.join(dir, name);
|
||||||
|
if (!fs.existsSync(full)) continue;
|
||||||
|
const raw = fs.readFileSync(full, 'utf8');
|
||||||
|
for (const line of raw.split('\n')) {
|
||||||
|
const t = line.trim();
|
||||||
|
if (!t || t.startsWith('#')) continue;
|
||||||
|
const i = t.indexOf('=');
|
||||||
|
if (i <= 0) continue;
|
||||||
|
const key = t.slice(0, i).trim();
|
||||||
|
let val = t.slice(i + 1).trim();
|
||||||
|
if (
|
||||||
|
(val.startsWith('"') && val.endsWith('"')) ||
|
||||||
|
(val.startsWith("'") && val.endsWith("'"))
|
||||||
|
) {
|
||||||
|
val = val.slice(1, -1);
|
||||||
|
}
|
||||||
|
if (process.env[key] === undefined) process.env[key] = val;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
loadEnvFiles();
|
||||||
|
|
||||||
|
module.exports = require('./app.json');
|
||||||
31
app/app.json
Normal file
31
app/app.json
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
{
|
||||||
|
"expo": {
|
||||||
|
"name": "mdb",
|
||||||
|
"slug": "mdb",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"orientation": "portrait",
|
||||||
|
"icon": "./assets/images/icon.png",
|
||||||
|
"scheme": "mdb",
|
||||||
|
"userInterfaceStyle": "automatic",
|
||||||
|
"newArchEnabled": true,
|
||||||
|
"splash": {
|
||||||
|
"image": "./assets/images/splash-icon.png",
|
||||||
|
"resizeMode": "contain",
|
||||||
|
"backgroundColor": "#f8fafc"
|
||||||
|
},
|
||||||
|
"ios": { "supportsTablet": true },
|
||||||
|
"android": {
|
||||||
|
"adaptiveIcon": {
|
||||||
|
"foregroundImage": "./assets/images/adaptive-icon.png",
|
||||||
|
"backgroundColor": "#f8fafc"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"web": {
|
||||||
|
"bundler": "metro",
|
||||||
|
"output": "static",
|
||||||
|
"favicon": "./assets/images/favicon.png"
|
||||||
|
},
|
||||||
|
"plugins": ["expo-router"],
|
||||||
|
"experiments": { "typedRoutes": true }
|
||||||
|
}
|
||||||
|
}
|
||||||
50
app/app/(tabs)/_layout.tsx
Normal file
50
app/app/(tabs)/_layout.tsx
Normal file
@ -0,0 +1,50 @@
|
|||||||
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
|
import { Tabs } from 'expo-router';
|
||||||
|
|
||||||
|
export default function TabsLayout() {
|
||||||
|
return (
|
||||||
|
<Tabs
|
||||||
|
screenOptions={{
|
||||||
|
tabBarActiveTintColor: '#1D4ED8',
|
||||||
|
headerStyle: { backgroundColor: '#f8fafc' },
|
||||||
|
headerTitleStyle: { fontWeight: '700', color: '#0f172a' },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Tabs.Screen
|
||||||
|
name="index"
|
||||||
|
options={{
|
||||||
|
title: 'Dashboard',
|
||||||
|
tabBarIcon: ({ color, size }) => <Ionicons name="home-outline" size={size} color={color} />,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Tabs.Screen
|
||||||
|
name="biens"
|
||||||
|
options={{
|
||||||
|
title: 'Biens',
|
||||||
|
tabBarIcon: ({ color, size }) => <Ionicons name="business-outline" size={size} color={color} />,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Tabs.Screen
|
||||||
|
name="visites"
|
||||||
|
options={{
|
||||||
|
title: 'Visites',
|
||||||
|
tabBarIcon: ({ color, size }) => <Ionicons name="calendar-outline" size={size} color={color} />,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Tabs.Screen
|
||||||
|
name="contacts"
|
||||||
|
options={{
|
||||||
|
title: 'Contacts',
|
||||||
|
tabBarIcon: ({ color, size }) => <Ionicons name="people-outline" size={size} color={color} />,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Tabs.Screen
|
||||||
|
name="agenda"
|
||||||
|
options={{
|
||||||
|
title: 'Agenda',
|
||||||
|
tabBarIcon: ({ color, size }) => <Ionicons name="list-outline" size={size} color={color} />,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Tabs>
|
||||||
|
);
|
||||||
|
}
|
||||||
13
app/app/(tabs)/agenda.tsx
Normal file
13
app/app/(tabs)/agenda.tsx
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
import { Stack } from 'expo-router';
|
||||||
|
import { Text, View } from 'react-native';
|
||||||
|
|
||||||
|
export default function AgendaTab() {
|
||||||
|
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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
129
app/app/(tabs)/biens.tsx
Normal file
129
app/app/(tabs)/biens.tsx
Normal file
@ -0,0 +1,129 @@
|
|||||||
|
import { Link, Stack } from 'expo-router';
|
||||||
|
import { useEffect, useMemo, useRef } from 'react';
|
||||||
|
import { ActivityIndicator, Pressable, ScrollView, Text, View } from 'react-native';
|
||||||
|
|
||||||
|
import { useBiens, type BienExpanded } from '@/hooks/useBiens';
|
||||||
|
import { useEtapes } from '@/hooks/useEtapes';
|
||||||
|
import { formatEUR } from '@/utils/format';
|
||||||
|
import { formatPocketBaseError } from '@/utils/pocketbaseErrors';
|
||||||
|
|
||||||
|
export default function BiensScreen() {
|
||||||
|
const { biens, prixByBien, isLoading, error } = useBiens();
|
||||||
|
const {
|
||||||
|
etapes,
|
||||||
|
isLoading: etapesLoading,
|
||||||
|
error: etapesError,
|
||||||
|
initEtapesDefaut,
|
||||||
|
initError: etapesInitMutationError,
|
||||||
|
} = useEtapes();
|
||||||
|
const initOnce = useRef(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (etapesLoading || etapes.length > 0 || initOnce.current) return;
|
||||||
|
initOnce.current = true;
|
||||||
|
void initEtapesDefaut().catch(() => {
|
||||||
|
initOnce.current = false;
|
||||||
|
});
|
||||||
|
}, [etapesLoading, etapes.length, initEtapesDefaut]);
|
||||||
|
|
||||||
|
const grouped = useMemo(() => {
|
||||||
|
const m = new Map<string, BienExpanded[]>();
|
||||||
|
const none = '__none__';
|
||||||
|
for (const e of etapes) m.set(e.id, []);
|
||||||
|
m.set(none, []);
|
||||||
|
for (const b of biens) {
|
||||||
|
const k = b.etape && m.has(b.etape) ? b.etape : none;
|
||||||
|
if (!m.has(k)) m.set(k, []);
|
||||||
|
m.get(k)!.push(b);
|
||||||
|
}
|
||||||
|
return { m, none };
|
||||||
|
}, [biens, etapes]);
|
||||||
|
|
||||||
|
const banner =
|
||||||
|
error != null
|
||||||
|
? formatPocketBaseError(error)
|
||||||
|
: etapesError != null
|
||||||
|
? formatPocketBaseError(etapesError)
|
||||||
|
: etapesInitMutationError != null
|
||||||
|
? formatPocketBaseError(etapesInitMutationError)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Stack.Screen options={{ title: 'Biens', headerShown: true }} />
|
||||||
|
<View className="flex-1 bg-slate-50">
|
||||||
|
{banner ? (
|
||||||
|
<View className="border-b border-red-200 bg-red-50 px-3 py-2">
|
||||||
|
<Text className="text-sm text-red-900">{banner}</Text>
|
||||||
|
</View>
|
||||||
|
) : null}
|
||||||
|
{isLoading || etapesLoading ? (
|
||||||
|
<View className="flex-1 items-center justify-center">
|
||||||
|
<ActivityIndicator size="large" color="#1D4ED8" />
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
|
<ScrollView horizontal className="flex-1" contentContainerStyle={{ padding: 12, paddingBottom: 96 }}>
|
||||||
|
{etapes.map((e) => {
|
||||||
|
const list = grouped.m.get(e.id) ?? [];
|
||||||
|
return (
|
||||||
|
<View key={e.id} className="mr-3 w-56 rounded-xl border border-slate-200 bg-white p-2">
|
||||||
|
<View className="mb-2 flex-row items-center justify-between border-b border-slate-100 pb-2">
|
||||||
|
<Text className="flex-1 font-bold text-slate-900" numberOfLines={2}>
|
||||||
|
{e.nom}
|
||||||
|
</Text>
|
||||||
|
<Text className="text-xs text-slate-500">{list.length}</Text>
|
||||||
|
</View>
|
||||||
|
<Text className="mb-2 text-xs text-slate-500">{list.length} bien(s)</Text>
|
||||||
|
<ScrollView nestedScrollEnabled>
|
||||||
|
{list.map((b) => (
|
||||||
|
<Link key={b.id} href={`/bien/${b.id}`} asChild>
|
||||||
|
<Pressable className="mb-2 rounded-lg border border-slate-100 bg-slate-50 p-2">
|
||||||
|
<Text className="font-medium text-slate-900" numberOfLines={2}>
|
||||||
|
{b.titre ?? 'Sans titre'}
|
||||||
|
</Text>
|
||||||
|
<Text className="text-xs text-slate-500" numberOfLines={1}>
|
||||||
|
{[b.code_postal, b.ville].filter(Boolean).join(' ') || '—'}
|
||||||
|
</Text>
|
||||||
|
{prixByBien.has(b.id) ? (
|
||||||
|
<Text className="mt-1 text-xs font-semibold text-slate-700">
|
||||||
|
{formatEUR(prixByBien.get(b.id))}
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
</Pressable>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</ScrollView>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
<View className="mr-3 w-56 rounded-xl border border-dashed border-slate-300 bg-slate-100/80 p-2">
|
||||||
|
<Text className="mb-2 font-bold text-slate-700">Sans étape</Text>
|
||||||
|
<Text className="mb-2 text-xs text-slate-500">
|
||||||
|
{(grouped.m.get(grouped.none) ?? []).length} bien(s)
|
||||||
|
</Text>
|
||||||
|
<ScrollView nestedScrollEnabled>
|
||||||
|
{(grouped.m.get(grouped.none) ?? []).map((b) => (
|
||||||
|
<Link key={b.id} href={`/bien/${b.id}`} asChild>
|
||||||
|
<Pressable className="mb-2 rounded-lg border border-slate-200 bg-white p-2">
|
||||||
|
<Text className="font-medium text-slate-900" numberOfLines={2}>
|
||||||
|
{b.titre ?? 'Sans titre'}
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</ScrollView>
|
||||||
|
</View>
|
||||||
|
</ScrollView>
|
||||||
|
)}
|
||||||
|
<Link href="/bien/nouveau" asChild>
|
||||||
|
<Pressable
|
||||||
|
className="absolute bottom-6 right-5 h-14 w-14 items-center justify-center rounded-full bg-blue-700 shadow-md"
|
||||||
|
style={{ elevation: 6 }}
|
||||||
|
>
|
||||||
|
<Text className="text-3xl leading-8 font-light text-white">+</Text>
|
||||||
|
</Pressable>
|
||||||
|
</Link>
|
||||||
|
</View>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
57
app/app/(tabs)/contacts.tsx
Normal file
57
app/app/(tabs)/contacts.tsx
Normal file
@ -0,0 +1,57 @@
|
|||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { Link, Stack } from 'expo-router';
|
||||||
|
import { ActivityIndicator, Pressable, ScrollView, Text, View } from 'react-native';
|
||||||
|
|
||||||
|
import { getCurrentUserId, pb } from '@/services/pocketbase';
|
||||||
|
import type { ContactRecord } from '@/types/collections';
|
||||||
|
import { formatPocketBaseError } from '@/utils/pocketbaseErrors';
|
||||||
|
|
||||||
|
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),
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Stack.Screen options={{ title: 'Contacts', headerShown: true }} />
|
||||||
|
<View className="flex-1 bg-slate-50">
|
||||||
|
{q.error ? (
|
||||||
|
<Text className="p-4 text-red-700">{formatPocketBaseError(q.error)}</Text>
|
||||||
|
) : null}
|
||||||
|
{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>
|
||||||
|
)}
|
||||||
|
<Link href="/contact/nouveau" asChild>
|
||||||
|
<Pressable className="absolute bottom-6 right-5 rounded-full bg-blue-700 px-4 py-3">
|
||||||
|
<Text className="font-semibold text-white">+ Contact</Text>
|
||||||
|
</Pressable>
|
||||||
|
</Link>
|
||||||
|
</View>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
20
app/app/(tabs)/index.tsx
Normal file
20
app/app/(tabs)/index.tsx
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
import { Link, Stack } from 'expo-router';
|
||||||
|
import { Text, View } from 'react-native';
|
||||||
|
|
||||||
|
export default function DashboardScreen() {
|
||||||
|
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>
|
||||||
|
<Link href="/bien/nouveau" className="mb-2 text-base font-semibold text-blue-700">
|
||||||
|
Nouveau bien
|
||||||
|
</Link>
|
||||||
|
</View>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
58
app/app/(tabs)/visites.tsx
Normal file
58
app/app/(tabs)/visites.tsx
Normal file
@ -0,0 +1,58 @@
|
|||||||
|
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 { 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),
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Stack.Screen options={{ title: 'Visites', headerShown: true }} />
|
||||||
|
<View className="flex-1 bg-slate-50">
|
||||||
|
{q.error ? (
|
||||||
|
<Text className="p-4 text-red-700">{formatPocketBaseError(q.error)}</Text>
|
||||||
|
) : null}
|
||||||
|
{q.isPending ? (
|
||||||
|
<View className="flex-1 items-center justify-center">
|
||||||
|
<ActivityIndicator color="#1D4ED8" />
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
|
<ScrollView className="flex-1 p-3">
|
||||||
|
{q.data?.length === 0 ? (
|
||||||
|
<Text className="text-slate-600">Aucune visite.</Text>
|
||||||
|
) : null}
|
||||||
|
{q.data?.map((v) => (
|
||||||
|
<Pressable
|
||||||
|
key={v.id}
|
||||||
|
className="mb-2 rounded-xl border border-slate-200 bg-white p-3"
|
||||||
|
onPress={() => router.push(`/visite/${v.id}`)}
|
||||||
|
>
|
||||||
|
<Text className="font-semibold text-slate-900">{v.date_visite?.slice(0, 10) ?? '—'}</Text>
|
||||||
|
<Text className="text-sm text-slate-600">
|
||||||
|
{v.avis_global ? AVIS_VISITE[v.avis_global]?.label ?? v.avis_global : '—'}
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
))}
|
||||||
|
</ScrollView>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
18
app/app/_layout.tsx
Normal file
18
app/app/_layout.tsx
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
import '../global.css';
|
||||||
|
|
||||||
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||||
|
import { Stack } from 'expo-router';
|
||||||
|
|
||||||
|
import { AuthProvider } from '@/context/AuthContext';
|
||||||
|
|
||||||
|
const queryClient = new QueryClient();
|
||||||
|
|
||||||
|
export default function RootLayout() {
|
||||||
|
return (
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<AuthProvider>
|
||||||
|
<Stack screenOptions={{ headerShown: false }} />
|
||||||
|
</AuthProvider>
|
||||||
|
</QueryClientProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
13
app/app/auth/_layout.tsx
Normal file
13
app/app/auth/_layout.tsx
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
import { Stack } from 'expo-router';
|
||||||
|
|
||||||
|
export default function AuthLayout() {
|
||||||
|
return (
|
||||||
|
<Stack
|
||||||
|
screenOptions={{
|
||||||
|
headerShown: true,
|
||||||
|
headerStyle: { backgroundColor: '#f8fafc' },
|
||||||
|
headerTitleStyle: { fontWeight: '700', color: '#0f172a' },
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
71
app/app/auth/login.tsx
Normal file
71
app/app/auth/login.tsx
Normal file
@ -0,0 +1,71 @@
|
|||||||
|
import { Link, Stack, useRouter } from 'expo-router';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { Pressable, Text, TextInput, View } from 'react-native';
|
||||||
|
|
||||||
|
import { useAuth } from '@/context/AuthContext';
|
||||||
|
import { formatPocketBaseError } from '@/utils/pocketbaseErrors';
|
||||||
|
|
||||||
|
export default function LoginScreen() {
|
||||||
|
const router = useRouter();
|
||||||
|
const { login } = useAuth();
|
||||||
|
const [email, setEmail] = useState('');
|
||||||
|
const [password, setPassword] = useState('');
|
||||||
|
const [err, setErr] = useState<string | null>(null);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
|
||||||
|
const onSubmit = async () => {
|
||||||
|
setErr(null);
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
await login(email, password);
|
||||||
|
router.replace('/(tabs)');
|
||||||
|
} catch (e) {
|
||||||
|
setErr(formatPocketBaseError(e));
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Stack.Screen options={{ title: 'Connexion' }} />
|
||||||
|
<View className="flex-1 justify-center bg-slate-50 px-6">
|
||||||
|
{err ? (
|
||||||
|
<View className="mb-4 rounded-xl border border-red-200 bg-red-50 px-3 py-2">
|
||||||
|
<Text className="text-sm text-red-900">{err}</Text>
|
||||||
|
</View>
|
||||||
|
) : null}
|
||||||
|
<Text className="mb-1 text-sm text-slate-600">Email</Text>
|
||||||
|
<TextInput
|
||||||
|
className="mb-4 rounded-xl border border-slate-200 bg-white px-3 py-3 text-base text-slate-900"
|
||||||
|
autoCapitalize="none"
|
||||||
|
keyboardType="email-address"
|
||||||
|
value={email}
|
||||||
|
onChangeText={setEmail}
|
||||||
|
placeholderTextColor="#94a3b8"
|
||||||
|
/>
|
||||||
|
<Text className="mb-1 text-sm text-slate-600">Mot de passe</Text>
|
||||||
|
<TextInput
|
||||||
|
className="mb-6 rounded-xl border border-slate-200 bg-white px-3 py-3 text-base text-slate-900"
|
||||||
|
secureTextEntry
|
||||||
|
value={password}
|
||||||
|
onChangeText={setPassword}
|
||||||
|
placeholderTextColor="#94a3b8"
|
||||||
|
/>
|
||||||
|
<Pressable
|
||||||
|
className="mb-4 rounded-xl py-3"
|
||||||
|
style={{ backgroundColor: busy ? '#94a3b8' : '#1D4ED8' }}
|
||||||
|
onPress={onSubmit}
|
||||||
|
disabled={busy}
|
||||||
|
>
|
||||||
|
<Text className="text-center font-semibold text-white">Se connecter</Text>
|
||||||
|
</Pressable>
|
||||||
|
<Link href="/auth/register" asChild>
|
||||||
|
<Pressable>
|
||||||
|
<Text className="text-center text-blue-700">Créer un compte</Text>
|
||||||
|
</Pressable>
|
||||||
|
</Link>
|
||||||
|
</View>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
79
app/app/auth/register.tsx
Normal file
79
app/app/auth/register.tsx
Normal file
@ -0,0 +1,79 @@
|
|||||||
|
import { Link, Stack, useRouter } from 'expo-router';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { Pressable, Text, TextInput, View } from 'react-native';
|
||||||
|
|
||||||
|
import { useAuth } from '@/context/AuthContext';
|
||||||
|
import { formatPocketBaseError } from '@/utils/pocketbaseErrors';
|
||||||
|
|
||||||
|
export default function RegisterScreen() {
|
||||||
|
const router = useRouter();
|
||||||
|
const { register } = useAuth();
|
||||||
|
const [name, setName] = useState('');
|
||||||
|
const [email, setEmail] = useState('');
|
||||||
|
const [password, setPassword] = useState('');
|
||||||
|
const [err, setErr] = useState<string | null>(null);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
|
||||||
|
const onSubmit = async () => {
|
||||||
|
setErr(null);
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
await register({ name, email, password });
|
||||||
|
router.replace('/(tabs)');
|
||||||
|
} catch (e) {
|
||||||
|
setErr(formatPocketBaseError(e));
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Stack.Screen options={{ title: 'Inscription' }} />
|
||||||
|
<View className="flex-1 justify-center bg-slate-50 px-6">
|
||||||
|
{err ? (
|
||||||
|
<View className="mb-4 rounded-xl border border-red-200 bg-red-50 px-3 py-2">
|
||||||
|
<Text className="text-sm text-red-900">{err}</Text>
|
||||||
|
</View>
|
||||||
|
) : null}
|
||||||
|
<Text className="mb-1 text-sm text-slate-600">Nom</Text>
|
||||||
|
<TextInput
|
||||||
|
className="mb-3 rounded-xl border border-slate-200 bg-white px-3 py-3 text-base text-slate-900"
|
||||||
|
value={name}
|
||||||
|
onChangeText={setName}
|
||||||
|
placeholderTextColor="#94a3b8"
|
||||||
|
/>
|
||||||
|
<Text className="mb-1 text-sm text-slate-600">Email</Text>
|
||||||
|
<TextInput
|
||||||
|
className="mb-3 rounded-xl border border-slate-200 bg-white px-3 py-3 text-base text-slate-900"
|
||||||
|
autoCapitalize="none"
|
||||||
|
keyboardType="email-address"
|
||||||
|
value={email}
|
||||||
|
onChangeText={setEmail}
|
||||||
|
placeholderTextColor="#94a3b8"
|
||||||
|
/>
|
||||||
|
<Text className="mb-1 text-sm text-slate-600">Mot de passe</Text>
|
||||||
|
<TextInput
|
||||||
|
className="mb-6 rounded-xl border border-slate-200 bg-white px-3 py-3 text-base text-slate-900"
|
||||||
|
secureTextEntry
|
||||||
|
value={password}
|
||||||
|
onChangeText={setPassword}
|
||||||
|
placeholderTextColor="#94a3b8"
|
||||||
|
/>
|
||||||
|
<Pressable
|
||||||
|
className="mb-4 rounded-xl py-3"
|
||||||
|
style={{ backgroundColor: busy ? '#94a3b8' : '#1D4ED8' }}
|
||||||
|
onPress={onSubmit}
|
||||||
|
disabled={busy}
|
||||||
|
>
|
||||||
|
<Text className="text-center font-semibold text-white">S'inscrire</Text>
|
||||||
|
</Pressable>
|
||||||
|
<Link href="/auth/login" asChild>
|
||||||
|
<Pressable>
|
||||||
|
<Text className="text-center text-blue-700">Déjà un compte ? Connexion</Text>
|
||||||
|
</Pressable>
|
||||||
|
</Link>
|
||||||
|
</View>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
234
app/app/bien/[id].tsx
Normal file
234
app/app/bien/[id].tsx
Normal file
@ -0,0 +1,234 @@
|
|||||||
|
import { Link, Stack, useLocalSearchParams, useRouter } from 'expo-router';
|
||||||
|
import type { ReactNode } from 'react';
|
||||||
|
import {
|
||||||
|
ActivityIndicator,
|
||||||
|
FlatList,
|
||||||
|
Pressable,
|
||||||
|
ScrollView,
|
||||||
|
Text,
|
||||||
|
TextInput,
|
||||||
|
View,
|
||||||
|
} from 'react-native';
|
||||||
|
|
||||||
|
import { AVIS_VISITE, TYPES_BIENS } from '@/constants/metier';
|
||||||
|
import { useBienDetail } from '@/hooks/useBiens';
|
||||||
|
import { useNoteLibre } from '@/hooks/useNoteLibre';
|
||||||
|
import { calculateResults, type AnalyseFormInput } from '@/hooks/useAnalyse';
|
||||||
|
import { formatEUR } from '@/utils/format';
|
||||||
|
|
||||||
|
function routeParamId(raw: string | string[] | undefined): string | undefined {
|
||||||
|
if (raw == null) return undefined;
|
||||||
|
return Array.isArray(raw) ? raw[0] : raw;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function BienDetailScreen() {
|
||||||
|
const { id: rawId } = useLocalSearchParams<{ id?: string | string[] }>();
|
||||||
|
const id = routeParamId(rawId);
|
||||||
|
const router = useRouter();
|
||||||
|
const { bundle, isLoading, error } = useBienDetail(id);
|
||||||
|
const { draft, setDraft, hydrated } = useNoteLibre(id, bundle?.notes);
|
||||||
|
|
||||||
|
if (!id) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Stack.Screen options={{ title: 'Bien', headerShown: true }} />
|
||||||
|
<View className="flex-1 items-center justify-center bg-slate-50 p-6">
|
||||||
|
<Text className="text-slate-600">Identifiant manquant.</Text>
|
||||||
|
</View>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Stack.Screen options={{ title: 'Chargement…', headerShown: true }} />
|
||||||
|
<View className="flex-1 items-center justify-center bg-slate-50">
|
||||||
|
<ActivityIndicator size="large" color="#1D4ED8" />
|
||||||
|
</View>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error || !bundle) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Stack.Screen options={{ title: 'Erreur', headerShown: true }} />
|
||||||
|
<View className="flex-1 items-center justify-center bg-slate-50 p-6">
|
||||||
|
<Text className="text-center text-slate-600">
|
||||||
|
{error instanceof Error ? error.message : 'Impossible de charger ce bien.'}
|
||||||
|
</Text>
|
||||||
|
<Pressable
|
||||||
|
className="mt-4 rounded-xl px-4 py-2"
|
||||||
|
style={{ backgroundColor: '#1D4ED8' }}
|
||||||
|
onPress={() => router.replace('/(tabs)/biens')}
|
||||||
|
>
|
||||||
|
<Text className="font-semibold text-white">Vers la liste des biens</Text>
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { bien, visites, notes, documents, analyse } = bundle;
|
||||||
|
const etape = bien.expand?.etape;
|
||||||
|
|
||||||
|
const analyseInput: AnalyseFormInput = {
|
||||||
|
prix_achat: analyse?.prix_achat,
|
||||||
|
type_bien_fiscal: analyse?.type_bien_fiscal,
|
||||||
|
frais_notaire: analyse?.frais_notaire,
|
||||||
|
frais_agence_achat: analyse?.frais_agence_achat,
|
||||||
|
budget_travaux: analyse?.budget_travaux,
|
||||||
|
reserve_imprevus_pct: analyse?.reserve_imprevus_pct,
|
||||||
|
duree_portage_mois: analyse?.duree_portage_mois,
|
||||||
|
taux_credit: analyse?.taux_credit,
|
||||||
|
taxe_fonciere_annuelle: analyse?.taxe_fonciere_annuelle,
|
||||||
|
charges_copropriete_mensuelle: analyse?.charges_copropriete_mensuelle,
|
||||||
|
prix_revente_cible: analyse?.prix_revente_cible,
|
||||||
|
frais_agence_vente_pct: analyse?.frais_agence_vente_pct,
|
||||||
|
taux_impot: analyse?.taux_impot,
|
||||||
|
};
|
||||||
|
const calc = calculateResults(analyseInput);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Stack.Screen options={{ title: bien.titre ?? 'Bien', headerShown: true }} />
|
||||||
|
<ScrollView className="flex-1 bg-slate-50" contentContainerStyle={{ paddingBottom: 48 }}>
|
||||||
|
<Section title="En-tête">
|
||||||
|
{etape ? (
|
||||||
|
<View
|
||||||
|
className="self-start rounded-full px-3 py-1"
|
||||||
|
style={{ backgroundColor: `${etape.couleur ?? '#64748B'}33` }}
|
||||||
|
>
|
||||||
|
<Text className="text-sm font-semibold text-slate-900">{etape.nom}</Text>
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
|
<Text className="text-slate-600">Aucune étape assignée.</Text>
|
||||||
|
)}
|
||||||
|
<Text className="mt-2 text-2xl font-bold text-slate-900">{bien.titre ?? 'Sans titre'}</Text>
|
||||||
|
<Text className="mt-1 text-slate-600">
|
||||||
|
{[bien.adresse, bien.code_postal, bien.ville].filter(Boolean).join(' · ') || '—'}
|
||||||
|
</Text>
|
||||||
|
<Link href={`/calculateur/${bien.id}`} asChild>
|
||||||
|
<Pressable className="mt-4 self-start rounded-xl px-4 py-2" style={{ backgroundColor: '#1D4ED8' }}>
|
||||||
|
<Text className="font-semibold text-white">Ouvrir le calculateur</Text>
|
||||||
|
</Pressable>
|
||||||
|
</Link>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Section title="Infos">
|
||||||
|
<InfoLine label="Type" value={bien.type_bien ? TYPES_BIENS[bien.type_bien] ?? bien.type_bien : '—'} />
|
||||||
|
<InfoLine
|
||||||
|
label="Surface habitable"
|
||||||
|
value={bien.surface_habitable != null ? `${bien.surface_habitable} m²` : '—'}
|
||||||
|
/>
|
||||||
|
<InfoLine label="Pièces" value={bien.nb_pieces != null ? String(bien.nb_pieces) : '—'} />
|
||||||
|
<InfoLine label="Source" value={bien.source ?? '—'} />
|
||||||
|
<InfoLine label="Off-market" value={bien.is_off_market ? 'Oui' : 'Non'} />
|
||||||
|
<InfoLine label="Priorité" value={bien.priorite != null ? String(bien.priorite) : '—'} />
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Section title="Finances">
|
||||||
|
{!analyse ? (
|
||||||
|
<Text className="text-slate-600">Aucune analyse enregistrée. Utilisez le calculateur.</Text>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<InfoLine label="Prix d'achat" value={formatEUR(analyse.prix_achat)} />
|
||||||
|
<InfoLine label="Frais notaire (calc.)" value={formatEUR(calc.frais_notaire)} />
|
||||||
|
<InfoLine label="Travaux (total)" value={formatEUR(calc.travaux_total)} />
|
||||||
|
<InfoLine label="Portage (total)" value={formatEUR(calc.frais_portage_total)} />
|
||||||
|
<InfoLine label="Prix de revient" value={formatEUR(calc.prix_revient)} />
|
||||||
|
<InfoLine label="Prix revente cible" value={formatEUR(analyse.prix_revente_cible)} />
|
||||||
|
<InfoLine label="Marge brute" value={formatEUR(calc.marge_brute)} />
|
||||||
|
<InfoLine label="Marge nette" value={formatEUR(calc.marge_nette)} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Section title="Visites">
|
||||||
|
{visites.length === 0 ? (
|
||||||
|
<Text className="text-slate-600">Aucune visite.</Text>
|
||||||
|
) : (
|
||||||
|
<FlatList
|
||||||
|
data={visites}
|
||||||
|
keyExtractor={(v) => v.id}
|
||||||
|
scrollEnabled={false}
|
||||||
|
renderItem={({ item }) => (
|
||||||
|
<Pressable
|
||||||
|
className="mb-2 rounded-xl border border-slate-200 bg-white p-3"
|
||||||
|
onPress={() => router.push(`/visite/${item.id}`)}
|
||||||
|
>
|
||||||
|
<Text className="font-semibold text-slate-900">{item.date_visite?.slice(0, 10) ?? '—'}</Text>
|
||||||
|
<Text className="text-sm text-slate-600">
|
||||||
|
{item.avis_global ? AVIS_VISITE[item.avis_global]?.label ?? item.avis_global : '—'}
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Section title="Notes">
|
||||||
|
<Text className="mb-2 text-xs text-slate-500">
|
||||||
|
Note libre (sauvegarde automatique après 500 ms sans frappe).
|
||||||
|
</Text>
|
||||||
|
{!hydrated ? (
|
||||||
|
<ActivityIndicator color="#1D4ED8" />
|
||||||
|
) : (
|
||||||
|
<TextInput
|
||||||
|
className="min-h-[120px] rounded-xl border border-slate-200 bg-white p-3 text-base text-slate-900"
|
||||||
|
multiline
|
||||||
|
textAlignVertical="top"
|
||||||
|
placeholder="Écrivez vos notes…"
|
||||||
|
placeholderTextColor="#94a3b8"
|
||||||
|
value={draft}
|
||||||
|
onChangeText={setDraft}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{notes.some((n) => n.type_note && n.type_note !== 'libre') ? (
|
||||||
|
<Text className="mt-3 text-xs font-semibold uppercase text-slate-500">Autres notes</Text>
|
||||||
|
) : null}
|
||||||
|
{notes
|
||||||
|
.filter((n) => n.type_note && n.type_note !== 'libre')
|
||||||
|
.map((n) => (
|
||||||
|
<View key={n.id} className="mt-2 rounded-lg border border-slate-100 bg-white p-2">
|
||||||
|
<Text className="text-xs text-slate-400">{n.updated?.slice(0, 16) ?? ''}</Text>
|
||||||
|
<Text className="text-sm text-slate-800">{n.contenu}</Text>
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Section title="Documents">
|
||||||
|
{documents.length === 0 ? (
|
||||||
|
<Text className="text-slate-600">Aucun document.</Text>
|
||||||
|
) : (
|
||||||
|
documents.map((d) => (
|
||||||
|
<View key={d.id} className="mb-2 rounded-xl border border-slate-200 bg-white px-3 py-2">
|
||||||
|
<Text className="font-medium text-slate-900">{d.nom}</Text>
|
||||||
|
{d.type_document ? <Text className="text-xs text-slate-500">{d.type_document}</Text> : null}
|
||||||
|
</View>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</Section>
|
||||||
|
</ScrollView>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Section({ title, children }: { title: string; children: ReactNode }) {
|
||||||
|
return (
|
||||||
|
<View className="mb-4 border-b border-slate-200 px-4 pb-4 pt-2">
|
||||||
|
<Text className="mb-3 text-lg font-bold text-slate-900">{title}</Text>
|
||||||
|
{children}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function InfoLine({ label, value }: { label: string; value: string }) {
|
||||||
|
return (
|
||||||
|
<View className="mb-2 flex-row justify-between">
|
||||||
|
<Text className="text-sm text-slate-500">{label}</Text>
|
||||||
|
<Text className="max-w-[55%] text-right text-sm font-medium text-slate-900">{value}</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
418
app/app/bien/nouveau.tsx
Normal file
418
app/app/bien/nouveau.tsx
Normal file
@ -0,0 +1,418 @@
|
|||||||
|
import { Stack, useRouter } from 'expo-router';
|
||||||
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
import {
|
||||||
|
ActivityIndicator,
|
||||||
|
Modal,
|
||||||
|
Pressable,
|
||||||
|
ScrollView,
|
||||||
|
Switch,
|
||||||
|
Text,
|
||||||
|
TextInput,
|
||||||
|
View,
|
||||||
|
} from 'react-native';
|
||||||
|
|
||||||
|
import { TYPES_BIENS } from '@/constants/metier';
|
||||||
|
import { useBiens } from '@/hooks/useBiens';
|
||||||
|
import { useEtapes } from '@/hooks/useEtapes';
|
||||||
|
import type { BienSource, BienType } from '@/types/collections';
|
||||||
|
import { getCurrentUserId } from '@/services/pocketbase';
|
||||||
|
import { formatPocketBaseError } from '@/utils/pocketbaseErrors';
|
||||||
|
|
||||||
|
const SOURCES: BienSource[] = [
|
||||||
|
'particulier',
|
||||||
|
'agence',
|
||||||
|
'notaire',
|
||||||
|
'tribunal',
|
||||||
|
'succession',
|
||||||
|
'reseau',
|
||||||
|
'autre',
|
||||||
|
];
|
||||||
|
|
||||||
|
const SOURCE_LABELS: Record<BienSource, string> = {
|
||||||
|
particulier: 'Particulier',
|
||||||
|
agence: 'Agence',
|
||||||
|
notaire: 'Notaire',
|
||||||
|
tribunal: 'Tribunal',
|
||||||
|
succession: 'Succession',
|
||||||
|
reseau: 'Réseau',
|
||||||
|
autre: 'Autre',
|
||||||
|
};
|
||||||
|
|
||||||
|
function parseNum(raw: string): number | undefined {
|
||||||
|
const n = Number(raw.replace(',', '.').trim());
|
||||||
|
return Number.isFinite(n) ? n : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ErrorBanner({ message }: { message: string }) {
|
||||||
|
return (
|
||||||
|
<View className="mb-4 rounded-xl border border-red-200 bg-red-50 px-3 py-3">
|
||||||
|
<Text className="text-sm leading-5 text-red-900">{message}</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function BienNouveauScreen() {
|
||||||
|
const router = useRouter();
|
||||||
|
const uid = getCurrentUserId();
|
||||||
|
const initOnce = useRef(false);
|
||||||
|
const createInFlight = useRef(false);
|
||||||
|
const {
|
||||||
|
etapes,
|
||||||
|
isLoading: etapesLoading,
|
||||||
|
initEtapesDefaut,
|
||||||
|
error: etapesQueryError,
|
||||||
|
initError: etapesInitMutationError,
|
||||||
|
} = useEtapes();
|
||||||
|
const { createBien } = useBiens({});
|
||||||
|
|
||||||
|
const [step, setStep] = useState(1);
|
||||||
|
const [stepHint, setStepHint] = useState<string | null>(null);
|
||||||
|
const [createError, setCreateError] = useState<string | null>(null);
|
||||||
|
const [initPipelineMsg, setInitPipelineMsg] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const [typeBien, setTypeBien] = useState<BienType>('appartement');
|
||||||
|
const [pickerTypeOpen, setPickerTypeOpen] = useState(false);
|
||||||
|
const [adresse, setAdresse] = useState('');
|
||||||
|
const [ville, setVille] = useState('');
|
||||||
|
const [codePostal, setCodePostal] = useState('');
|
||||||
|
const [surface, setSurface] = useState('');
|
||||||
|
const [nbPieces, setNbPieces] = useState('');
|
||||||
|
const [prixEstime, setPrixEstime] = useState('');
|
||||||
|
const [source, setSource] = useState<BienSource>('particulier');
|
||||||
|
const [pickerSourceOpen, setPickerSourceOpen] = useState(false);
|
||||||
|
const [offMarket, setOffMarket] = useState(false);
|
||||||
|
const [priorite, setPriorite] = useState('2');
|
||||||
|
const [noteProjet, setNoteProjet] = useState('');
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setStepHint(null);
|
||||||
|
}, [step]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (step !== 3) {
|
||||||
|
setCreateError(null);
|
||||||
|
}
|
||||||
|
}, [step]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (etapesLoading || etapes.length > 0 || initOnce.current) return;
|
||||||
|
initOnce.current = true;
|
||||||
|
void initEtapesDefaut()
|
||||||
|
.then(() => {
|
||||||
|
setInitPipelineMsg(null);
|
||||||
|
})
|
||||||
|
.catch((e: unknown) => {
|
||||||
|
initOnce.current = false;
|
||||||
|
setInitPipelineMsg(formatPocketBaseError(e));
|
||||||
|
});
|
||||||
|
}, [etapesLoading, etapes.length, initEtapesDefaut]);
|
||||||
|
|
||||||
|
const firstEtapeId = etapes[0]?.id;
|
||||||
|
|
||||||
|
const canNext1 = ville.trim().length > 0 && codePostal.trim().length > 0;
|
||||||
|
const canNext2 =
|
||||||
|
parseNum(surface) != null &&
|
||||||
|
parseNum(nbPieces) != null &&
|
||||||
|
parseNum(prixEstime) != null &&
|
||||||
|
parseNum(prixEstime)! > 0;
|
||||||
|
|
||||||
|
const pipelineBanner =
|
||||||
|
etapesQueryError != null
|
||||||
|
? formatPocketBaseError(etapesQueryError)
|
||||||
|
: etapesInitMutationError != null
|
||||||
|
? formatPocketBaseError(etapesInitMutationError)
|
||||||
|
: initPipelineMsg;
|
||||||
|
|
||||||
|
const goNext1 = () => {
|
||||||
|
if (canNext1) {
|
||||||
|
setStep(2);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setStepHint('Renseignez la ville et le code postal pour continuer.');
|
||||||
|
};
|
||||||
|
|
||||||
|
const goNext2 = () => {
|
||||||
|
if (canNext2) {
|
||||||
|
setStep(3);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setStepHint('Indiquez une surface, un nombre de pièces et un prix d’achat estimé (> 0).');
|
||||||
|
};
|
||||||
|
|
||||||
|
const onCreate = async () => {
|
||||||
|
if (!uid) {
|
||||||
|
setCreateError('Vous devez être connecté.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (createInFlight.current) return;
|
||||||
|
createInFlight.current = true;
|
||||||
|
setCreateError(null);
|
||||||
|
setSubmitting(true);
|
||||||
|
try {
|
||||||
|
const titre =
|
||||||
|
`${TYPES_BIENS[typeBien] ?? typeBien} — ${ville.trim()}`.trim() || `Bien — ${ville.trim()}`;
|
||||||
|
const id = await createBien({
|
||||||
|
bien: {
|
||||||
|
user: uid,
|
||||||
|
...(firstEtapeId ? { etape: firstEtapeId } : {}),
|
||||||
|
type_bien: typeBien,
|
||||||
|
adresse: adresse.trim() || undefined,
|
||||||
|
ville: ville.trim(),
|
||||||
|
code_postal: codePostal.trim(),
|
||||||
|
titre,
|
||||||
|
surface_habitable: parseNum(surface),
|
||||||
|
nb_pieces: parseNum(nbPieces),
|
||||||
|
source,
|
||||||
|
is_off_market: offMarket,
|
||||||
|
priorite: parseNum(priorite) ?? 2,
|
||||||
|
statut: 'actif',
|
||||||
|
description: noteProjet.trim() || undefined,
|
||||||
|
},
|
||||||
|
prixEstime: parseNum(prixEstime),
|
||||||
|
});
|
||||||
|
router.replace(`/bien/${id}`);
|
||||||
|
} catch (e: unknown) {
|
||||||
|
setCreateError(formatPocketBaseError(e));
|
||||||
|
} finally {
|
||||||
|
createInFlight.current = false;
|
||||||
|
setSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!uid) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Stack.Screen options={{ title: 'Nouveau bien', headerShown: true }} />
|
||||||
|
<View className="flex-1 items-center justify-center bg-slate-50 p-6">
|
||||||
|
<Text className="text-center text-slate-600">Connectez-vous pour créer un bien.</Text>
|
||||||
|
</View>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Stack.Screen options={{ title: 'Nouveau bien', headerShown: true }} />
|
||||||
|
<ScrollView
|
||||||
|
className="flex-1 bg-slate-50"
|
||||||
|
contentContainerStyle={{ paddingHorizontal: 16, paddingTop: 16, paddingBottom: 64 }}
|
||||||
|
>
|
||||||
|
{pipelineBanner ? <ErrorBanner message={pipelineBanner} /> : null}
|
||||||
|
|
||||||
|
<View className="mb-6 flex-row gap-2">
|
||||||
|
{[1, 2, 3].map((s) => (
|
||||||
|
<View
|
||||||
|
key={s}
|
||||||
|
className="h-2 flex-1 rounded-full"
|
||||||
|
style={{ backgroundColor: step >= s ? '#1D4ED8' : '#E2E8F0' }}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
<Text className="mb-1 text-xs font-semibold uppercase text-slate-500">Étape {step} / 3</Text>
|
||||||
|
|
||||||
|
{stepHint ? <ErrorBanner message={stepHint} /> : null}
|
||||||
|
{step === 3 && createError ? <ErrorBanner message={createError} /> : null}
|
||||||
|
|
||||||
|
{!firstEtapeId && !etapesLoading ? (
|
||||||
|
<View className="mb-4 rounded-xl border border-amber-200 bg-amber-50 px-3 py-2">
|
||||||
|
<Text className="text-sm text-amber-900">
|
||||||
|
Aucune étape pipeline disponible. Le bien sera créé sans étape ; vous pourrez l’assigner plus tard.
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{step === 1 ? (
|
||||||
|
<View>
|
||||||
|
<Text className="mb-2 text-lg font-bold text-slate-900">Localisation</Text>
|
||||||
|
<Text className="mb-1 text-sm text-slate-600">Type de bien</Text>
|
||||||
|
<Pressable
|
||||||
|
className="mb-4 rounded-xl border border-slate-200 bg-white px-3 py-3"
|
||||||
|
onPress={() => setPickerTypeOpen(true)}
|
||||||
|
>
|
||||||
|
<Text className="text-base text-slate-900">{TYPES_BIENS[typeBien]}</Text>
|
||||||
|
</Pressable>
|
||||||
|
<Field label="Adresse" value={adresse} onChangeText={setAdresse} />
|
||||||
|
<Field label="Ville *" value={ville} onChangeText={setVille} />
|
||||||
|
<Field label="Code postal *" value={codePostal} onChangeText={setCodePostal} />
|
||||||
|
<NavButtons showPrev={false} onNext={goNext1} />
|
||||||
|
</View>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{step === 2 ? (
|
||||||
|
<View>
|
||||||
|
<Text className="mb-2 text-lg font-bold text-slate-900">Caractéristiques</Text>
|
||||||
|
<Field label="Surface habitable (m²) *" value={surface} onChangeText={setSurface} keyboard="numeric" />
|
||||||
|
<Field label="Nombre de pièces *" value={nbPieces} onChangeText={setNbPieces} keyboard="numeric" />
|
||||||
|
<Field label="Prix d'achat estimé (€) *" value={prixEstime} onChangeText={setPrixEstime} keyboard="numeric" />
|
||||||
|
<Text className="mb-1 mt-2 text-sm text-slate-600">Source</Text>
|
||||||
|
<Pressable
|
||||||
|
className="mb-4 rounded-xl border border-slate-200 bg-white px-3 py-3"
|
||||||
|
onPress={() => setPickerSourceOpen(true)}
|
||||||
|
>
|
||||||
|
<Text className="text-base text-slate-900">{SOURCE_LABELS[source]}</Text>
|
||||||
|
</Pressable>
|
||||||
|
<View className="mb-4 flex-row items-center justify-between rounded-xl border border-slate-200 bg-white px-3 py-3">
|
||||||
|
<Text className="text-base text-slate-800">Off-market</Text>
|
||||||
|
<Switch value={offMarket} onValueChange={setOffMarket} />
|
||||||
|
</View>
|
||||||
|
<Field label="Priorité (1–5)" value={priorite} onChangeText={setPriorite} keyboard="numeric" />
|
||||||
|
<Text className="mb-1 mt-2 text-sm text-slate-600">Note (optionnel)</Text>
|
||||||
|
<TextInput
|
||||||
|
className="mb-4 min-h-[100px] rounded-xl border border-slate-200 bg-white px-3 py-3 text-base text-slate-900"
|
||||||
|
placeholder="Contexte, contact, remarques…"
|
||||||
|
placeholderTextColor="#94a3b8"
|
||||||
|
multiline
|
||||||
|
textAlignVertical="top"
|
||||||
|
value={noteProjet}
|
||||||
|
onChangeText={setNoteProjet}
|
||||||
|
/>
|
||||||
|
<NavButtons onPrev={() => setStep(1)} onNext={goNext2} />
|
||||||
|
</View>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{step === 3 ? (
|
||||||
|
<View>
|
||||||
|
<Text className="mb-2 text-lg font-bold text-slate-900">Résumé</Text>
|
||||||
|
<SummaryRow label="Type" value={TYPES_BIENS[typeBien]} />
|
||||||
|
<SummaryRow label="Adresse" value={[adresse, codePostal, ville].filter(Boolean).join(', ') || '—'} />
|
||||||
|
<SummaryRow label="Surface" value={surface ? `${surface} m²` : '—'} />
|
||||||
|
<SummaryRow label="Pièces" value={nbPieces || '—'} />
|
||||||
|
<SummaryRow label="Prix estimé" value={prixEstime ? `${prixEstime} €` : '—'} />
|
||||||
|
<SummaryRow label="Source" value={SOURCE_LABELS[source]} />
|
||||||
|
<SummaryRow label="Off-market" value={offMarket ? 'Oui' : 'Non'} />
|
||||||
|
<SummaryRow label="Priorité" value={priorite} />
|
||||||
|
<SummaryRow
|
||||||
|
label="Note"
|
||||||
|
value={
|
||||||
|
noteProjet.trim()
|
||||||
|
? noteProjet.length > 80
|
||||||
|
? `${noteProjet.slice(0, 80)}…`
|
||||||
|
: noteProjet
|
||||||
|
: '—'
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<SummaryRow label="Étape" value={firstEtapeId ? etapes[0]?.nom ?? '—' : 'Non assignée'} />
|
||||||
|
<NavButtons
|
||||||
|
onPrev={() => setStep(2)}
|
||||||
|
onNext={onCreate}
|
||||||
|
nextLabel={submitting ? 'Création…' : 'Créer'}
|
||||||
|
nextDisabled={submitting || etapesLoading}
|
||||||
|
/>
|
||||||
|
{etapesLoading ? <ActivityIndicator className="mt-4" color="#1D4ED8" /> : null}
|
||||||
|
</View>
|
||||||
|
) : null}
|
||||||
|
</ScrollView>
|
||||||
|
|
||||||
|
<Modal visible={pickerTypeOpen} transparent animationType="fade">
|
||||||
|
<Pressable className="flex-1 justify-end bg-black/40" onPress={() => setPickerTypeOpen(false)}>
|
||||||
|
<View className="rounded-t-2xl bg-white p-4">
|
||||||
|
<Text className="mb-3 text-lg font-bold">Type de bien</Text>
|
||||||
|
<ScrollView style={{ maxHeight: 360 }}>
|
||||||
|
{(Object.keys(TYPES_BIENS) as BienType[]).map((k) => (
|
||||||
|
<Pressable
|
||||||
|
key={k}
|
||||||
|
className="border-b border-slate-100 py-3"
|
||||||
|
onPress={() => {
|
||||||
|
setTypeBien(k);
|
||||||
|
setPickerTypeOpen(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text className="text-base text-slate-900">{TYPES_BIENS[k]}</Text>
|
||||||
|
</Pressable>
|
||||||
|
))}
|
||||||
|
</ScrollView>
|
||||||
|
</View>
|
||||||
|
</Pressable>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
<Modal visible={pickerSourceOpen} transparent animationType="fade">
|
||||||
|
<Pressable className="flex-1 justify-end bg-black/40" onPress={() => setPickerSourceOpen(false)}>
|
||||||
|
<View className="rounded-t-2xl bg-white p-4">
|
||||||
|
<Text className="mb-3 text-lg font-bold">Source</Text>
|
||||||
|
{SOURCES.map((k) => (
|
||||||
|
<Pressable
|
||||||
|
key={k}
|
||||||
|
className="border-b border-slate-100 py-3"
|
||||||
|
onPress={() => {
|
||||||
|
setSource(k);
|
||||||
|
setPickerSourceOpen(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text className="text-base text-slate-900">{SOURCE_LABELS[k]}</Text>
|
||||||
|
</Pressable>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
</Pressable>
|
||||||
|
</Modal>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Field({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
onChangeText,
|
||||||
|
keyboard,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
onChangeText: (t: string) => void;
|
||||||
|
keyboard?: 'numeric';
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<View className="mb-3">
|
||||||
|
<Text className="mb-1 text-sm text-slate-600">{label}</Text>
|
||||||
|
<TextInput
|
||||||
|
className="rounded-xl border border-slate-200 bg-white px-3 py-3 text-base text-slate-900"
|
||||||
|
value={value}
|
||||||
|
onChangeText={onChangeText}
|
||||||
|
keyboardType={keyboard === 'numeric' ? 'decimal-pad' : 'default'}
|
||||||
|
placeholderTextColor="#94a3b8"
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SummaryRow({ label, value }: { label: string; value: string }) {
|
||||||
|
return (
|
||||||
|
<View className="mb-2 flex-row justify-between border-b border-slate-100 py-2">
|
||||||
|
<Text className="text-sm text-slate-500">{label}</Text>
|
||||||
|
<Text className="max-w-[60%] text-right text-sm font-medium text-slate-900">{value}</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function NavButtons({
|
||||||
|
showPrev = true,
|
||||||
|
onPrev,
|
||||||
|
onNext,
|
||||||
|
nextLabel = 'Suivant',
|
||||||
|
nextDisabled,
|
||||||
|
}: {
|
||||||
|
showPrev?: boolean;
|
||||||
|
onPrev?: () => void;
|
||||||
|
onNext: () => void;
|
||||||
|
nextLabel?: string;
|
||||||
|
nextDisabled?: boolean;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<View className="mt-6 flex-row justify-between gap-3">
|
||||||
|
{showPrev ? (
|
||||||
|
<Pressable className="flex-1 rounded-xl border border-slate-300 py-3" onPress={onPrev}>
|
||||||
|
<Text className="text-center font-semibold text-slate-800">Retour</Text>
|
||||||
|
</Pressable>
|
||||||
|
) : (
|
||||||
|
<View className="flex-1" />
|
||||||
|
)}
|
||||||
|
<Pressable
|
||||||
|
className="flex-1 rounded-xl py-3"
|
||||||
|
style={{ backgroundColor: nextDisabled ? '#94a3b8' : '#1D4ED8' }}
|
||||||
|
onPress={onNext}
|
||||||
|
disabled={nextDisabled}
|
||||||
|
>
|
||||||
|
<Text className="text-center font-semibold text-white">{nextLabel}</Text>
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
164
app/app/calculateur/[bienId].tsx
Normal file
164
app/app/calculateur/[bienId].tsx
Normal file
@ -0,0 +1,164 @@
|
|||||||
|
import { Stack, useLocalSearchParams } from 'expo-router';
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import {
|
||||||
|
ActivityIndicator,
|
||||||
|
KeyboardAvoidingView,
|
||||||
|
Platform,
|
||||||
|
Pressable,
|
||||||
|
ScrollView,
|
||||||
|
Text,
|
||||||
|
TextInput,
|
||||||
|
View,
|
||||||
|
} from 'react-native';
|
||||||
|
|
||||||
|
import { useAnalyse, rendementColor } from '@/hooks/useAnalyse';
|
||||||
|
import { formatEUR } from '@/utils/format';
|
||||||
|
import { formatPocketBaseError } from '@/utils/pocketbaseErrors';
|
||||||
|
|
||||||
|
function routeParamId(raw: string | string[] | undefined): string | undefined {
|
||||||
|
if (raw == null) return undefined;
|
||||||
|
return Array.isArray(raw) ? raw[0] : raw;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function CalculateurScreen() {
|
||||||
|
const { bienId: raw } = useLocalSearchParams<{ bienId?: string | string[] }>();
|
||||||
|
const bienId = routeParamId(raw);
|
||||||
|
const { analyse, isLoading, saveAnalyse, isSaving, calculateResults: calcFn } = useAnalyse(bienId);
|
||||||
|
|
||||||
|
const [prixAchat, setPrixAchat] = useState('');
|
||||||
|
const [typeFiscal, setTypeFiscal] = useState<'ancien' | 'neuf'>('ancien');
|
||||||
|
const [budgetTravaux, setBudgetTravaux] = useState('');
|
||||||
|
const [prixRevente, setPrixRevente] = useState('');
|
||||||
|
const [err, setErr] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!analyse) return;
|
||||||
|
setPrixAchat(analyse.prix_achat != null ? String(analyse.prix_achat) : '');
|
||||||
|
setTypeFiscal(analyse.type_bien_fiscal ?? 'ancien');
|
||||||
|
setBudgetTravaux(analyse.budget_travaux != null ? String(analyse.budget_travaux) : '');
|
||||||
|
setPrixRevente(analyse.prix_revente_cible != null ? String(analyse.prix_revente_cible) : '');
|
||||||
|
}, [analyse]);
|
||||||
|
|
||||||
|
const parsed = {
|
||||||
|
prix_achat: Number(prixAchat.replace(',', '.')) || 0,
|
||||||
|
type_bien_fiscal: typeFiscal,
|
||||||
|
budget_travaux: Number(budgetTravaux.replace(',', '.')) || 0,
|
||||||
|
prix_revente_cible: Number(prixRevente.replace(',', '.')) || 0,
|
||||||
|
};
|
||||||
|
const calc = calcFn(parsed);
|
||||||
|
|
||||||
|
const onSave = async () => {
|
||||||
|
setErr(null);
|
||||||
|
try {
|
||||||
|
await saveAnalyse({
|
||||||
|
prix_achat: parsed.prix_achat,
|
||||||
|
type_bien_fiscal: typeFiscal,
|
||||||
|
budget_travaux: parsed.budget_travaux,
|
||||||
|
prix_revente_cible: parsed.prix_revente_cible,
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
setErr(formatPocketBaseError(e));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!bienId) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Stack.Screen options={{ title: 'Calculateur', headerShown: true }} />
|
||||||
|
<View className="flex-1 items-center justify-center p-6">
|
||||||
|
<Text>Bien manquant.</Text>
|
||||||
|
</View>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Stack.Screen options={{ title: 'Calculateur', headerShown: true }} />
|
||||||
|
<KeyboardAvoidingView
|
||||||
|
className="flex-1 bg-slate-50"
|
||||||
|
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
|
||||||
|
>
|
||||||
|
{isLoading ? (
|
||||||
|
<View className="flex-1 items-center justify-center">
|
||||||
|
<ActivityIndicator color="#1D4ED8" />
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
|
<ScrollView className="flex-1 p-4" contentContainerStyle={{ paddingBottom: 40 }}>
|
||||||
|
{err ? (
|
||||||
|
<Text className="mb-2 text-red-700">{err}</Text>
|
||||||
|
) : null}
|
||||||
|
<Text className="mb-1 text-sm text-slate-600">Prix d'achat (€)</Text>
|
||||||
|
<TextInput
|
||||||
|
className="mb-3 rounded-xl border border-slate-200 bg-white px-3 py-3"
|
||||||
|
keyboardType="decimal-pad"
|
||||||
|
value={prixAchat}
|
||||||
|
onChangeText={setPrixAchat}
|
||||||
|
placeholderTextColor="#94a3b8"
|
||||||
|
/>
|
||||||
|
<Text className="mb-1 text-sm text-slate-600">Type fiscal</Text>
|
||||||
|
<View className="mb-3 flex-row gap-2">
|
||||||
|
<Pressable
|
||||||
|
className="flex-1 rounded-xl border px-3 py-2"
|
||||||
|
style={{
|
||||||
|
borderColor: typeFiscal === 'ancien' ? '#1D4ED8' : '#e2e8f0',
|
||||||
|
backgroundColor: typeFiscal === 'ancien' ? '#eff6ff' : '#fff',
|
||||||
|
}}
|
||||||
|
onPress={() => setTypeFiscal('ancien')}
|
||||||
|
>
|
||||||
|
<Text className="text-center font-medium">Ancien</Text>
|
||||||
|
</Pressable>
|
||||||
|
<Pressable
|
||||||
|
className="flex-1 rounded-xl border px-3 py-2"
|
||||||
|
style={{
|
||||||
|
borderColor: typeFiscal === 'neuf' ? '#1D4ED8' : '#e2e8f0',
|
||||||
|
backgroundColor: typeFiscal === 'neuf' ? '#eff6ff' : '#fff',
|
||||||
|
}}
|
||||||
|
onPress={() => setTypeFiscal('neuf')}
|
||||||
|
>
|
||||||
|
<Text className="text-center font-medium">Neuf</Text>
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
<Text className="mb-1 text-sm text-slate-600">Budget travaux (€)</Text>
|
||||||
|
<TextInput
|
||||||
|
className="mb-3 rounded-xl border border-slate-200 bg-white px-3 py-3"
|
||||||
|
keyboardType="decimal-pad"
|
||||||
|
value={budgetTravaux}
|
||||||
|
onChangeText={setBudgetTravaux}
|
||||||
|
placeholderTextColor="#94a3b8"
|
||||||
|
/>
|
||||||
|
<Text className="mb-1 text-sm text-slate-600">Prix revente cible (€)</Text>
|
||||||
|
<TextInput
|
||||||
|
className="mb-4 rounded-xl border border-slate-200 bg-white px-3 py-3"
|
||||||
|
keyboardType="decimal-pad"
|
||||||
|
value={prixRevente}
|
||||||
|
onChangeText={setPrixRevente}
|
||||||
|
placeholderTextColor="#94a3b8"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<View className="mb-4 rounded-xl border border-slate-200 bg-white p-3">
|
||||||
|
<Text className="text-sm font-semibold text-slate-800">Aperçu</Text>
|
||||||
|
<Text className="mt-1 text-slate-700">Frais notaire (estim.) : {formatEUR(calc.frais_notaire)}</Text>
|
||||||
|
<Text className="text-slate-700">Prix de revient : {formatEUR(calc.prix_revient)}</Text>
|
||||||
|
<Text className="text-slate-700">Marge nette : {formatEUR(calc.marge_nette)}</Text>
|
||||||
|
<Text style={{ color: rendementColor(calc.rendement_net_pct) }} className="mt-1 font-bold">
|
||||||
|
Rendement net / revient : {calc.rendement_net_pct.toFixed(1)} %
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<Pressable
|
||||||
|
className="rounded-xl py-3"
|
||||||
|
style={{ backgroundColor: isSaving ? '#94a3b8' : '#1D4ED8' }}
|
||||||
|
onPress={onSave}
|
||||||
|
disabled={isSaving}
|
||||||
|
>
|
||||||
|
<Text className="text-center font-semibold text-white">
|
||||||
|
{isSaving ? 'Enregistrement…' : 'Enregistrer'}
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
</ScrollView>
|
||||||
|
)}
|
||||||
|
</KeyboardAvoidingView>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
101
app/app/contact/[id].tsx
Normal file
101
app/app/contact/[id].tsx
Normal file
@ -0,0 +1,101 @@
|
|||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { Stack, useLocalSearchParams } from 'expo-router';
|
||||||
|
import { ActivityIndicator, ScrollView, Text, View } from 'react-native';
|
||||||
|
|
||||||
|
import { getCurrentUserId, pb } from '@/services/pocketbase';
|
||||||
|
import type { ContactRecord } from '@/types/collections';
|
||||||
|
import { formatPocketBaseError } from '@/utils/pocketbaseErrors';
|
||||||
|
|
||||||
|
function routeParamId(raw: string | string[] | undefined): string | undefined {
|
||||||
|
if (raw == null) return undefined;
|
||||||
|
return Array.isArray(raw) ? raw[0] : raw;
|
||||||
|
}
|
||||||
|
|
||||||
|
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),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!id) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Stack.Screen options={{ title: 'Contact', headerShown: true }} />
|
||||||
|
<View className="flex-1 items-center justify-center p-6">
|
||||||
|
<Text>Identifiant manquant.</Text>
|
||||||
|
</View>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (q.isPending) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Stack.Screen options={{ title: '…', headerShown: true }} />
|
||||||
|
<View className="flex-1 items-center justify-center">
|
||||||
|
<ActivityIndicator color="#1D4ED8" />
|
||||||
|
</View>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (q.error || !q.data) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Stack.Screen options={{ title: 'Erreur', headerShown: true }} />
|
||||||
|
<View className="flex-1 items-center justify-center p-6">
|
||||||
|
<Text className="text-center text-red-700">
|
||||||
|
{q.error ? formatPocketBaseError(q.error) : 'Introuvable.'}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const c = q.data;
|
||||||
|
if (uid && c.user !== uid) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Stack.Screen options={{ title: 'Contact', headerShown: true }} />
|
||||||
|
<View className="flex-1 items-center justify-center p-6">
|
||||||
|
<Text>Accès refusé.</Text>
|
||||||
|
</View>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Stack.Screen options={{ title: c.nom, headerShown: true }} />
|
||||||
|
<ScrollView className="flex-1 bg-slate-50 p-4">
|
||||||
|
<Text className="text-xl font-bold text-slate-900">
|
||||||
|
{c.prenom ? `${c.prenom} ` : ''}
|
||||||
|
{c.nom}
|
||||||
|
</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>
|
||||||
|
{c.email ? (
|
||||||
|
<>
|
||||||
|
<Text className="mt-3 text-sm text-slate-500">Email</Text>
|
||||||
|
<Text className="text-base text-slate-900">{c.email}</Text>
|
||||||
|
</>
|
||||||
|
) : 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>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</ScrollView>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
87
app/app/contact/nouveau.tsx
Normal file
87
app/app/contact/nouveau.tsx
Normal file
@ -0,0 +1,87 @@
|
|||||||
|
import { Stack, useRouter } from 'expo-router';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { Pressable, Text, TextInput, View } from 'react-native';
|
||||||
|
|
||||||
|
import { getCurrentUserId, pb } from '@/services/pocketbase';
|
||||||
|
import { formatPocketBaseError } from '@/utils/pocketbaseErrors';
|
||||||
|
|
||||||
|
export default function ContactNouveauScreen() {
|
||||||
|
const router = useRouter();
|
||||||
|
const uid = getCurrentUserId();
|
||||||
|
const [nom, setNom] = useState('');
|
||||||
|
const [prenom, setPrenom] = useState('');
|
||||||
|
const [err, setErr] = useState<string | null>(null);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
|
||||||
|
const onSave = async () => {
|
||||||
|
if (!uid) {
|
||||||
|
setErr('Connectez-vous.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!nom.trim()) {
|
||||||
|
setErr('Le nom est obligatoire.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setErr(null);
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
const c = await pb.collection('contacts').create({
|
||||||
|
user: uid,
|
||||||
|
nom: nom.trim(),
|
||||||
|
prenom: prenom.trim() || undefined,
|
||||||
|
categorie: 'autre',
|
||||||
|
});
|
||||||
|
router.replace(`/contact/${c.id}`);
|
||||||
|
} catch (e) {
|
||||||
|
setErr(formatPocketBaseError(e));
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!uid) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Stack.Screen options={{ title: 'Nouveau contact', headerShown: true }} />
|
||||||
|
<View className="flex-1 items-center justify-center p-6">
|
||||||
|
<Text className="text-slate-600">Connexion requise.</Text>
|
||||||
|
</View>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Stack.Screen options={{ title: 'Nouveau contact', headerShown: true }} />
|
||||||
|
<View className="flex-1 bg-slate-50 p-4">
|
||||||
|
{err ? (
|
||||||
|
<View className="mb-3 rounded-xl border border-red-200 bg-red-50 px-3 py-2">
|
||||||
|
<Text className="text-sm text-red-900">{err}</Text>
|
||||||
|
</View>
|
||||||
|
) : null}
|
||||||
|
<Text className="mb-1 text-sm text-slate-600">Nom *</Text>
|
||||||
|
<TextInput
|
||||||
|
className="mb-3 rounded-xl border border-slate-200 bg-white px-3 py-3 text-base"
|
||||||
|
value={nom}
|
||||||
|
onChangeText={setNom}
|
||||||
|
placeholderTextColor="#94a3b8"
|
||||||
|
/>
|
||||||
|
<Text className="mb-1 text-sm text-slate-600">Prénom</Text>
|
||||||
|
<TextInput
|
||||||
|
className="mb-6 rounded-xl border border-slate-200 bg-white px-3 py-3 text-base"
|
||||||
|
value={prenom}
|
||||||
|
onChangeText={setPrenom}
|
||||||
|
placeholderTextColor="#94a3b8"
|
||||||
|
/>
|
||||||
|
<Pressable
|
||||||
|
className="rounded-xl py-3"
|
||||||
|
style={{ backgroundColor: busy ? '#94a3b8' : '#1D4ED8' }}
|
||||||
|
onPress={onSave}
|
||||||
|
disabled={busy}
|
||||||
|
>
|
||||||
|
<Text className="text-center font-semibold text-white">Enregistrer</Text>
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
18
app/app/index.tsx
Normal file
18
app/app/index.tsx
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
import { Redirect } from 'expo-router';
|
||||||
|
import { ActivityIndicator, View } from 'react-native';
|
||||||
|
|
||||||
|
import { useAuth } from '@/context/AuthContext';
|
||||||
|
|
||||||
|
export default function Index() {
|
||||||
|
const { loading, user } = useAuth();
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<View className="flex-1 items-center justify-center bg-slate-50">
|
||||||
|
<ActivityIndicator size="large" color="#1D4ED8" />
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return user ? <Redirect href="/(tabs)" /> : <Redirect href="/auth/login" />;
|
||||||
|
}
|
||||||
97
app/app/visite/[id].tsx
Normal file
97
app/app/visite/[id].tsx
Normal file
@ -0,0 +1,97 @@
|
|||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { Stack, useLocalSearchParams } from 'expo-router';
|
||||||
|
import { ActivityIndicator, 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 { formatPocketBaseError } from '@/utils/pocketbaseErrors';
|
||||||
|
|
||||||
|
function routeParamId(raw: string | string[] | undefined): string | undefined {
|
||||||
|
if (raw == null) return undefined;
|
||||||
|
return Array.isArray(raw) ? raw[0] : raw;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function VisiteDetailScreen() {
|
||||||
|
const { id: rawId } = useLocalSearchParams<{ id?: string | string[] }>();
|
||||||
|
const id = routeParamId(rawId);
|
||||||
|
const uid = getCurrentUserId();
|
||||||
|
|
||||||
|
const q = useQuery({
|
||||||
|
queryKey: ['visite_detail', id],
|
||||||
|
queryFn: async () => {
|
||||||
|
if (!id) throw new Error('id');
|
||||||
|
return pb.collection('visites').getOne<VisiteRecord>(id, { expand: 'bien' });
|
||||||
|
},
|
||||||
|
enabled: Boolean(id),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!id) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Stack.Screen options={{ title: 'Visite', headerShown: true }} />
|
||||||
|
<View className="flex-1 items-center justify-center p-6">
|
||||||
|
<Text>Identifiant manquant.</Text>
|
||||||
|
</View>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (q.isPending) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Stack.Screen options={{ title: '…', headerShown: true }} />
|
||||||
|
<View className="flex-1 items-center justify-center">
|
||||||
|
<ActivityIndicator color="#1D4ED8" />
|
||||||
|
</View>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (q.error || !q.data) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Stack.Screen options={{ title: 'Erreur', headerShown: true }} />
|
||||||
|
<View className="flex-1 items-center justify-center p-6">
|
||||||
|
<Text className="text-center text-red-700">
|
||||||
|
{q.error ? formatPocketBaseError(q.error) : 'Introuvable.'}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const v = q.data;
|
||||||
|
if (uid && v.user !== uid) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Stack.Screen options={{ title: 'Visite', headerShown: true }} />
|
||||||
|
<View className="flex-1 items-center justify-center p-6">
|
||||||
|
<Text>Accès refusé.</Text>
|
||||||
|
</View>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const titre = v.date_visite?.slice(0, 10) ?? 'Visite';
|
||||||
|
|
||||||
|
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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
BIN
app/assets/images/adaptive-icon.png
Normal file
BIN
app/assets/images/adaptive-icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 70 B |
BIN
app/assets/images/favicon.png
Normal file
BIN
app/assets/images/favicon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 70 B |
BIN
app/assets/images/icon.png
Normal file
BIN
app/assets/images/icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 70 B |
BIN
app/assets/images/splash-icon.png
Normal file
BIN
app/assets/images/splash-icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 70 B |
9
app/babel.config.js
Normal file
9
app/babel.config.js
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
module.exports = function (api) {
|
||||||
|
api.cache(true);
|
||||||
|
return {
|
||||||
|
presets: [
|
||||||
|
['babel-preset-expo', { jsxImportSource: 'nativewind' }],
|
||||||
|
'nativewind/babel',
|
||||||
|
],
|
||||||
|
};
|
||||||
|
};
|
||||||
19
app/constants/metier.ts
Normal file
19
app/constants/metier.ts
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
import type { BienType } from '@/types/collections';
|
||||||
|
|
||||||
|
export const TYPES_BIENS: Record<BienType, string> = {
|
||||||
|
appartement: 'Appartement',
|
||||||
|
maison: 'Maison',
|
||||||
|
immeuble: 'Immeuble',
|
||||||
|
terrain: 'Terrain',
|
||||||
|
local_commercial: 'Local commercial',
|
||||||
|
parking: 'Parking',
|
||||||
|
cave: 'Cave',
|
||||||
|
autre: 'Autre',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const AVIS_VISITE: Record<string, { label: string }> = {
|
||||||
|
coup_de_coeur: { label: 'Coup de cœur' },
|
||||||
|
interessant: { label: 'Intéressant' },
|
||||||
|
neutre: { label: 'Neutre' },
|
||||||
|
a_eviter: { label: 'À éviter' },
|
||||||
|
};
|
||||||
82
app/context/AuthContext.tsx
Normal file
82
app/context/AuthContext.tsx
Normal file
@ -0,0 +1,82 @@
|
|||||||
|
import { createContext, useCallback, useContext, useEffect, useMemo, useState, type ReactNode } from 'react';
|
||||||
|
|
||||||
|
import type { UserRecord } from '@/types/collections';
|
||||||
|
import { hydratePocketBaseAuth, isAuthenticated, pb } from '@/services/pocketbase';
|
||||||
|
|
||||||
|
type AuthContextValue = {
|
||||||
|
user: UserRecord | null;
|
||||||
|
loading: boolean;
|
||||||
|
login: (email: string, password: string) => Promise<void>;
|
||||||
|
register: (params: { email: string; password: string; name: string }) => Promise<void>;
|
||||||
|
logout: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const AuthContext = createContext<AuthContextValue | null>(null);
|
||||||
|
|
||||||
|
export function AuthProvider({ children }: { children: ReactNode }) {
|
||||||
|
const [user, setUser] = useState<UserRecord | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
void (async () => {
|
||||||
|
await hydratePocketBaseAuth();
|
||||||
|
if (cancelled) return;
|
||||||
|
setUser((pb.authStore.record as UserRecord | null) ?? null);
|
||||||
|
setLoading(false);
|
||||||
|
})();
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const unsub = pb.authStore.onChange(() => {
|
||||||
|
setUser((pb.authStore.record as UserRecord | null) ?? null);
|
||||||
|
});
|
||||||
|
return () => unsub();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const login = useCallback(async (email: string, password: string) => {
|
||||||
|
await pb.collection('users').authWithPassword(email.trim(), password);
|
||||||
|
setUser((pb.authStore.record as UserRecord | null) ?? null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const register = useCallback(async (params: { email: string; password: string; name: string }) => {
|
||||||
|
await pb.collection('users').create({
|
||||||
|
email: params.email.trim(),
|
||||||
|
password: params.password,
|
||||||
|
passwordConfirm: params.password,
|
||||||
|
name: params.name.trim(),
|
||||||
|
emailVisibility: true,
|
||||||
|
});
|
||||||
|
await pb.collection('users').authWithPassword(params.email.trim(), params.password);
|
||||||
|
setUser((pb.authStore.record as UserRecord | null) ?? null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const logout = useCallback(() => {
|
||||||
|
pb.authStore.clear();
|
||||||
|
setUser(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const value = useMemo(
|
||||||
|
() => ({
|
||||||
|
user,
|
||||||
|
loading,
|
||||||
|
login,
|
||||||
|
register,
|
||||||
|
logout,
|
||||||
|
}),
|
||||||
|
[user, loading, login, register, logout],
|
||||||
|
);
|
||||||
|
|
||||||
|
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAuth(): AuthContextValue {
|
||||||
|
const ctx = useContext(AuthContext);
|
||||||
|
if (!ctx) throw new Error('useAuth must be used within AuthProvider');
|
||||||
|
return ctx;
|
||||||
|
}
|
||||||
|
|
||||||
|
export { isAuthenticated };
|
||||||
3
app/global.css
Normal file
3
app/global.css
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
@tailwind base;
|
||||||
|
@tailwind components;
|
||||||
|
@tailwind utilities;
|
||||||
166
app/hooks/useAnalyse.ts
Normal file
166
app/hooks/useAnalyse.ts
Normal file
@ -0,0 +1,166 @@
|
|||||||
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
|
|
||||||
|
import { getCurrentUserId, pb } from '@/services/pocketbase';
|
||||||
|
import type { AnalyseFinanciereRecord, TypeBienFiscal } from '@/types/collections';
|
||||||
|
import { roundMoney } from '@/utils/format';
|
||||||
|
|
||||||
|
export type AnalyseFormInput = {
|
||||||
|
prix_achat?: number;
|
||||||
|
type_bien_fiscal?: TypeBienFiscal;
|
||||||
|
frais_notaire?: number;
|
||||||
|
frais_agence_achat?: number;
|
||||||
|
budget_travaux?: number;
|
||||||
|
reserve_imprevus_pct?: number;
|
||||||
|
duree_portage_mois?: number;
|
||||||
|
taux_credit?: number;
|
||||||
|
taxe_fonciere_annuelle?: number;
|
||||||
|
charges_copropriete_mensuelle?: number;
|
||||||
|
prix_revente_cible?: number;
|
||||||
|
frais_agence_vente_pct?: number;
|
||||||
|
taux_impot?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AnalyseCalculated = {
|
||||||
|
frais_notaire: number;
|
||||||
|
travaux_total: number;
|
||||||
|
frais_portage_total: number;
|
||||||
|
prix_revient: number;
|
||||||
|
marge_brute: number;
|
||||||
|
marge_nette: number;
|
||||||
|
marge_brute_pct: number;
|
||||||
|
marge_nette_pct: number;
|
||||||
|
rendement_net_pct: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function calculateResults(data: AnalyseFormInput): AnalyseCalculated {
|
||||||
|
const prixAchat = data.prix_achat ?? 0;
|
||||||
|
const typeFiscal = data.type_bien_fiscal ?? 'ancien';
|
||||||
|
const fraisNotaireAuto = prixAchat * (typeFiscal === 'neuf' ? 0.02 : 0.075);
|
||||||
|
const fraisNotaire = data.frais_notaire ?? fraisNotaireAuto;
|
||||||
|
|
||||||
|
const budgetTravaux = data.budget_travaux ?? 0;
|
||||||
|
const reservePct = data.reserve_imprevus_pct ?? 0;
|
||||||
|
const travauxTotal = budgetTravaux * (1 + reservePct / 100);
|
||||||
|
|
||||||
|
const tauxCredit = data.taux_credit ?? 0;
|
||||||
|
const taxeFon = data.taxe_fonciere_annuelle ?? 0;
|
||||||
|
const charges = data.charges_copropriete_mensuelle ?? 0;
|
||||||
|
const dureeMois = data.duree_portage_mois ?? 0;
|
||||||
|
const mensualiteCredit = (prixAchat * (tauxCredit / 100)) / 12;
|
||||||
|
const mensualiteFoncier = taxeFon / 12;
|
||||||
|
const fraisPortageTotal = (mensualiteCredit + mensualiteFoncier + charges) * dureeMois;
|
||||||
|
|
||||||
|
const fraisAgenceAchat = data.frais_agence_achat ?? 0;
|
||||||
|
const prixRevient = prixAchat + fraisNotaire + fraisAgenceAchat + travauxTotal + fraisPortageTotal;
|
||||||
|
|
||||||
|
const prixRevente = data.prix_revente_cible ?? 0;
|
||||||
|
const margeBrute = prixRevente - prixRevient;
|
||||||
|
|
||||||
|
const fraisAgenceVentePct = data.frais_agence_vente_pct ?? 0;
|
||||||
|
const tauxImpot = data.taux_impot ?? 0;
|
||||||
|
const fraisAgenceVente = prixRevente * (fraisAgenceVentePct / 100);
|
||||||
|
const impotSurMarge = margeBrute * (tauxImpot / 100);
|
||||||
|
const margeNette = margeBrute - fraisAgenceVente - impotSurMarge;
|
||||||
|
|
||||||
|
const margeBrutePct = prixRevient > 0 ? (margeBrute / prixRevient) * 100 : 0;
|
||||||
|
const margeNettePct = prixRevente > 0 ? (margeNette / prixRevente) * 100 : 0;
|
||||||
|
const rendementNetPct = prixRevient > 0 ? (margeNette / prixRevient) * 100 : 0;
|
||||||
|
|
||||||
|
return {
|
||||||
|
frais_notaire: roundMoney(fraisNotaire),
|
||||||
|
travaux_total: roundMoney(travauxTotal),
|
||||||
|
frais_portage_total: roundMoney(fraisPortageTotal),
|
||||||
|
prix_revient: roundMoney(prixRevient),
|
||||||
|
marge_brute: roundMoney(margeBrute),
|
||||||
|
marge_nette: roundMoney(margeNette),
|
||||||
|
marge_brute_pct: roundMoney(margeBrutePct),
|
||||||
|
marge_nette_pct: roundMoney(margeNettePct),
|
||||||
|
rendement_net_pct: roundMoney(rendementNetPct),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function rendementColor(rendementNetPct: number): string {
|
||||||
|
if (rendementNetPct > 15) return '#16A34A';
|
||||||
|
if (rendementNetPct >= 8) return '#EA580C';
|
||||||
|
return '#DC2626';
|
||||||
|
}
|
||||||
|
|
||||||
|
function formToRecord(form: AnalyseFormInput, calc: AnalyseCalculated): Record<string, unknown> {
|
||||||
|
return {
|
||||||
|
prix_achat: form.prix_achat,
|
||||||
|
type_bien_fiscal: form.type_bien_fiscal,
|
||||||
|
frais_notaire: calc.frais_notaire,
|
||||||
|
frais_agence_achat: form.frais_agence_achat,
|
||||||
|
budget_travaux: form.budget_travaux,
|
||||||
|
reserve_imprevus_pct: form.reserve_imprevus_pct,
|
||||||
|
duree_portage_mois: form.duree_portage_mois,
|
||||||
|
taux_credit: form.taux_credit,
|
||||||
|
taxe_fonciere_annuelle: form.taxe_fonciere_annuelle,
|
||||||
|
charges_copropriete_mensuelle: form.charges_copropriete_mensuelle,
|
||||||
|
prix_revente_cible: form.prix_revente_cible,
|
||||||
|
frais_agence_vente_pct: form.frais_agence_vente_pct,
|
||||||
|
taux_impot: form.taux_impot,
|
||||||
|
marge_brute: calc.marge_brute,
|
||||||
|
marge_brute_pct: calc.marge_brute_pct,
|
||||||
|
marge_nette: calc.marge_nette,
|
||||||
|
marge_nette_pct: calc.marge_nette_pct,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAnalyse(bienId: string | undefined) {
|
||||||
|
const uid = getCurrentUserId();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
const query = useQuery({
|
||||||
|
queryKey: ['analyse_financiere', bienId, uid],
|
||||||
|
queryFn: async (): Promise<AnalyseFinanciereRecord | null> => {
|
||||||
|
if (!bienId || !uid) return null;
|
||||||
|
const res = await pb.collection('analyses_financieres').getList<AnalyseFinanciereRecord>(1, 1, {
|
||||||
|
filter: `bien="${bienId}" && user="${uid}"`,
|
||||||
|
sort: '-id',
|
||||||
|
});
|
||||||
|
return res.items[0] ?? null;
|
||||||
|
},
|
||||||
|
enabled: Boolean(bienId && uid),
|
||||||
|
});
|
||||||
|
|
||||||
|
const saveMutation = useMutation({
|
||||||
|
mutationFn: async (data: AnalyseFormInput & { notes?: string }) => {
|
||||||
|
if (!bienId || !uid) throw new Error('Données manquantes');
|
||||||
|
const res = await pb.collection('analyses_financieres').getList<AnalyseFinanciereRecord>(1, 1, {
|
||||||
|
filter: `bien="${bienId}" && user="${uid}"`,
|
||||||
|
sort: '-id',
|
||||||
|
});
|
||||||
|
const existing = res.items[0];
|
||||||
|
const calc = calculateResults(data);
|
||||||
|
const payload = {
|
||||||
|
...formToRecord(data, calc),
|
||||||
|
notes: data.notes,
|
||||||
|
};
|
||||||
|
if (existing) {
|
||||||
|
return pb.collection('analyses_financieres').update<AnalyseFinanciereRecord>(existing.id, payload);
|
||||||
|
}
|
||||||
|
return pb.collection('analyses_financieres').create<AnalyseFinanciereRecord>({
|
||||||
|
user: uid,
|
||||||
|
bien: bienId,
|
||||||
|
...payload,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
void queryClient.invalidateQueries({ queryKey: ['analyse_financiere', bienId, uid] });
|
||||||
|
void queryClient.invalidateQueries({ queryKey: ['bien_detail', bienId] });
|
||||||
|
void queryClient.invalidateQueries({ queryKey: ['biens', uid] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
analyse: query.data ?? null,
|
||||||
|
isLoading: query.isPending,
|
||||||
|
error: query.error,
|
||||||
|
refetch: query.refetch,
|
||||||
|
fetchAnalyse: query.refetch,
|
||||||
|
saveAnalyse: saveMutation.mutateAsync,
|
||||||
|
isSaving: saveMutation.isPending,
|
||||||
|
calculateResults,
|
||||||
|
};
|
||||||
|
}
|
||||||
204
app/hooks/useBiens.ts
Normal file
204
app/hooks/useBiens.ts
Normal file
@ -0,0 +1,204 @@
|
|||||||
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { ClientResponseError } from 'pocketbase';
|
||||||
|
|
||||||
|
import { getCurrentUserId, pb } from '@/services/pocketbase';
|
||||||
|
import type {
|
||||||
|
AnalyseFinanciereRecord,
|
||||||
|
BienCreate,
|
||||||
|
BienRecord,
|
||||||
|
BienUpdate,
|
||||||
|
ContactRecord,
|
||||||
|
DocumentRecord,
|
||||||
|
EtapePipelineRecord,
|
||||||
|
NoteRecord,
|
||||||
|
VisiteRecord,
|
||||||
|
} from '@/types/collections';
|
||||||
|
|
||||||
|
export type BiensFilters = {
|
||||||
|
search?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type BienExpanded = BienRecord & {
|
||||||
|
expand?: {
|
||||||
|
etape?: EtapePipelineRecord;
|
||||||
|
source_contact?: ContactRecord;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export type BienDetailBundle = {
|
||||||
|
bien: BienExpanded;
|
||||||
|
visites: VisiteRecord[];
|
||||||
|
notes: NoteRecord[];
|
||||||
|
documents: DocumentRecord[];
|
||||||
|
analyse: AnalyseFinanciereRecord | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
async function fetchPrixMapForUser(uid: string): Promise<Map<string, number>> {
|
||||||
|
const analyses = await pb.collection('analyses_financieres').getFullList<AnalyseFinanciereRecord>({
|
||||||
|
filter: `user="${uid}"`,
|
||||||
|
});
|
||||||
|
const map = new Map<string, number>();
|
||||||
|
for (const a of analyses) {
|
||||||
|
if (a.prix_achat != null && a.bien) {
|
||||||
|
map.set(a.bien, a.prix_achat);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchBienDetail(bienId: string): Promise<BienDetailBundle> {
|
||||||
|
const uid = getCurrentUserId();
|
||||||
|
if (!uid) throw new Error('Utilisateur non connecté');
|
||||||
|
let bien: BienExpanded;
|
||||||
|
try {
|
||||||
|
bien = await pb.collection('biens').getOne<BienExpanded>(bienId, {
|
||||||
|
expand: 'etape,source_contact',
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof ClientResponseError && (e.status === 404 || e.status === 400)) {
|
||||||
|
throw new Error(
|
||||||
|
"Ce bien n'existe pas ou a été supprimé (vérifie l'admin PocketBase). Retourne à la liste des biens.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
if (bien.user !== uid) {
|
||||||
|
throw new Error('Accès refusé');
|
||||||
|
}
|
||||||
|
const [visites, notes, documents, analyses] = await Promise.all([
|
||||||
|
pb.collection('visites').getFullList<VisiteRecord>({
|
||||||
|
filter: `bien="${bienId}"`,
|
||||||
|
sort: '-date_visite',
|
||||||
|
}),
|
||||||
|
pb.collection('notes_biens').getFullList<NoteRecord>({
|
||||||
|
filter: `bien="${bienId}"`,
|
||||||
|
sort: '-id',
|
||||||
|
}),
|
||||||
|
pb.collection('documents_biens').getFullList<DocumentRecord>({
|
||||||
|
filter: `bien="${bienId}"`,
|
||||||
|
sort: '-id',
|
||||||
|
}),
|
||||||
|
pb.collection('analyses_financieres').getList<AnalyseFinanciereRecord>(1, 1, {
|
||||||
|
filter: `bien="${bienId}" && user="${uid}"`,
|
||||||
|
sort: '-id',
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
const analyse = analyses.items[0] ?? null;
|
||||||
|
const byUpdatedDesc = (a: { updated?: string }, b: { updated?: string }) =>
|
||||||
|
(b.updated ?? '').localeCompare(a.updated ?? '');
|
||||||
|
return {
|
||||||
|
bien,
|
||||||
|
visites,
|
||||||
|
notes: [...notes].sort(byUpdatedDesc),
|
||||||
|
documents: [...documents].sort(byUpdatedDesc),
|
||||||
|
analyse,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useBiens(filters?: BiensFilters) {
|
||||||
|
const uid = getCurrentUserId();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const search = filters?.search?.trim() ?? '';
|
||||||
|
|
||||||
|
const query = useQuery({
|
||||||
|
queryKey: ['biens', uid, search],
|
||||||
|
queryFn: async () => {
|
||||||
|
if (!uid) return { biens: [] as BienExpanded[], prixByBien: new Map<string, number>() };
|
||||||
|
const parts = [`user="${uid}"`];
|
||||||
|
if (search.length > 0) {
|
||||||
|
const esc = search.replace(/"/g, '\\"');
|
||||||
|
parts.push(`(titre ~ "${esc}" || ville ~ "${esc}" || adresse ~ "${esc}" || code_postal ~ "${esc}")`);
|
||||||
|
}
|
||||||
|
const filter = parts.join(' && ');
|
||||||
|
const [biens, prixByBien] = await Promise.all([
|
||||||
|
pb.collection('biens').getFullList<BienExpanded>({
|
||||||
|
filter,
|
||||||
|
sort: '-id',
|
||||||
|
expand: 'etape,source_contact',
|
||||||
|
}),
|
||||||
|
fetchPrixMapForUser(uid),
|
||||||
|
]);
|
||||||
|
return { biens, prixByBien };
|
||||||
|
},
|
||||||
|
enabled: Boolean(uid),
|
||||||
|
});
|
||||||
|
|
||||||
|
const invalidateBiens = () => {
|
||||||
|
void queryClient.invalidateQueries({ queryKey: ['biens'] });
|
||||||
|
};
|
||||||
|
|
||||||
|
const createBien = useMutation({
|
||||||
|
mutationFn: async (payload: { bien: BienCreate; prixEstime?: number }) => {
|
||||||
|
if (!uid) throw new Error('Utilisateur non connecté');
|
||||||
|
const created = await pb.collection('biens').create<BienRecord>(payload.bien);
|
||||||
|
if (
|
||||||
|
payload.prixEstime != null &&
|
||||||
|
!Number.isNaN(payload.prixEstime) &&
|
||||||
|
payload.prixEstime > 0
|
||||||
|
) {
|
||||||
|
await pb.collection('analyses_financieres').create({
|
||||||
|
user: uid,
|
||||||
|
bien: created.id,
|
||||||
|
prix_achat: payload.prixEstime,
|
||||||
|
type_bien_fiscal: 'ancien',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return created.id;
|
||||||
|
},
|
||||||
|
onSuccess: invalidateBiens,
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateBien = useMutation({
|
||||||
|
mutationFn: async ({ id, data }: { id: string; data: BienUpdate }) => {
|
||||||
|
return pb.collection('biens').update<BienRecord>(id, data);
|
||||||
|
},
|
||||||
|
onSuccess: (_, variables) => {
|
||||||
|
invalidateBiens();
|
||||||
|
void queryClient.invalidateQueries({ queryKey: ['bien_detail', variables.id] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const deleteBien = useMutation({
|
||||||
|
mutationFn: async (id: string) => pb.collection('biens').delete(id),
|
||||||
|
onSuccess: invalidateBiens,
|
||||||
|
});
|
||||||
|
|
||||||
|
const moveBienToEtape = useMutation({
|
||||||
|
mutationFn: async ({ bienId, etapeId }: { bienId: string; etapeId: string }) => {
|
||||||
|
return pb.collection('biens').update(bienId, { etape: etapeId });
|
||||||
|
},
|
||||||
|
onSuccess: (_, variables) => {
|
||||||
|
invalidateBiens();
|
||||||
|
void queryClient.invalidateQueries({ queryKey: ['bien_detail', variables.bienId] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
biens: query.data?.biens ?? [],
|
||||||
|
prixByBien: query.data?.prixByBien ?? new Map<string, number>(),
|
||||||
|
isLoading: query.isPending,
|
||||||
|
error: query.error,
|
||||||
|
refetch: query.refetch,
|
||||||
|
fetchBiens: query.refetch,
|
||||||
|
createBien: createBien.mutateAsync,
|
||||||
|
updateBien: updateBien.mutateAsync,
|
||||||
|
deleteBien: deleteBien.mutateAsync,
|
||||||
|
moveBienToEtape: moveBienToEtape.mutateAsync,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useBienDetail(bienId: string | undefined) {
|
||||||
|
const query = useQuery({
|
||||||
|
queryKey: ['bien_detail', bienId],
|
||||||
|
queryFn: () => fetchBienDetail(bienId!),
|
||||||
|
enabled: Boolean(bienId),
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
bundle: query.data ?? null,
|
||||||
|
isLoading: query.isPending,
|
||||||
|
error: query.error,
|
||||||
|
refetch: query.refetch,
|
||||||
|
fetchBienDetail: query.refetch,
|
||||||
|
};
|
||||||
|
}
|
||||||
67
app/hooks/useEtapes.ts
Normal file
67
app/hooks/useEtapes.ts
Normal file
@ -0,0 +1,67 @@
|
|||||||
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
|
|
||||||
|
import { getCurrentUserId, pb } from '@/services/pocketbase';
|
||||||
|
import type { EtapePipelineRecord } from '@/types/collections';
|
||||||
|
|
||||||
|
const DEFAULT_ETAPES: { nom: string; ordre: number; couleur: string; is_terminal?: boolean }[] = [
|
||||||
|
{ nom: 'Prospection', ordre: 1, couleur: '#64748B' },
|
||||||
|
{ nom: 'Contact établi', ordre: 2, couleur: '#0EA5E9' },
|
||||||
|
{ nom: 'Visite', ordre: 3, couleur: '#8B5CF6' },
|
||||||
|
{ nom: 'Analyse', ordre: 4, couleur: '#F59E0B' },
|
||||||
|
{ nom: 'Offre', ordre: 5, couleur: '#EC4899' },
|
||||||
|
{ nom: 'Compromis', ordre: 6, couleur: '#10B981' },
|
||||||
|
{ nom: 'Acte / acquisition', ordre: 7, couleur: '#16A34A', is_terminal: true },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function useEtapes() {
|
||||||
|
const uid = getCurrentUserId();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
const query = useQuery({
|
||||||
|
queryKey: ['etapes_pipeline', uid],
|
||||||
|
queryFn: async () => {
|
||||||
|
if (!uid) return [] as EtapePipelineRecord[];
|
||||||
|
const list = await pb.collection('etapes_pipeline').getFullList<EtapePipelineRecord>({
|
||||||
|
filter: `user="${uid}"`,
|
||||||
|
sort: 'ordre',
|
||||||
|
});
|
||||||
|
return list;
|
||||||
|
},
|
||||||
|
enabled: Boolean(uid),
|
||||||
|
});
|
||||||
|
|
||||||
|
const initMutation = useMutation({
|
||||||
|
mutationFn: async () => {
|
||||||
|
if (!uid) throw new Error('Non connecté');
|
||||||
|
const existing = await pb.collection('etapes_pipeline').getFullList<EtapePipelineRecord>({
|
||||||
|
filter: `user="${uid}"`,
|
||||||
|
});
|
||||||
|
if (existing.length > 0) return existing;
|
||||||
|
for (const e of DEFAULT_ETAPES) {
|
||||||
|
await pb.collection('etapes_pipeline').create({
|
||||||
|
user: uid,
|
||||||
|
nom: e.nom,
|
||||||
|
ordre: e.ordre,
|
||||||
|
couleur: e.couleur,
|
||||||
|
is_terminal: e.is_terminal ?? false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return pb.collection('etapes_pipeline').getFullList<EtapePipelineRecord>({
|
||||||
|
filter: `user="${uid}"`,
|
||||||
|
sort: 'ordre',
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
void queryClient.invalidateQueries({ queryKey: ['etapes_pipeline'] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
etapes: query.data ?? [],
|
||||||
|
isLoading: query.isPending,
|
||||||
|
error: query.error,
|
||||||
|
initEtapesDefaut: initMutation.mutateAsync,
|
||||||
|
initError: initMutation.error,
|
||||||
|
isInitPending: initMutation.isPending,
|
||||||
|
};
|
||||||
|
}
|
||||||
79
app/hooks/useNoteLibre.ts
Normal file
79
app/hooks/useNoteLibre.ts
Normal file
@ -0,0 +1,79 @@
|
|||||||
|
import { useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
|
||||||
|
import { getCurrentUserId, pb } from '@/services/pocketbase';
|
||||||
|
import type { NoteRecord } from '@/types/collections';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Note libre : hydrate depuis le bundle `useBienDetail` (évite un 2e GET sur notes_biens).
|
||||||
|
*/
|
||||||
|
export function useNoteLibre(bienId: string | undefined, notesFromBundle: NoteRecord[] | undefined) {
|
||||||
|
const uid = getCurrentUserId();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const [draft, setDraftState] = useState('');
|
||||||
|
const [hydrated, setHydrated] = useState(false);
|
||||||
|
const noteIdRef = useRef<string | null>(null);
|
||||||
|
const userEdited = useRef(false);
|
||||||
|
const prevBienId = useRef<string | undefined>(undefined);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (prevBienId.current !== bienId) {
|
||||||
|
prevBienId.current = bienId;
|
||||||
|
userEdited.current = false;
|
||||||
|
}
|
||||||
|
}, [bienId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!bienId || !uid) {
|
||||||
|
setHydrated(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (notesFromBundle === undefined) {
|
||||||
|
setHydrated(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const libre =
|
||||||
|
notesFromBundle.find((r) => {
|
||||||
|
const t = r.type_note as string | undefined;
|
||||||
|
return t == null || t === '' || t === 'libre';
|
||||||
|
}) ?? null;
|
||||||
|
noteIdRef.current = libre?.id ?? null;
|
||||||
|
if (!userEdited.current) {
|
||||||
|
setDraftState(libre?.contenu ?? '');
|
||||||
|
}
|
||||||
|
setHydrated(true);
|
||||||
|
}, [bienId, uid, notesFromBundle]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!bienId || !uid || !hydrated || !userEdited.current) return;
|
||||||
|
const t = setTimeout(() => {
|
||||||
|
void (async () => {
|
||||||
|
try {
|
||||||
|
if (!draft.trim()) return;
|
||||||
|
if (noteIdRef.current) {
|
||||||
|
await pb.collection('notes_biens').update(noteIdRef.current, { contenu: draft });
|
||||||
|
} else {
|
||||||
|
const c = await pb.collection('notes_biens').create<NoteRecord>({
|
||||||
|
user: uid,
|
||||||
|
bien: bienId,
|
||||||
|
contenu: draft,
|
||||||
|
type_note: 'libre',
|
||||||
|
});
|
||||||
|
noteIdRef.current = c.id;
|
||||||
|
}
|
||||||
|
void queryClient.invalidateQueries({ queryKey: ['bien_detail', bienId] });
|
||||||
|
} catch {
|
||||||
|
/* ignore autosave */
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
}, 500);
|
||||||
|
return () => clearTimeout(t);
|
||||||
|
}, [draft, bienId, uid, hydrated, queryClient]);
|
||||||
|
|
||||||
|
const setDraft = (text: string) => {
|
||||||
|
userEdited.current = true;
|
||||||
|
setDraftState(text);
|
||||||
|
};
|
||||||
|
|
||||||
|
return { draft, setDraft, hydrated };
|
||||||
|
}
|
||||||
6
app/metro.config.js
Normal file
6
app/metro.config.js
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
const { getDefaultConfig } = require('expo/metro-config');
|
||||||
|
const { withNativeWind } = require('nativewind/metro');
|
||||||
|
|
||||||
|
const config = getDefaultConfig(__dirname);
|
||||||
|
|
||||||
|
module.exports = withNativeWind(config, { input: './global.css' });
|
||||||
1
app/nativewind-env.d.ts
vendored
Normal file
1
app/nativewind-env.d.ts
vendored
Normal file
@ -0,0 +1 @@
|
|||||||
|
/// <reference types="nativewind/types" />
|
||||||
10226
app/package-lock.json
generated
Normal file
10226
app/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
49
app/package.json
Normal file
49
app/package.json
Normal file
@ -0,0 +1,49 @@
|
|||||||
|
{
|
||||||
|
"name": "app",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"private": true,
|
||||||
|
"main": "expo-router/entry",
|
||||||
|
"scripts": {
|
||||||
|
"postinstall": "node scripts/ensure-assets.js",
|
||||||
|
"start": "expo start",
|
||||||
|
"android": "expo start --android",
|
||||||
|
"ios": "expo start --ios",
|
||||||
|
"web": "expo start --web"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@expo/vector-icons": "^15.0.3",
|
||||||
|
"@react-native-async-storage/async-storage": "2.2.0",
|
||||||
|
"@react-navigation/native": "^7.1.8",
|
||||||
|
"@tanstack/react-query": "^5.90.0",
|
||||||
|
"expo": "~54.0.0",
|
||||||
|
"expo-constants": "~18.0.13",
|
||||||
|
"expo-document-picker": "~14.0.8",
|
||||||
|
"expo-font": "~14.0.11",
|
||||||
|
"expo-haptics": "~15.0.7",
|
||||||
|
"expo-image-picker": "~17.0.8",
|
||||||
|
"expo-linking": "~8.0.11",
|
||||||
|
"expo-router": "~6.0.0",
|
||||||
|
"expo-splash-screen": "~31.0.13",
|
||||||
|
"expo-status-bar": "~3.0.8",
|
||||||
|
"expo-web-browser": "~15.0.10",
|
||||||
|
"nativewind": "^4.1.23",
|
||||||
|
"pocketbase": "^0.26.0",
|
||||||
|
"react": "19.1.0",
|
||||||
|
"react-dom": "19.1.0",
|
||||||
|
"react-native": "0.81.4",
|
||||||
|
"react-native-gesture-handler": "~2.28.0",
|
||||||
|
"react-native-reanimated": "~4.1.1",
|
||||||
|
"react-native-safe-area-context": "~5.6.0",
|
||||||
|
"react-native-screens": "~4.16.0",
|
||||||
|
"react-native-web": "~0.21.0",
|
||||||
|
"react-native-worklets": "0.5.1",
|
||||||
|
"tailwindcss": "^3.4.17",
|
||||||
|
"zustand": "^5.0.3"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@babel/core": "^7.26.0",
|
||||||
|
"@types/react": "~19.1.0",
|
||||||
|
"babel-preset-expo": "~54.0.0",
|
||||||
|
"typescript": "~5.9.2"
|
||||||
|
}
|
||||||
|
}
|
||||||
26
app/scripts/ensure-assets.js
Normal file
26
app/scripts/ensure-assets.js
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
/**
|
||||||
|
* Postinstall : vérifie que les icônes Expo existent (déjà dans le repo).
|
||||||
|
*/
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
const required = [
|
||||||
|
'assets/images/icon.png',
|
||||||
|
'assets/images/splash-icon.png',
|
||||||
|
'assets/images/adaptive-icon.png',
|
||||||
|
'assets/images/favicon.png',
|
||||||
|
];
|
||||||
|
|
||||||
|
const root = path.join(__dirname, '..');
|
||||||
|
let ok = true;
|
||||||
|
for (const rel of required) {
|
||||||
|
const p = path.join(root, rel);
|
||||||
|
if (!fs.existsSync(p)) {
|
||||||
|
console.warn('[ensure-assets] missing:', rel);
|
||||||
|
ok = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!ok) {
|
||||||
|
console.warn('[ensure-assets] add missing images under assets/images/');
|
||||||
|
}
|
||||||
|
process.exit(0);
|
||||||
86
app/services/pocketbase.ts
Normal file
86
app/services/pocketbase.ts
Normal file
@ -0,0 +1,86 @@
|
|||||||
|
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||||
|
import PocketBase, { type AuthRecord } from 'pocketbase';
|
||||||
|
|
||||||
|
const PB_AUTH_KEY = 'mdb_pb_auth';
|
||||||
|
|
||||||
|
function resolvePocketBaseUrl(): string {
|
||||||
|
const fromEnv = process.env.EXPO_PUBLIC_PB_URL?.replace(/\/$/, '').trim() ?? '';
|
||||||
|
if (fromEnv.startsWith('http://') || fromEnv.startsWith('https://')) {
|
||||||
|
return fromEnv;
|
||||||
|
}
|
||||||
|
const fallback = 'http://localhost:8090';
|
||||||
|
if (typeof __DEV__ !== 'undefined' && __DEV__) {
|
||||||
|
console.warn(
|
||||||
|
'[mdb] EXPO_PUBLIC_PB_URL absent ou invalide — utilisation de',
|
||||||
|
fallback,
|
||||||
|
'(placez EXPO_PUBLIC_PB_URL dans app/.env.local ou mdb/.env.local ; redémarrez Expo)',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
const baseUrl = resolvePocketBaseUrl();
|
||||||
|
|
||||||
|
export const pb = new PocketBase(baseUrl);
|
||||||
|
|
||||||
|
pb.autoCancellation(false);
|
||||||
|
|
||||||
|
type StoredAuth = {
|
||||||
|
token: string;
|
||||||
|
record: AuthRecord;
|
||||||
|
};
|
||||||
|
|
||||||
|
let persistListenerRegistered = false;
|
||||||
|
|
||||||
|
function registerAuthPersistence(): void {
|
||||||
|
if (persistListenerRegistered) return;
|
||||||
|
persistListenerRegistered = true;
|
||||||
|
|
||||||
|
pb.authStore.onChange(() => {
|
||||||
|
void (async () => {
|
||||||
|
try {
|
||||||
|
if (!pb.authStore.isValid || !pb.authStore.token || !pb.authStore.record) {
|
||||||
|
await AsyncStorage.removeItem(PB_AUTH_KEY);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const payload: StoredAuth = {
|
||||||
|
token: pb.authStore.token,
|
||||||
|
record: pb.authStore.record,
|
||||||
|
};
|
||||||
|
await AsyncStorage.setItem(PB_AUTH_KEY, JSON.stringify(payload));
|
||||||
|
} catch {
|
||||||
|
await AsyncStorage.removeItem(PB_AUTH_KEY);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
}, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function hydratePocketBaseAuth(): Promise<void> {
|
||||||
|
registerAuthPersistence();
|
||||||
|
const raw = await AsyncStorage.getItem(PB_AUTH_KEY);
|
||||||
|
if (!raw) return;
|
||||||
|
try {
|
||||||
|
const { token, record } = JSON.parse(raw) as StoredAuth;
|
||||||
|
if (!token || !record) {
|
||||||
|
await AsyncStorage.removeItem(PB_AUTH_KEY);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
pb.authStore.save(token, record);
|
||||||
|
try {
|
||||||
|
await pb.collection('users').authRefresh();
|
||||||
|
} catch {
|
||||||
|
pb.authStore.clear();
|
||||||
|
await AsyncStorage.removeItem(PB_AUTH_KEY);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
await AsyncStorage.removeItem(PB_AUTH_KEY);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getCurrentUserId(): string | undefined {
|
||||||
|
return pb.authStore.record?.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isAuthenticated(): boolean {
|
||||||
|
return pb.authStore.isValid;
|
||||||
|
}
|
||||||
6
app/tailwind.config.js
Normal file
6
app/tailwind.config.js
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
/** @type {import('tailwindcss').Config} */
|
||||||
|
module.exports = {
|
||||||
|
content: ['./app/**/*.{js,jsx,ts,tsx}'],
|
||||||
|
presets: [require('nativewind/preset')],
|
||||||
|
theme: { extend: {} },
|
||||||
|
};
|
||||||
10
app/tsconfig.json
Normal file
10
app/tsconfig.json
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"extends": "expo/tsconfig.base",
|
||||||
|
"compilerOptions": {
|
||||||
|
"strict": true,
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["./*"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"include": ["**/*.ts", "**/*.tsx", ".expo/types/**/*.ts", "expo-env.d.ts", "nativewind-env.d.ts"]
|
||||||
|
}
|
||||||
133
app/types/collections.ts
Normal file
133
app/types/collections.ts
Normal file
@ -0,0 +1,133 @@
|
|||||||
|
import type { RecordModel } from 'pocketbase';
|
||||||
|
|
||||||
|
export type UserRecord = RecordModel & {
|
||||||
|
email: string;
|
||||||
|
name?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type BienType =
|
||||||
|
| 'appartement'
|
||||||
|
| 'maison'
|
||||||
|
| 'immeuble'
|
||||||
|
| 'terrain'
|
||||||
|
| 'local_commercial'
|
||||||
|
| 'parking'
|
||||||
|
| 'cave'
|
||||||
|
| 'autre';
|
||||||
|
|
||||||
|
export type BienSource =
|
||||||
|
| 'particulier'
|
||||||
|
| 'agence'
|
||||||
|
| 'notaire'
|
||||||
|
| 'tribunal'
|
||||||
|
| 'succession'
|
||||||
|
| 'reseau'
|
||||||
|
| 'autre';
|
||||||
|
|
||||||
|
export type TypeBienFiscal = 'ancien' | 'neuf';
|
||||||
|
|
||||||
|
export type BienRecord = RecordModel & {
|
||||||
|
user: string;
|
||||||
|
etape?: string;
|
||||||
|
source_contact?: string;
|
||||||
|
titre?: string;
|
||||||
|
type_bien?: BienType;
|
||||||
|
adresse?: string;
|
||||||
|
code_postal?: string;
|
||||||
|
ville?: string;
|
||||||
|
latitude?: number;
|
||||||
|
longitude?: number;
|
||||||
|
surface_habitable?: number;
|
||||||
|
surface_totale?: number;
|
||||||
|
nb_pieces?: number;
|
||||||
|
nb_chambres?: number;
|
||||||
|
annee_construction?: number;
|
||||||
|
dpe_lettre?: string;
|
||||||
|
dpe_valeur?: number;
|
||||||
|
source?: BienSource;
|
||||||
|
url_annonce?: string;
|
||||||
|
statut?: string;
|
||||||
|
priorite?: number;
|
||||||
|
is_off_market?: boolean;
|
||||||
|
date_premiere_visite?: string;
|
||||||
|
date_offre?: string;
|
||||||
|
date_compromis?: string;
|
||||||
|
date_acte?: string;
|
||||||
|
description?: string;
|
||||||
|
points_forts?: string;
|
||||||
|
points_faibles?: string;
|
||||||
|
photo_principale?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type BienCreate = Partial<Omit<BienRecord, 'id' | 'created' | 'updated' | 'collectionId' | 'collectionName'>> & {
|
||||||
|
user: string;
|
||||||
|
ville: string;
|
||||||
|
code_postal: string;
|
||||||
|
type_bien: BienType;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type BienUpdate = Partial<Omit<BienRecord, 'id' | 'user' | 'created' | 'updated' | 'collectionId' | 'collectionName'>>;
|
||||||
|
|
||||||
|
export type EtapePipelineRecord = RecordModel & {
|
||||||
|
user: string;
|
||||||
|
nom: string;
|
||||||
|
ordre: number;
|
||||||
|
couleur?: string;
|
||||||
|
is_terminal?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ContactRecord = RecordModel & {
|
||||||
|
user: string;
|
||||||
|
nom: string;
|
||||||
|
prenom?: string;
|
||||||
|
societe?: string;
|
||||||
|
categorie: string;
|
||||||
|
email?: string;
|
||||||
|
telephone?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AnalyseFinanciereRecord = RecordModel & {
|
||||||
|
user: string;
|
||||||
|
bien: string;
|
||||||
|
prix_achat?: number;
|
||||||
|
type_bien_fiscal?: TypeBienFiscal;
|
||||||
|
frais_notaire?: number;
|
||||||
|
frais_agence_achat?: number;
|
||||||
|
budget_travaux?: number;
|
||||||
|
reserve_imprevus_pct?: number;
|
||||||
|
duree_portage_mois?: number;
|
||||||
|
taux_credit?: number;
|
||||||
|
taxe_fonciere_annuelle?: number;
|
||||||
|
charges_copropriete_mensuelle?: number;
|
||||||
|
prix_revente_cible?: number;
|
||||||
|
frais_agence_vente_pct?: number;
|
||||||
|
taux_impot?: number;
|
||||||
|
marge_brute?: number;
|
||||||
|
marge_brute_pct?: number;
|
||||||
|
marge_nette?: number;
|
||||||
|
marge_nette_pct?: number;
|
||||||
|
notes?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type VisiteRecord = RecordModel & {
|
||||||
|
user: string;
|
||||||
|
bien: string;
|
||||||
|
date_visite: string;
|
||||||
|
type_visite?: string;
|
||||||
|
avis_global?: string;
|
||||||
|
notes_brutes?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type NoteRecord = RecordModel & {
|
||||||
|
user: string;
|
||||||
|
bien: string;
|
||||||
|
contenu: string;
|
||||||
|
type_note?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type DocumentRecord = RecordModel & {
|
||||||
|
user: string;
|
||||||
|
bien: string;
|
||||||
|
nom: string;
|
||||||
|
type_document?: string;
|
||||||
|
};
|
||||||
8
app/utils/format.ts
Normal file
8
app/utils/format.ts
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
export function formatEUR(value: number | null | undefined): string {
|
||||||
|
if (value == null || Number.isNaN(value)) return '—';
|
||||||
|
return new Intl.NumberFormat('fr-FR', { style: 'currency', currency: 'EUR' }).format(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function roundMoney(n: number): number {
|
||||||
|
return Math.round(n * 100) / 100;
|
||||||
|
}
|
||||||
12
app/utils/pocketbaseErrors.ts
Normal file
12
app/utils/pocketbaseErrors.ts
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
import { ClientResponseError } from 'pocketbase';
|
||||||
|
|
||||||
|
export function formatPocketBaseError(e: unknown): string {
|
||||||
|
if (e instanceof ClientResponseError) {
|
||||||
|
const msg = e.response?.message;
|
||||||
|
if (typeof msg === 'string') return msg;
|
||||||
|
if (Array.isArray(msg)) return msg.join(', ');
|
||||||
|
if (e.message) return e.message;
|
||||||
|
}
|
||||||
|
if (e instanceof Error) return e.message;
|
||||||
|
return 'Une erreur est survenue.';
|
||||||
|
}
|
||||||
@ -1,16 +1,25 @@
|
|||||||
/// <reference path="../pb_data/types.d.ts" />
|
/// <reference path="../pb_data/types.d.ts" />
|
||||||
migrate((app) => {
|
/**
|
||||||
|
* Collections métier PocketBase (v0.23+).
|
||||||
|
* Ne recrée PAS `users` : elle existe déjà (auth par défaut).
|
||||||
|
* Idempotent : si une collection existe déjà, on la réutilise (évite "name must be unique" au redémarrage).
|
||||||
|
*/
|
||||||
|
migrate(
|
||||||
|
(app) => {
|
||||||
|
const usersId = app.findCollectionByNameOrId("users").id;
|
||||||
|
|
||||||
// ── 0. Collection users (auth) ───────────────────────────
|
function loadOrCreate(name, factory) {
|
||||||
const users = new Collection({
|
try {
|
||||||
name: "users",
|
return app.findCollectionByNameOrId(name);
|
||||||
type: "auth"
|
} catch {
|
||||||
});
|
const col = factory();
|
||||||
app.save(users);
|
app.save(col);
|
||||||
const usersId = users.id;
|
return col;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ── 1. Étapes pipeline ───────────────────────────────────
|
// ── 1. Étapes pipeline ───────────────────────────────────
|
||||||
const etapes = new Collection({
|
const etapes = loadOrCreate("etapes_pipeline", () => new Collection({
|
||||||
name: "etapes_pipeline",
|
name: "etapes_pipeline",
|
||||||
type: "base",
|
type: "base",
|
||||||
fields: [
|
fields: [
|
||||||
@ -18,18 +27,17 @@ migrate((app) => {
|
|||||||
{ name: "nom", type: "text", required: true },
|
{ name: "nom", type: "text", required: true },
|
||||||
{ name: "ordre", type: "number", required: true },
|
{ name: "ordre", type: "number", required: true },
|
||||||
{ name: "couleur", type: "text" },
|
{ name: "couleur", type: "text" },
|
||||||
{ name: "is_terminal", type: "bool" }
|
{ name: "is_terminal", type: "bool" },
|
||||||
],
|
],
|
||||||
listRule: "@request.auth.id != '' && user = @request.auth.id",
|
listRule: "@request.auth.id != \"\" && user = @request.auth.id",
|
||||||
viewRule: "@request.auth.id != '' && user = @request.auth.id",
|
viewRule: "@request.auth.id != \"\" && user = @request.auth.id",
|
||||||
createRule: "@request.auth.id != ''",
|
createRule: "@request.auth.id != \"\"",
|
||||||
updateRule: "@request.auth.id != '' && user = @request.auth.id",
|
updateRule: "@request.auth.id != \"\" && user = @request.auth.id",
|
||||||
deleteRule: "@request.auth.id != '' && user = @request.auth.id"
|
deleteRule: "@request.auth.id != \"\" && user = @request.auth.id",
|
||||||
});
|
}));
|
||||||
app.save(etapes);
|
|
||||||
|
|
||||||
// ── 2. Contacts ──────────────────────────────────────────
|
// ── 2. Contacts ──────────────────────────────────────────
|
||||||
const contacts = new Collection({
|
const contacts = loadOrCreate("contacts", () => new Collection({
|
||||||
name: "contacts",
|
name: "contacts",
|
||||||
type: "base",
|
type: "base",
|
||||||
fields: [
|
fields: [
|
||||||
@ -48,18 +56,17 @@ migrate((app) => {
|
|||||||
{ name: "recommande", type: "bool" },
|
{ name: "recommande", type: "bool" },
|
||||||
{ name: "taux_horaire", type: "number" },
|
{ name: "taux_horaire", type: "number" },
|
||||||
{ name: "notes", type: "text" },
|
{ name: "notes", type: "text" },
|
||||||
{ name: "is_favori", type: "bool" }
|
{ name: "is_favori", type: "bool" },
|
||||||
],
|
],
|
||||||
listRule: "@request.auth.id != '' && user = @request.auth.id",
|
listRule: "@request.auth.id != \"\" && user = @request.auth.id",
|
||||||
viewRule: "@request.auth.id != '' && user = @request.auth.id",
|
viewRule: "@request.auth.id != \"\" && user = @request.auth.id",
|
||||||
createRule: "@request.auth.id != ''",
|
createRule: "@request.auth.id != \"\"",
|
||||||
updateRule: "@request.auth.id != '' && user = @request.auth.id",
|
updateRule: "@request.auth.id != \"\" && user = @request.auth.id",
|
||||||
deleteRule: "@request.auth.id != '' && user = @request.auth.id"
|
deleteRule: "@request.auth.id != \"\" && user = @request.auth.id",
|
||||||
});
|
}));
|
||||||
app.save(contacts);
|
|
||||||
|
|
||||||
// ── 3. Biens ─────────────────────────────────────────────
|
// ── 3. Biens ─────────────────────────────────────────────
|
||||||
const biens = new Collection({
|
const biens = loadOrCreate("biens", () => new Collection({
|
||||||
name: "biens",
|
name: "biens",
|
||||||
type: "base",
|
type: "base",
|
||||||
fields: [
|
fields: [
|
||||||
@ -92,18 +99,17 @@ migrate((app) => {
|
|||||||
{ name: "description", type: "text" },
|
{ name: "description", type: "text" },
|
||||||
{ name: "points_forts", type: "text" },
|
{ name: "points_forts", type: "text" },
|
||||||
{ name: "points_faibles", type: "text" },
|
{ name: "points_faibles", type: "text" },
|
||||||
{ name: "photo_principale", type: "file", options: { maxSelect: 1, maxSize: 10485760, mimeTypes: ["image/jpeg","image/png","image/webp"] } }
|
{ name: "photo_principale", type: "file", options: { maxSelect: 1, maxSize: 10485760, mimeTypes: ["image/jpeg", "image/png", "image/webp"] } },
|
||||||
],
|
],
|
||||||
listRule: "@request.auth.id != '' && user = @request.auth.id",
|
listRule: "@request.auth.id != \"\" && user = @request.auth.id",
|
||||||
viewRule: "@request.auth.id != '' && user = @request.auth.id",
|
viewRule: "@request.auth.id != \"\" && user = @request.auth.id",
|
||||||
createRule: "@request.auth.id != ''",
|
createRule: "@request.auth.id != \"\"",
|
||||||
updateRule: "@request.auth.id != '' && user = @request.auth.id",
|
updateRule: "@request.auth.id != \"\" && user = @request.auth.id",
|
||||||
deleteRule: "@request.auth.id != '' && user = @request.auth.id"
|
deleteRule: "@request.auth.id != \"\" && user = @request.auth.id",
|
||||||
});
|
}));
|
||||||
app.save(biens);
|
|
||||||
|
|
||||||
// ── 4. Analyses financières ───────────────────────────────
|
// ── 4. Analyses financières ───────────────────────────────
|
||||||
const analyses = new Collection({
|
loadOrCreate("analyses_financieres", () => new Collection({
|
||||||
name: "analyses_financieres",
|
name: "analyses_financieres",
|
||||||
type: "base",
|
type: "base",
|
||||||
fields: [
|
fields: [
|
||||||
@ -126,18 +132,17 @@ migrate((app) => {
|
|||||||
{ name: "marge_brute_pct", type: "number" },
|
{ name: "marge_brute_pct", type: "number" },
|
||||||
{ name: "marge_nette", type: "number" },
|
{ name: "marge_nette", type: "number" },
|
||||||
{ name: "marge_nette_pct", type: "number" },
|
{ name: "marge_nette_pct", type: "number" },
|
||||||
{ name: "notes", type: "text" }
|
{ name: "notes", type: "text" },
|
||||||
],
|
],
|
||||||
listRule: "@request.auth.id != '' && user = @request.auth.id",
|
listRule: "@request.auth.id != \"\" && user = @request.auth.id",
|
||||||
viewRule: "@request.auth.id != '' && user = @request.auth.id",
|
viewRule: "@request.auth.id != \"\" && user = @request.auth.id",
|
||||||
createRule: "@request.auth.id != ''",
|
createRule: "@request.auth.id != \"\"",
|
||||||
updateRule: "@request.auth.id != '' && user = @request.auth.id",
|
updateRule: "@request.auth.id != \"\" && user = @request.auth.id",
|
||||||
deleteRule: "@request.auth.id != '' && user = @request.auth.id"
|
deleteRule: "@request.auth.id != \"\" && user = @request.auth.id",
|
||||||
});
|
}));
|
||||||
app.save(analyses);
|
|
||||||
|
|
||||||
// ── 5. Visites ────────────────────────────────────────────
|
// ── 5. Visites ────────────────────────────────────────────
|
||||||
const visites = new Collection({
|
loadOrCreate("visites", () => new Collection({
|
||||||
name: "visites",
|
name: "visites",
|
||||||
type: "base",
|
type: "base",
|
||||||
fields: [
|
fields: [
|
||||||
@ -153,18 +158,17 @@ migrate((app) => {
|
|||||||
{ name: "estimation_travaux_max", type: "number" },
|
{ name: "estimation_travaux_max", type: "number" },
|
||||||
{ name: "avis_global", type: "select", options: { maxSelect: 1, values: ["coup_de_coeur", "interessant", "neutre", "a_eviter"] } },
|
{ name: "avis_global", type: "select", options: { maxSelect: 1, values: ["coup_de_coeur", "interessant", "neutre", "a_eviter"] } },
|
||||||
{ name: "score_opportunite", type: "number", options: { min: 1, max: 10 } },
|
{ name: "score_opportunite", type: "number", options: { min: 1, max: 10 } },
|
||||||
{ name: "photos", type: "file", options: { maxSelect: 20, maxSize: 10485760, mimeTypes: ["image/jpeg","image/png","image/webp"] } }
|
{ name: "photos", type: "file", options: { maxSelect: 20, maxSize: 10485760, mimeTypes: ["image/jpeg", "image/png", "image/webp"] } },
|
||||||
],
|
],
|
||||||
listRule: "@request.auth.id != '' && user = @request.auth.id",
|
listRule: "@request.auth.id != \"\" && user = @request.auth.id",
|
||||||
viewRule: "@request.auth.id != '' && user = @request.auth.id",
|
viewRule: "@request.auth.id != \"\" && user = @request.auth.id",
|
||||||
createRule: "@request.auth.id != ''",
|
createRule: "@request.auth.id != \"\"",
|
||||||
updateRule: "@request.auth.id != '' && user = @request.auth.id",
|
updateRule: "@request.auth.id != \"\" && user = @request.auth.id",
|
||||||
deleteRule: "@request.auth.id != '' && user = @request.auth.id"
|
deleteRule: "@request.auth.id != \"\" && user = @request.auth.id",
|
||||||
});
|
}));
|
||||||
app.save(visites);
|
|
||||||
|
|
||||||
// ── 6. Tâches ─────────────────────────────────────────────
|
// ── 6. Tâches ─────────────────────────────────────────────
|
||||||
const taches = new Collection({
|
loadOrCreate("taches", () => new Collection({
|
||||||
name: "taches",
|
name: "taches",
|
||||||
type: "base",
|
type: "base",
|
||||||
fields: [
|
fields: [
|
||||||
@ -178,36 +182,34 @@ migrate((app) => {
|
|||||||
{ name: "statut", type: "select", options: { maxSelect: 1, values: ["a_faire", "en_cours", "fait", "annule"] } },
|
{ name: "statut", type: "select", options: { maxSelect: 1, values: ["a_faire", "en_cours", "fait", "annule"] } },
|
||||||
{ name: "date_echeance", type: "date" },
|
{ name: "date_echeance", type: "date" },
|
||||||
{ name: "date_rappel", type: "date" },
|
{ name: "date_rappel", type: "date" },
|
||||||
{ name: "is_urgent", type: "bool" }
|
{ name: "is_urgent", type: "bool" },
|
||||||
],
|
],
|
||||||
listRule: "@request.auth.id != '' && user = @request.auth.id",
|
listRule: "@request.auth.id != \"\" && user = @request.auth.id",
|
||||||
viewRule: "@request.auth.id != '' && user = @request.auth.id",
|
viewRule: "@request.auth.id != \"\" && user = @request.auth.id",
|
||||||
createRule: "@request.auth.id != ''",
|
createRule: "@request.auth.id != \"\"",
|
||||||
updateRule: "@request.auth.id != '' && user = @request.auth.id",
|
updateRule: "@request.auth.id != \"\" && user = @request.auth.id",
|
||||||
deleteRule: "@request.auth.id != '' && user = @request.auth.id"
|
deleteRule: "@request.auth.id != \"\" && user = @request.auth.id",
|
||||||
});
|
}));
|
||||||
app.save(taches);
|
|
||||||
|
|
||||||
// ── 7. Notes biens ────────────────────────────────────────
|
// ── 7. Notes biens ────────────────────────────────────────
|
||||||
const notes = new Collection({
|
loadOrCreate("notes_biens", () => new Collection({
|
||||||
name: "notes_biens",
|
name: "notes_biens",
|
||||||
type: "base",
|
type: "base",
|
||||||
fields: [
|
fields: [
|
||||||
{ name: "user", type: "relation", required: true, options: { collectionId: usersId, maxSelect: 1, cascadeDelete: true } },
|
{ name: "user", type: "relation", required: true, options: { collectionId: usersId, maxSelect: 1, cascadeDelete: true } },
|
||||||
{ name: "bien", type: "relation", required: true, options: { collectionId: biens.id, maxSelect: 1, cascadeDelete: true } },
|
{ name: "bien", type: "relation", required: true, options: { collectionId: biens.id, maxSelect: 1, cascadeDelete: true } },
|
||||||
{ name: "contenu", type: "text", required: true },
|
{ name: "contenu", type: "text", required: true },
|
||||||
{ name: "type_note", type: "select", options: { maxSelect: 1, values: ["libre","contact_vendeur","negociation","info_marche"] } }
|
{ name: "type_note", type: "select", options: { maxSelect: 1, values: ["libre", "contact_vendeur", "negociation", "info_marche"] } },
|
||||||
],
|
],
|
||||||
listRule: "@request.auth.id != '' && user = @request.auth.id",
|
listRule: "@request.auth.id != \"\" && user = @request.auth.id",
|
||||||
viewRule: "@request.auth.id != '' && user = @request.auth.id",
|
viewRule: "@request.auth.id != \"\" && user = @request.auth.id",
|
||||||
createRule: "@request.auth.id != ''",
|
createRule: "@request.auth.id != \"\"",
|
||||||
updateRule: "@request.auth.id != '' && user = @request.auth.id",
|
updateRule: "@request.auth.id != \"\" && user = @request.auth.id",
|
||||||
deleteRule: "@request.auth.id != '' && user = @request.auth.id"
|
deleteRule: "@request.auth.id != \"\" && user = @request.auth.id",
|
||||||
});
|
}));
|
||||||
app.save(notes);
|
|
||||||
|
|
||||||
// ── 8. Documents biens ────────────────────────────────────
|
// ── 8. Documents biens ────────────────────────────────────
|
||||||
const documents = new Collection({
|
loadOrCreate("documents_biens", () => new Collection({
|
||||||
name: "documents_biens",
|
name: "documents_biens",
|
||||||
type: "base",
|
type: "base",
|
||||||
fields: [
|
fields: [
|
||||||
@ -217,18 +219,17 @@ migrate((app) => {
|
|||||||
{ name: "type_document", type: "select", options: { maxSelect: 1, values: ["compromis", "acte", "dpe", "diagnostics", "devis", "facture", "titre_propriete", "autre"] } },
|
{ name: "type_document", type: "select", options: { maxSelect: 1, values: ["compromis", "acte", "dpe", "diagnostics", "devis", "facture", "titre_propriete", "autre"] } },
|
||||||
{ name: "date_document", type: "date" },
|
{ name: "date_document", type: "date" },
|
||||||
{ name: "notes", type: "text" },
|
{ name: "notes", type: "text" },
|
||||||
{ name: "fichier", type: "file", required: true, options: { maxSelect: 1, maxSize: 52428800, mimeTypes: ["application/pdf","image/jpeg","image/png"] } }
|
{ name: "fichier", type: "file", required: true, options: { maxSelect: 1, maxSize: 52428800, mimeTypes: ["application/pdf", "image/jpeg", "image/png"] } },
|
||||||
],
|
],
|
||||||
listRule: "@request.auth.id != '' && user = @request.auth.id",
|
listRule: "@request.auth.id != \"\" && user = @request.auth.id",
|
||||||
viewRule: "@request.auth.id != '' && user = @request.auth.id",
|
viewRule: "@request.auth.id != \"\" && user = @request.auth.id",
|
||||||
createRule: "@request.auth.id != ''",
|
createRule: "@request.auth.id != \"\"",
|
||||||
updateRule: "@request.auth.id != '' && user = @request.auth.id",
|
updateRule: "@request.auth.id != \"\" && user = @request.auth.id",
|
||||||
deleteRule: "@request.auth.id != '' && user = @request.auth.id"
|
deleteRule: "@request.auth.id != \"\" && user = @request.auth.id",
|
||||||
});
|
}));
|
||||||
app.save(documents);
|
|
||||||
|
|
||||||
// ── 9. Devis travaux ──────────────────────────────────────
|
// ── 9. Devis travaux ─────────────────────────────────────
|
||||||
const devis = new Collection({
|
loadOrCreate("devis_travaux", () => new Collection({
|
||||||
name: "devis_travaux",
|
name: "devis_travaux",
|
||||||
type: "base",
|
type: "base",
|
||||||
fields: [
|
fields: [
|
||||||
@ -245,18 +246,30 @@ migrate((app) => {
|
|||||||
{ name: "date_debut", type: "date" },
|
{ name: "date_debut", type: "date" },
|
||||||
{ name: "date_fin_prevu", type: "date" },
|
{ name: "date_fin_prevu", type: "date" },
|
||||||
{ name: "notes", type: "text" },
|
{ name: "notes", type: "text" },
|
||||||
{ name: "fichier_pdf", type: "file", options: { maxSelect: 1, maxSize: 10485760, mimeTypes: ["application/pdf"] } }
|
{ name: "fichier_pdf", type: "file", options: { maxSelect: 1, maxSize: 10485760, mimeTypes: ["application/pdf"] } },
|
||||||
],
|
],
|
||||||
listRule: "@request.auth.id != '' && user = @request.auth.id",
|
listRule: "@request.auth.id != \"\" && user = @request.auth.id",
|
||||||
viewRule: "@request.auth.id != '' && user = @request.auth.id",
|
viewRule: "@request.auth.id != \"\" && user = @request.auth.id",
|
||||||
createRule: "@request.auth.id != ''",
|
createRule: "@request.auth.id != \"\"",
|
||||||
updateRule: "@request.auth.id != '' && user = @request.auth.id",
|
updateRule: "@request.auth.id != \"\" && user = @request.auth.id",
|
||||||
deleteRule: "@request.auth.id != '' && user = @request.auth.id"
|
deleteRule: "@request.auth.id != \"\" && user = @request.auth.id",
|
||||||
});
|
}));
|
||||||
app.save(devis);
|
},
|
||||||
|
(app) => {
|
||||||
}, (app) => {
|
for (const name of [
|
||||||
for (const name of ["devis_travaux","documents_biens","notes_biens","taches","visites","analyses_financieres","biens","contacts","etapes_pipeline","users"]) {
|
"devis_travaux",
|
||||||
try { app.delete(app.findCollectionByNameOrId(name)); } catch(_) {}
|
"documents_biens",
|
||||||
|
"notes_biens",
|
||||||
|
"taches",
|
||||||
|
"visites",
|
||||||
|
"analyses_financieres",
|
||||||
|
"biens",
|
||||||
|
"contacts",
|
||||||
|
"etapes_pipeline",
|
||||||
|
]) {
|
||||||
|
try {
|
||||||
|
app.delete(app.findCollectionByNameOrId(name));
|
||||||
|
} catch (_) {}
|
||||||
}
|
}
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|||||||
@ -0,0 +1,40 @@
|
|||||||
|
/**
|
||||||
|
* Remplace `!= ''` par `!= ""` dans les règles API (syntaxe attendue par PocketBase).
|
||||||
|
* Sans ça, les GET list peuvent répondre 400 alors que les données existent.
|
||||||
|
*/
|
||||||
|
migrate(
|
||||||
|
(app) => {
|
||||||
|
const names = [
|
||||||
|
"etapes_pipeline",
|
||||||
|
"contacts",
|
||||||
|
"biens",
|
||||||
|
"analyses_financieres",
|
||||||
|
"visites",
|
||||||
|
"taches",
|
||||||
|
"notes_biens",
|
||||||
|
"documents_biens",
|
||||||
|
"devis_travaux",
|
||||||
|
];
|
||||||
|
const ruleKeys = ["listRule", "viewRule", "createRule", "updateRule", "deleteRule"];
|
||||||
|
for (const name of names) {
|
||||||
|
let col;
|
||||||
|
try {
|
||||||
|
col = app.findCollectionByNameOrId(name);
|
||||||
|
} catch {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let changed = false;
|
||||||
|
for (const key of ruleKeys) {
|
||||||
|
const v = col[key];
|
||||||
|
if (typeof v === "string" && v.includes("!= ''")) {
|
||||||
|
col[key] = v.replace(/!= ''/g, '!= ""');
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (changed) {
|
||||||
|
app.save(col);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
() => {},
|
||||||
|
);
|
||||||
Reference in New Issue
Block a user