init
This commit is contained in:
40
src/components/LabeledField.tsx
Normal file
40
src/components/LabeledField.tsx
Normal file
@ -0,0 +1,40 @@
|
||||
import { StyleSheet, Text, TextInput, View, type TextInputProps } from 'react-native';
|
||||
import { colors } from '../theme/colors';
|
||||
|
||||
type Props = TextInputProps & {
|
||||
label: string;
|
||||
};
|
||||
|
||||
export function LabeledField({ label, style, ...rest }: Props) {
|
||||
return (
|
||||
<View style={styles.wrap}>
|
||||
<Text style={styles.label}>{label}</Text>
|
||||
<TextInput
|
||||
placeholderTextColor={colors.textMuted}
|
||||
style={[styles.input, style]}
|
||||
{...rest}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
wrap: { marginBottom: 14 },
|
||||
label: {
|
||||
color: colors.textMuted,
|
||||
fontSize: 12,
|
||||
marginBottom: 6,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 0.06,
|
||||
},
|
||||
input: {
|
||||
borderWidth: 1,
|
||||
borderColor: colors.border,
|
||||
borderRadius: 10,
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 10,
|
||||
color: colors.text,
|
||||
backgroundColor: colors.bgCard,
|
||||
fontSize: 16,
|
||||
},
|
||||
});
|
||||
77
src/components/PrimaryButton.tsx
Normal file
77
src/components/PrimaryButton.tsx
Normal file
@ -0,0 +1,77 @@
|
||||
import {
|
||||
ActivityIndicator,
|
||||
Pressable,
|
||||
StyleSheet,
|
||||
Text,
|
||||
type PressableProps,
|
||||
type ViewStyle,
|
||||
} from 'react-native';
|
||||
import { colors } from '../theme/colors';
|
||||
|
||||
type Props = Omit<PressableProps, 'style'> & {
|
||||
title: string;
|
||||
variant?: 'primary' | 'danger' | 'ghost';
|
||||
loading?: boolean;
|
||||
containerStyle?: ViewStyle;
|
||||
};
|
||||
|
||||
export function PrimaryButton({
|
||||
title,
|
||||
variant = 'primary',
|
||||
loading,
|
||||
disabled,
|
||||
containerStyle,
|
||||
...rest
|
||||
}: Props) {
|
||||
const dim = disabled || loading;
|
||||
return (
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
style={({ pressed }) => [
|
||||
styles.base,
|
||||
variant === 'primary' && styles.primary,
|
||||
variant === 'danger' && styles.danger,
|
||||
variant === 'ghost' && styles.ghost,
|
||||
(pressed || dim) && styles.dim,
|
||||
containerStyle,
|
||||
]}
|
||||
disabled={dim}
|
||||
{...rest}
|
||||
>
|
||||
{loading ? (
|
||||
<ActivityIndicator color={variant === 'ghost' ? colors.accent : '#fff'} />
|
||||
) : (
|
||||
<Text
|
||||
style={[
|
||||
styles.text,
|
||||
variant === 'ghost' && styles.textGhost,
|
||||
variant === 'danger' && styles.textDanger,
|
||||
]}
|
||||
>
|
||||
{title}
|
||||
</Text>
|
||||
)}
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
base: {
|
||||
paddingVertical: 14,
|
||||
paddingHorizontal: 18,
|
||||
borderRadius: 12,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
primary: { backgroundColor: colors.accent },
|
||||
danger: { backgroundColor: colors.danger },
|
||||
ghost: {
|
||||
backgroundColor: 'transparent',
|
||||
borderWidth: 1,
|
||||
borderColor: colors.border,
|
||||
},
|
||||
dim: { opacity: 0.55 },
|
||||
text: { color: '#fff', fontWeight: '600', fontSize: 16 },
|
||||
textGhost: { color: colors.text },
|
||||
textDanger: { color: '#fff' },
|
||||
});
|
||||
655
src/context/AppContext.tsx
Normal file
655
src/context/AppContext.tsx
Normal file
@ -0,0 +1,655 @@
|
||||
import type { Session, SupabaseClient } from '@supabase/supabase-js';
|
||||
import React, {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { makeSupabase } from '../lib/supabaseFactory';
|
||||
import {
|
||||
newDossierTemplate,
|
||||
seedVisitRowsForDossier,
|
||||
LOCAL_USER_ID,
|
||||
} from '../data/defaults';
|
||||
import {
|
||||
SCOUT_SAMPLE_JSON,
|
||||
scoutFilterListings,
|
||||
} from '../data/dealSource';
|
||||
import type {
|
||||
DealSourceRow,
|
||||
DossierRow,
|
||||
DossierVisitFindingRow,
|
||||
InvestisseurRow,
|
||||
LocalDbSnapshot,
|
||||
VisitFindingDefinitionRow,
|
||||
} from '../data/types';
|
||||
import { VISIT_FINDING_SEED } from '../data/visitWorks';
|
||||
import {
|
||||
readCloudConfig,
|
||||
readLocalDb,
|
||||
readStoredMode,
|
||||
writeCloudConfig,
|
||||
writeLocalDb,
|
||||
writeStoredMode,
|
||||
type StoredCloudConfig,
|
||||
} from './persistence';
|
||||
import { randomUuid } from '../lib/uuid';
|
||||
|
||||
export type AppRuntimeMode = 'none' | 'local' | 'cloud';
|
||||
|
||||
type AppUser = { id: string; email: string | null };
|
||||
|
||||
interface AppContextValue {
|
||||
ready: boolean;
|
||||
runtimeMode: AppRuntimeMode;
|
||||
user: AppUser | null;
|
||||
supabase: SupabaseClient | null;
|
||||
dossiers: DossierRow[];
|
||||
investisseurs: InvestisseurRow[];
|
||||
definitions: VisitFindingDefinitionRow[];
|
||||
findingTick: number;
|
||||
refreshAll: () => Promise<void>;
|
||||
enterLocalMode: () => Promise<void>;
|
||||
saveCloudConfig: (cfg: StoredCloudConfig) => Promise<void>;
|
||||
signIn: (email: string, password: string) => Promise<{ error?: string }>;
|
||||
signUp: (
|
||||
email: string,
|
||||
password: string,
|
||||
fullName: string,
|
||||
) => Promise<{ error?: string }>;
|
||||
signOut: () => Promise<void>;
|
||||
createDossier: () => Promise<string | null>;
|
||||
updateDossier: (id: string, patch: Partial<DossierRow>) => Promise<void>;
|
||||
deleteDossier: (id: string) => Promise<void>;
|
||||
setDossierStatus: (id: string, status: DossierRow['status']) => Promise<void>;
|
||||
listFindings: (dossierId: string) => DossierVisitFindingRow[];
|
||||
toggleFinding: (
|
||||
dossierId: string,
|
||||
code: string,
|
||||
checked: boolean,
|
||||
) => Promise<void>;
|
||||
upsertInvestisseur: (
|
||||
row: Omit<InvestisseurRow, 'created_at' | 'updated_at' | 'id'> & {
|
||||
id?: string;
|
||||
},
|
||||
) => Promise<void>;
|
||||
deleteInvestisseur: (id: string) => Promise<void>;
|
||||
dealSources: DealSourceRow[];
|
||||
runScoutSampleBatch: () => Promise<
|
||||
{ inserted: number; gradeA: number } | { error: string }
|
||||
>;
|
||||
}
|
||||
|
||||
const Ctx = createContext<AppContextValue | null>(null);
|
||||
|
||||
function normalizeDefinitions(
|
||||
rows: VisitFindingDefinitionRow[] | null | undefined,
|
||||
): VisitFindingDefinitionRow[] {
|
||||
if (rows && rows.length) {
|
||||
return [...rows].sort((a, b) => a.sort_order - b.sort_order);
|
||||
}
|
||||
return VISIT_FINDING_SEED;
|
||||
}
|
||||
|
||||
export function AppProvider({ children }: { children: React.ReactNode }) {
|
||||
const [ready, setReady] = useState(false);
|
||||
const [runtimeMode, setRuntimeMode] = useState<AppRuntimeMode>('none');
|
||||
const [supabase, setSupabase] = useState<SupabaseClient | null>(null);
|
||||
const [session, setSession] = useState<Session | null>(null);
|
||||
const [dossiers, setDossiers] = useState<DossierRow[]>([]);
|
||||
const [investisseurs, setInvestisseurs] = useState<InvestisseurRow[]>([]);
|
||||
const [definitions, setDefinitions] = useState<VisitFindingDefinitionRow[]>(
|
||||
VISIT_FINDING_SEED,
|
||||
);
|
||||
const [localDb, setLocalDb] = useState<LocalDbSnapshot>({
|
||||
dossiers: [],
|
||||
dossier_visit_findings: [],
|
||||
investisseurs: [],
|
||||
deals_sources: [],
|
||||
});
|
||||
const [findingTick, setFindingTick] = useState(0);
|
||||
const [dealSources, setDealSources] = useState<DealSourceRow[]>([]);
|
||||
|
||||
const user = useMemo<AppUser | null>(() => {
|
||||
if (runtimeMode === 'local') {
|
||||
return { id: LOCAL_USER_ID, email: 'hors-ligne@mdb-turbo' };
|
||||
}
|
||||
if (runtimeMode === 'cloud' && session?.user) {
|
||||
return { id: session.user.id, email: session.user.email ?? null };
|
||||
}
|
||||
return null;
|
||||
}, [runtimeMode, session]);
|
||||
|
||||
const refreshAll = useCallback(async () => {
|
||||
if (runtimeMode === 'local') {
|
||||
const db = await readLocalDb();
|
||||
setLocalDb(db);
|
||||
setDossiers(db.dossiers);
|
||||
setInvestisseurs(db.investisseurs);
|
||||
setDealSources(db.deals_sources ?? []);
|
||||
setDefinitions(VISIT_FINDING_SEED);
|
||||
return;
|
||||
}
|
||||
if (!supabase || !session?.user) {
|
||||
setDossiers([]);
|
||||
setInvestisseurs([]);
|
||||
setDealSources([]);
|
||||
return;
|
||||
}
|
||||
const uid = session.user.id;
|
||||
const [dRes, iRes, defRes, dealsRes] = await Promise.all([
|
||||
supabase
|
||||
.from('dossiers')
|
||||
.select('*')
|
||||
.eq('user_id', uid)
|
||||
.order('updated_at', { ascending: false }),
|
||||
supabase.from('investisseurs').select('*').eq('user_id', uid),
|
||||
supabase
|
||||
.from('visit_finding_definitions')
|
||||
.select('*')
|
||||
.order('sort_order', { ascending: true }),
|
||||
supabase
|
||||
.from('deals_sources')
|
||||
.select('*')
|
||||
.eq('user_id', uid)
|
||||
.order('opportunity_score', { ascending: false }),
|
||||
]);
|
||||
setDossiers((dRes.data as DossierRow[]) ?? []);
|
||||
setInvestisseurs((iRes.data as InvestisseurRow[]) ?? []);
|
||||
setDefinitions(normalizeDefinitions(defRes.data as VisitFindingDefinitionRow[]));
|
||||
setDealSources((dealsRes.data as DealSourceRow[]) ?? []);
|
||||
}, [runtimeMode, supabase, session]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
const mode = await readStoredMode();
|
||||
const cfg = await readCloudConfig();
|
||||
const local = await readLocalDb();
|
||||
if (cancelled) return;
|
||||
|
||||
if (mode === 'local') {
|
||||
setRuntimeMode('local');
|
||||
setLocalDb(local);
|
||||
setDossiers(local.dossiers);
|
||||
setInvestisseurs(local.investisseurs);
|
||||
setDealSources(local.deals_sources ?? []);
|
||||
setDefinitions(VISIT_FINDING_SEED);
|
||||
setSupabase(null);
|
||||
setSession(null);
|
||||
setReady(true);
|
||||
return;
|
||||
}
|
||||
|
||||
if (mode === 'cloud' && cfg?.supabaseUrl && cfg.supabaseAnonKey) {
|
||||
const client = makeSupabase(cfg.supabaseUrl, cfg.supabaseAnonKey);
|
||||
const { data } = await client.auth.getSession();
|
||||
if (cancelled) return;
|
||||
setSupabase(client);
|
||||
setSession(data.session ?? null);
|
||||
setRuntimeMode('cloud');
|
||||
setReady(true);
|
||||
return;
|
||||
}
|
||||
|
||||
setRuntimeMode('none');
|
||||
setSupabase(null);
|
||||
setSession(null);
|
||||
setReady(true);
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!supabase) return;
|
||||
const { data: sub } = supabase.auth.onAuthStateChange((_event, sess) => {
|
||||
setSession(sess);
|
||||
});
|
||||
return () => sub.subscription.unsubscribe();
|
||||
}, [supabase]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!ready) return;
|
||||
void refreshAll();
|
||||
}, [ready, runtimeMode, session?.user?.id, refreshAll]);
|
||||
|
||||
const persistLocal = useCallback(async (next: LocalDbSnapshot) => {
|
||||
setLocalDb(next);
|
||||
setDossiers(next.dossiers);
|
||||
setInvestisseurs(next.investisseurs);
|
||||
setDealSources(next.deals_sources ?? []);
|
||||
await writeLocalDb(next);
|
||||
setFindingTick((t) => t + 1);
|
||||
}, []);
|
||||
|
||||
const enterLocalMode = useCallback(async () => {
|
||||
await writeStoredMode('local');
|
||||
const existing = await readLocalDb();
|
||||
setRuntimeMode('local');
|
||||
setSupabase(null);
|
||||
setSession(null);
|
||||
setLocalDb(existing);
|
||||
setDossiers(existing.dossiers);
|
||||
setInvestisseurs(existing.investisseurs);
|
||||
setDealSources(existing.deals_sources ?? []);
|
||||
setDefinitions(VISIT_FINDING_SEED);
|
||||
}, []);
|
||||
|
||||
const saveCloudConfig = useCallback(async (cfg: StoredCloudConfig) => {
|
||||
await writeCloudConfig(cfg);
|
||||
await writeStoredMode('cloud');
|
||||
const client = makeSupabase(cfg.supabaseUrl, cfg.supabaseAnonKey);
|
||||
const { data } = await client.auth.getSession();
|
||||
setSupabase(client);
|
||||
setSession(data.session ?? null);
|
||||
setRuntimeMode('cloud');
|
||||
}, []);
|
||||
|
||||
const signIn = useCallback(
|
||||
async (email: string, password: string) => {
|
||||
if (!supabase) return { error: 'Supabase non configuré.' };
|
||||
const { error } = await supabase.auth.signInWithPassword({
|
||||
email,
|
||||
password,
|
||||
});
|
||||
if (error) return { error: error.message };
|
||||
return {};
|
||||
},
|
||||
[supabase],
|
||||
);
|
||||
|
||||
const signUp = useCallback(
|
||||
async (email: string, password: string, fullName: string) => {
|
||||
if (!supabase) return { error: 'Supabase non configuré.' };
|
||||
const { error } = await supabase.auth.signUp({
|
||||
email,
|
||||
password,
|
||||
options: { data: { full_name: fullName } },
|
||||
});
|
||||
if (error) return { error: error.message };
|
||||
return {};
|
||||
},
|
||||
[supabase],
|
||||
);
|
||||
|
||||
const signOut = useCallback(async () => {
|
||||
if (runtimeMode === 'cloud' && supabase) {
|
||||
await supabase.auth.signOut();
|
||||
return;
|
||||
}
|
||||
if (runtimeMode === 'local') {
|
||||
await writeStoredMode('none');
|
||||
setRuntimeMode('none');
|
||||
setSession(null);
|
||||
setSupabase(null);
|
||||
setDossiers([]);
|
||||
setInvestisseurs([]);
|
||||
setDealSources([]);
|
||||
setLocalDb({
|
||||
dossiers: [],
|
||||
dossier_visit_findings: [],
|
||||
investisseurs: [],
|
||||
deals_sources: [],
|
||||
});
|
||||
}
|
||||
}, [runtimeMode, supabase]);
|
||||
|
||||
const createDossier = useCallback(async (): Promise<string | null> => {
|
||||
const uid = user?.id;
|
||||
if (!uid) return null;
|
||||
if (runtimeMode === 'local') {
|
||||
const snap = await readLocalDb();
|
||||
const row = newDossierTemplate(uid);
|
||||
const findings = seedVisitRowsForDossier(row.id);
|
||||
const next: LocalDbSnapshot = {
|
||||
dossiers: [row, ...snap.dossiers],
|
||||
dossier_visit_findings: [...findings, ...snap.dossier_visit_findings],
|
||||
investisseurs: snap.investisseurs,
|
||||
deals_sources: snap.deals_sources ?? [],
|
||||
};
|
||||
await persistLocal(next);
|
||||
return row.id;
|
||||
}
|
||||
if (!supabase) return null;
|
||||
const base = newDossierTemplate(uid);
|
||||
const { data, error } = await supabase
|
||||
.from('dossiers')
|
||||
.insert({
|
||||
user_id: uid,
|
||||
title: base.title,
|
||||
status: base.status,
|
||||
surface_m2: base.surface_m2,
|
||||
purchase_price_target: base.purchase_price_target,
|
||||
resale_price_estimate: base.resale_price_estimate,
|
||||
dvf_reference_price_m2: base.dvf_reference_price_m2,
|
||||
works_estimate_total: base.works_estimate_total,
|
||||
works_visit_adjustment: base.works_visit_adjustment,
|
||||
notary_fee_rate: base.notary_fee_rate,
|
||||
sale_agency_fee_rate: base.sale_agency_fee_rate,
|
||||
misc_acquisition_cost: base.misc_acquisition_cost,
|
||||
misc_sale_cost: base.misc_sale_cost,
|
||||
carrying_months: base.carrying_months,
|
||||
carrying_annual_rate: base.carrying_annual_rate,
|
||||
parcel_subdivision_candidate: base.parcel_subdivision_candidate,
|
||||
deficit_foncier_candidate: base.deficit_foncier_candidate,
|
||||
plu_zone_code: base.plu_zone_code,
|
||||
plu_notes: base.plu_notes,
|
||||
})
|
||||
.select('id')
|
||||
.single();
|
||||
if (error || !data?.id) {
|
||||
return null;
|
||||
}
|
||||
const id = data.id as string;
|
||||
const defs = definitions.length ? definitions : VISIT_FINDING_SEED;
|
||||
const rows = defs.map((d) => ({
|
||||
dossier_id: id,
|
||||
finding_code: d.code,
|
||||
checked: false,
|
||||
}));
|
||||
await supabase.from('dossier_visit_findings').insert(rows);
|
||||
await refreshAll();
|
||||
return id;
|
||||
}, [user?.id, runtimeMode, supabase, persistLocal, definitions, refreshAll]);
|
||||
|
||||
const updateDossier = useCallback(
|
||||
async (id: string, patch: Partial<DossierRow>) => {
|
||||
if (runtimeMode === 'local') {
|
||||
const snap = await readLocalDb();
|
||||
const nextDossiers = snap.dossiers.map((d) =>
|
||||
d.id === id ? { ...d, ...patch, updated_at: new Date().toISOString() } : d,
|
||||
);
|
||||
await persistLocal({ ...snap, dossiers: nextDossiers });
|
||||
return;
|
||||
}
|
||||
if (!supabase || !user) return;
|
||||
const clean: Record<string, unknown> = { ...patch };
|
||||
delete clean.id;
|
||||
delete clean.user_id;
|
||||
delete clean.created_at;
|
||||
await supabase.from('dossiers').update(clean).eq('id', id).eq('user_id', user.id);
|
||||
await refreshAll();
|
||||
},
|
||||
[runtimeMode, persistLocal, supabase, user, refreshAll],
|
||||
);
|
||||
|
||||
const deleteDossier = useCallback(
|
||||
async (id: string) => {
|
||||
if (runtimeMode === 'local') {
|
||||
const snap = await readLocalDb();
|
||||
const next: LocalDbSnapshot = {
|
||||
dossiers: snap.dossiers.filter((d) => d.id !== id),
|
||||
dossier_visit_findings: snap.dossier_visit_findings.filter(
|
||||
(f) => f.dossier_id !== id,
|
||||
),
|
||||
investisseurs: snap.investisseurs,
|
||||
deals_sources: snap.deals_sources ?? [],
|
||||
};
|
||||
await persistLocal(next);
|
||||
return;
|
||||
}
|
||||
if (!supabase || !user) return;
|
||||
await supabase.from('dossiers').delete().eq('id', id).eq('user_id', user.id);
|
||||
await refreshAll();
|
||||
},
|
||||
[runtimeMode, persistLocal, supabase, user, refreshAll],
|
||||
);
|
||||
|
||||
const setDossierStatus = useCallback(
|
||||
async (id: string, status: DossierRow['status']) => {
|
||||
const extra: Partial<DossierRow> = { status };
|
||||
if (status === 'under_promise') {
|
||||
extra.under_promise_at = new Date().toISOString();
|
||||
}
|
||||
await updateDossier(id, extra);
|
||||
},
|
||||
[updateDossier],
|
||||
);
|
||||
|
||||
const listFindings = useCallback(
|
||||
(dossierId: string): DossierVisitFindingRow[] => {
|
||||
if (runtimeMode !== 'local') return [];
|
||||
return localDb.dossier_visit_findings.filter((f) => f.dossier_id === dossierId);
|
||||
},
|
||||
[runtimeMode, localDb.dossier_visit_findings],
|
||||
);
|
||||
|
||||
const toggleFinding = useCallback(
|
||||
async (dossierId: string, code: string, checked: boolean) => {
|
||||
const now = new Date().toISOString();
|
||||
if (runtimeMode === 'local') {
|
||||
const snap = await readLocalDb();
|
||||
const nextFindings = snap.dossier_visit_findings.map((f) =>
|
||||
f.dossier_id === dossierId && f.finding_code === code
|
||||
? { ...f, checked, checked_at: checked ? now : null }
|
||||
: f,
|
||||
);
|
||||
await persistLocal({ ...snap, dossier_visit_findings: nextFindings });
|
||||
return;
|
||||
}
|
||||
if (!supabase) return;
|
||||
await supabase
|
||||
.from('dossier_visit_findings')
|
||||
.update({ checked, checked_at: checked ? now : null })
|
||||
.eq('dossier_id', dossierId)
|
||||
.eq('finding_code', code);
|
||||
setFindingTick((t) => t + 1);
|
||||
await refreshAll();
|
||||
},
|
||||
[runtimeMode, persistLocal, supabase, refreshAll],
|
||||
);
|
||||
|
||||
const upsertInvestisseur = useCallback(
|
||||
async (
|
||||
row: Omit<InvestisseurRow, 'created_at' | 'updated_at' | 'id'> & {
|
||||
id?: string;
|
||||
},
|
||||
) => {
|
||||
const ts = new Date().toISOString();
|
||||
if (runtimeMode === 'local') {
|
||||
const snap = await readLocalDb();
|
||||
const id = row.id ?? randomUuid();
|
||||
const existing = snap.investisseurs.find((i) => i.id === id);
|
||||
const nextRow: InvestisseurRow = {
|
||||
id,
|
||||
user_id: row.user_id,
|
||||
display_name: row.display_name,
|
||||
email: row.email,
|
||||
phone: row.phone,
|
||||
min_margin_pct: row.min_margin_pct,
|
||||
max_ticket_eur: row.max_ticket_eur,
|
||||
zones: row.zones,
|
||||
strategies: row.strategies,
|
||||
notes: row.notes,
|
||||
created_at: existing?.created_at ?? ts,
|
||||
updated_at: ts,
|
||||
};
|
||||
const list = existing
|
||||
? snap.investisseurs.map((i) => (i.id === id ? nextRow : i))
|
||||
: [nextRow, ...snap.investisseurs];
|
||||
await persistLocal({ ...snap, investisseurs: list });
|
||||
return;
|
||||
}
|
||||
if (!supabase || !user) return;
|
||||
if (row.id) {
|
||||
await supabase
|
||||
.from('investisseurs')
|
||||
.update({
|
||||
display_name: row.display_name,
|
||||
email: row.email,
|
||||
phone: row.phone,
|
||||
min_margin_pct: row.min_margin_pct,
|
||||
max_ticket_eur: row.max_ticket_eur,
|
||||
zones: row.zones,
|
||||
strategies: row.strategies,
|
||||
notes: row.notes,
|
||||
})
|
||||
.eq('id', row.id)
|
||||
.eq('user_id', user.id);
|
||||
} else {
|
||||
await supabase.from('investisseurs').insert({
|
||||
user_id: user.id,
|
||||
display_name: row.display_name,
|
||||
email: row.email,
|
||||
phone: row.phone,
|
||||
min_margin_pct: row.min_margin_pct,
|
||||
max_ticket_eur: row.max_ticket_eur,
|
||||
zones: row.zones,
|
||||
strategies: row.strategies,
|
||||
notes: row.notes,
|
||||
});
|
||||
}
|
||||
await refreshAll();
|
||||
},
|
||||
[runtimeMode, persistLocal, supabase, user, refreshAll],
|
||||
);
|
||||
|
||||
const deleteInvestisseur = useCallback(
|
||||
async (id: string) => {
|
||||
if (runtimeMode === 'local') {
|
||||
const snap = await readLocalDb();
|
||||
await persistLocal({
|
||||
...snap,
|
||||
investisseurs: snap.investisseurs.filter((i) => i.id !== id),
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (!supabase || !user) return;
|
||||
await supabase.from('investisseurs').delete().eq('id', id).eq('user_id', user.id);
|
||||
await refreshAll();
|
||||
},
|
||||
[runtimeMode, persistLocal, supabase, user, refreshAll],
|
||||
);
|
||||
|
||||
const runScoutSampleBatch = useCallback(async () => {
|
||||
const uid = user?.id;
|
||||
if (!uid) {
|
||||
return { error: 'Session requise.' };
|
||||
}
|
||||
if (runtimeMode === 'local') {
|
||||
const snap = await readLocalDb();
|
||||
const newOnes = scoutFilterListings(SCOUT_SAMPLE_JSON, uid);
|
||||
await persistLocal({
|
||||
...snap,
|
||||
deals_sources: [...newOnes, ...(snap.deals_sources ?? [])],
|
||||
});
|
||||
const gradeA = newOnes.filter((x) => x.grade === 'A').length;
|
||||
return { inserted: newOnes.length, gradeA };
|
||||
}
|
||||
if (!supabase) {
|
||||
return { error: 'Supabase non configuré.' };
|
||||
}
|
||||
const { data, error } = await supabase.rpc('scout_process_batch', {
|
||||
p_listings: SCOUT_SAMPLE_JSON,
|
||||
});
|
||||
if (error) {
|
||||
return { error: error.message };
|
||||
}
|
||||
const row = data as { inserted_count?: number; grade_a_count?: number } | null;
|
||||
await refreshAll();
|
||||
return {
|
||||
inserted: row?.inserted_count ?? 0,
|
||||
gradeA: row?.grade_a_count ?? 0,
|
||||
};
|
||||
}, [user?.id, runtimeMode, supabase, persistLocal, refreshAll]);
|
||||
|
||||
const value = useMemo<AppContextValue>(
|
||||
() => ({
|
||||
ready,
|
||||
runtimeMode,
|
||||
user,
|
||||
supabase,
|
||||
dossiers,
|
||||
investisseurs,
|
||||
definitions,
|
||||
findingTick,
|
||||
dealSources,
|
||||
refreshAll,
|
||||
enterLocalMode,
|
||||
saveCloudConfig,
|
||||
signIn,
|
||||
signUp,
|
||||
signOut,
|
||||
createDossier,
|
||||
updateDossier,
|
||||
deleteDossier,
|
||||
setDossierStatus,
|
||||
listFindings,
|
||||
toggleFinding,
|
||||
upsertInvestisseur,
|
||||
deleteInvestisseur,
|
||||
runScoutSampleBatch,
|
||||
}),
|
||||
[
|
||||
ready,
|
||||
runtimeMode,
|
||||
user,
|
||||
supabase,
|
||||
dossiers,
|
||||
investisseurs,
|
||||
definitions,
|
||||
findingTick,
|
||||
dealSources,
|
||||
refreshAll,
|
||||
enterLocalMode,
|
||||
saveCloudConfig,
|
||||
signIn,
|
||||
signUp,
|
||||
signOut,
|
||||
createDossier,
|
||||
updateDossier,
|
||||
deleteDossier,
|
||||
setDossierStatus,
|
||||
listFindings,
|
||||
toggleFinding,
|
||||
upsertInvestisseur,
|
||||
deleteInvestisseur,
|
||||
runScoutSampleBatch,
|
||||
],
|
||||
);
|
||||
|
||||
return <Ctx.Provider value={value}>{children}</Ctx.Provider>;
|
||||
}
|
||||
|
||||
export function useApp(): AppContextValue {
|
||||
const v = useContext(Ctx);
|
||||
if (!v) {
|
||||
throw new Error('useApp doit être utilisé dans AppProvider');
|
||||
}
|
||||
return v;
|
||||
}
|
||||
|
||||
export function useVisitFindings(dossierId: string | undefined) {
|
||||
const app = useApp();
|
||||
const [cloudRows, setCloudRows] = useState<DossierVisitFindingRow[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
if (!dossierId || app.runtimeMode !== 'cloud' || !app.supabase) {
|
||||
setCloudRows([]);
|
||||
return;
|
||||
}
|
||||
const { data } = await app.supabase
|
||||
.from('dossier_visit_findings')
|
||||
.select('*')
|
||||
.eq('dossier_id', dossierId);
|
||||
if (!cancelled) {
|
||||
setCloudRows((data as DossierVisitFindingRow[]) ?? []);
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [dossierId, app.runtimeMode, app.supabase, app.findingTick]);
|
||||
|
||||
if (!dossierId) return [];
|
||||
if (app.runtimeMode === 'local') {
|
||||
return app.listFindings(dossierId);
|
||||
}
|
||||
return cloudRows;
|
||||
}
|
||||
75
src/context/persistence.ts
Normal file
75
src/context/persistence.ts
Normal file
@ -0,0 +1,75 @@
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||
import type { LocalDbSnapshot } from '../data/types';
|
||||
|
||||
const KEY_MODE = 'mdb_mode_v1';
|
||||
const KEY_CONFIG = 'mdb_config_v1';
|
||||
const KEY_LOCAL_DB = 'mdb_local_db_v1';
|
||||
|
||||
export type StoredMode = 'local' | 'cloud' | 'none';
|
||||
|
||||
export interface StoredCloudConfig {
|
||||
supabaseUrl: string;
|
||||
supabaseAnonKey: string;
|
||||
}
|
||||
|
||||
export async function readStoredMode(): Promise<StoredMode> {
|
||||
const v = await AsyncStorage.getItem(KEY_MODE);
|
||||
if (v === 'local' || v === 'cloud') return v;
|
||||
return 'none';
|
||||
}
|
||||
|
||||
export async function writeStoredMode(mode: StoredMode): Promise<void> {
|
||||
if (mode === 'none') {
|
||||
await AsyncStorage.removeItem(KEY_MODE);
|
||||
return;
|
||||
}
|
||||
await AsyncStorage.setItem(KEY_MODE, mode);
|
||||
}
|
||||
|
||||
export async function readCloudConfig(): Promise<StoredCloudConfig | null> {
|
||||
const raw = await AsyncStorage.getItem(KEY_CONFIG);
|
||||
if (!raw) return null;
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as StoredCloudConfig;
|
||||
if (parsed.supabaseUrl && parsed.supabaseAnonKey) return parsed;
|
||||
return null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function writeCloudConfig(cfg: StoredCloudConfig): Promise<void> {
|
||||
await AsyncStorage.setItem(KEY_CONFIG, JSON.stringify(cfg));
|
||||
}
|
||||
|
||||
export async function readLocalDb(): Promise<LocalDbSnapshot> {
|
||||
const raw = await AsyncStorage.getItem(KEY_LOCAL_DB);
|
||||
if (!raw) {
|
||||
return {
|
||||
dossiers: [],
|
||||
dossier_visit_findings: [],
|
||||
investisseurs: [],
|
||||
deals_sources: [],
|
||||
};
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as LocalDbSnapshot;
|
||||
return {
|
||||
dossiers: parsed.dossiers ?? [],
|
||||
dossier_visit_findings: parsed.dossier_visit_findings ?? [],
|
||||
investisseurs: parsed.investisseurs ?? [],
|
||||
deals_sources: parsed.deals_sources ?? [],
|
||||
};
|
||||
} catch {
|
||||
return {
|
||||
dossiers: [],
|
||||
dossier_visit_findings: [],
|
||||
investisseurs: [],
|
||||
deals_sources: [],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export async function writeLocalDb(db: LocalDbSnapshot): Promise<void> {
|
||||
await AsyncStorage.setItem(KEY_LOCAL_DB, JSON.stringify(db));
|
||||
}
|
||||
10
src/core/juge/index.ts
Normal file
10
src/core/juge/index.ts
Normal file
@ -0,0 +1,10 @@
|
||||
export {
|
||||
evaluateDeal,
|
||||
maxPurchaseForTargetNetMarginPct,
|
||||
} from './marginCalculator';
|
||||
export type { JugeInputs, JugeResult } from './marginCalculator';
|
||||
export {
|
||||
DEFAULT_VAT_ON_MARGIN_RATE,
|
||||
DVF_DISCOUNT_FLASH_PCT,
|
||||
MIN_NET_MARGIN_PCT,
|
||||
} from './thresholds';
|
||||
@ -1,16 +1,16 @@
|
||||
import type { DealTrafficLight } from '../../domain/dealSignals';
|
||||
import {
|
||||
DEFAULT_VAT_ON_MARGIN_RATE,
|
||||
DVF_DISCOUNT_FLASH_PCT,
|
||||
MIN_NET_MARGIN_PCT,
|
||||
} from './thresholds';
|
||||
|
||||
export type DealTrafficLight = 'red' | 'orange' | 'green' | 'green_flash_dvf';
|
||||
export type { DealTrafficLight };
|
||||
|
||||
export interface JugeInputs {
|
||||
purchasePrice: number;
|
||||
resalePrice: number;
|
||||
surfaceM2: number;
|
||||
/** Prix m² de référence marché (DVF / étude locale). */
|
||||
dvfReferencePriceM2?: number | null;
|
||||
worksTotal: number;
|
||||
notaryFeeRate: number;
|
||||
@ -20,11 +20,6 @@ export interface JugeInputs {
|
||||
carryingMonths: number;
|
||||
carryingAnnualRate: number;
|
||||
carryingPrincipal?: number | null;
|
||||
/**
|
||||
* TVA sur marge : approximation prudentielle pour outil terrain.
|
||||
* Base imposable = marge économique avant TVA (cash hors TVA collectée/déductible).
|
||||
* Ajustez avec votre expert-comptable selon votre assiette réelle.
|
||||
*/
|
||||
vatOnMarginRate?: number;
|
||||
}
|
||||
|
||||
@ -38,8 +33,9 @@ export interface JugeResult {
|
||||
breakEvenResalePrice: number;
|
||||
purchasePricePerM2: number;
|
||||
dvfDiscountPct: number | null;
|
||||
/** Sous-cotation vs DVF (prix / m² ≤ référence × (1 − 20 %)). */
|
||||
dvfUnderMarketFlash: boolean;
|
||||
trafficLight: DealTrafficLight;
|
||||
/** 0–100 : marge nette normalisée vs seuil + bonus sous-cotation DVF. */
|
||||
scoreDeal: number;
|
||||
}
|
||||
|
||||
@ -51,9 +47,6 @@ function carryingCostEUR(input: JugeInputs): number {
|
||||
return principal * input.carryingAnnualRate * (input.carryingMonths / 12);
|
||||
}
|
||||
|
||||
/**
|
||||
* Le Juge : synthèse financière go / no-go pour marchand de biens.
|
||||
*/
|
||||
export function evaluateDeal(input: JugeInputs): JugeResult {
|
||||
const vatRate = input.vatOnMarginRate ?? DEFAULT_VAT_ON_MARGIN_RATE;
|
||||
|
||||
@ -81,6 +74,7 @@ export function evaluateDeal(input: JugeInputs): JugeResult {
|
||||
input.surfaceM2 > 0 ? input.purchasePrice / input.surfaceM2 : 0;
|
||||
|
||||
let dvfDiscountPct: number | null = null;
|
||||
let dvfUnderMarketFlash = false;
|
||||
if (
|
||||
input.dvfReferencePriceM2 != null &&
|
||||
input.dvfReferencePriceM2 > 0 &&
|
||||
@ -89,13 +83,15 @@ export function evaluateDeal(input: JugeInputs): JugeResult {
|
||||
dvfDiscountPct =
|
||||
(input.dvfReferencePriceM2 - purchasePricePerM2) /
|
||||
input.dvfReferencePriceM2;
|
||||
dvfUnderMarketFlash =
|
||||
purchasePricePerM2 <=
|
||||
input.dvfReferencePriceM2 * (1 - DVF_DISCOUNT_FLASH_PCT);
|
||||
}
|
||||
|
||||
const trafficLight = resolveTrafficLight(
|
||||
netMarginPct,
|
||||
dvfUnderMarketFlash,
|
||||
dvfDiscountPct,
|
||||
purchasePricePerM2,
|
||||
input.dvfReferencePriceM2,
|
||||
);
|
||||
|
||||
const scoreDeal = computeScoreDeal(netMarginPct, dvfDiscountPct);
|
||||
@ -107,35 +103,34 @@ export function evaluateDeal(input: JugeInputs): JugeResult {
|
||||
vatOnMargin,
|
||||
netMarginAfterVat,
|
||||
netMarginPct,
|
||||
breakEvenResalePrice: computeBreakEvenResale(input, vatRate),
|
||||
breakEvenResalePrice: computeBreakEvenResale(input),
|
||||
purchasePricePerM2,
|
||||
dvfDiscountPct,
|
||||
dvfUnderMarketFlash,
|
||||
trafficLight,
|
||||
scoreDeal,
|
||||
};
|
||||
}
|
||||
|
||||
/** Marge : feu rouge sous 15 %. DVF : flash vert indépendant (signal d’achat). */
|
||||
function resolveTrafficLight(
|
||||
netMarginPct: number,
|
||||
dvfUnderMarketFlash: boolean,
|
||||
dvfDiscountPct: number | null,
|
||||
purchasePricePerM2: number,
|
||||
dvfReferencePriceM2?: number | null,
|
||||
): DealTrafficLight {
|
||||
const underDvfFlash =
|
||||
dvfReferencePriceM2 != null &&
|
||||
dvfReferencePriceM2 > 0 &&
|
||||
purchasePricePerM2 <= dvfReferencePriceM2 * (1 - DVF_DISCOUNT_FLASH_PCT);
|
||||
|
||||
if (netMarginPct < MIN_NET_MARGIN_PCT) {
|
||||
return underDvfFlash ? 'green_flash_dvf' : 'red';
|
||||
return 'red';
|
||||
}
|
||||
if (underDvfFlash) {
|
||||
if (dvfUnderMarketFlash) {
|
||||
return 'green_flash_dvf';
|
||||
}
|
||||
if (dvfDiscountPct != null && dvfDiscountPct > 0.1) {
|
||||
return 'green';
|
||||
}
|
||||
return 'orange';
|
||||
if (netMarginPct < 0.18) {
|
||||
return 'orange';
|
||||
}
|
||||
return 'green';
|
||||
}
|
||||
|
||||
function computeScoreDeal(
|
||||
@ -153,7 +148,7 @@ function computeScoreDeal(
|
||||
return Math.round(Math.min(100, marginScore + dvfBonus));
|
||||
}
|
||||
|
||||
function computeBreakEvenResale(input: JugeInputs, vatRate: number): number {
|
||||
function computeBreakEvenResale(input: JugeInputs): number {
|
||||
const notaryFees = input.purchasePrice * input.notaryFeeRate;
|
||||
const carrying = carryingCostEUR(input);
|
||||
const fixedCosts =
|
||||
@ -165,83 +160,25 @@ function computeBreakEvenResale(input: JugeInputs, vatRate: number): number {
|
||||
input.miscSaleCost;
|
||||
|
||||
const agencyRate = input.saleAgencyFeeRate;
|
||||
const effectiveCoeff = 1 - agencyRate - vatRate * (1 - agencyRate);
|
||||
if (effectiveCoeff <= 0) {
|
||||
const coeff = 1 - agencyRate;
|
||||
if (coeff <= 0) {
|
||||
return Number.POSITIVE_INFINITY;
|
||||
}
|
||||
return fixedCosts / effectiveCoeff;
|
||||
return fixedCosts / coeff;
|
||||
}
|
||||
|
||||
/**
|
||||
* Prix d'achat maximum pour respecter une marge nette cible (linéaire sur coûts).
|
||||
* Utile quand la checklist visite augmente les travaux : recalcul offre max.
|
||||
* Prix d’achat max pour tenir une marge nette cible (ex. 15 % après TVA sur marge).
|
||||
* Recalcul instantané quand la checklist visite augmente les travaux.
|
||||
*/
|
||||
export function maxPurchaseForTargetNetMarginPct(
|
||||
input: Omit<JugeInputs, 'purchasePrice'>,
|
||||
targetNetMarginPct: number,
|
||||
): number {
|
||||
const vatRate = input.vatOnMarginRate ?? DEFAULT_VAT_ON_MARGIN_RATE;
|
||||
const carryingPrincipalFallback = 0;
|
||||
|
||||
const agency = input.saleAgencyFeeRate;
|
||||
const netResaleCoeff = 1 - agency;
|
||||
|
||||
const numerator =
|
||||
netResaleCoeff * input.resalePrice -
|
||||
input.miscSaleCost -
|
||||
input.worksTotal -
|
||||
input.miscAcquisitionCost -
|
||||
(1 + targetNetMarginPct + vatRate * (1 + targetNetMarginPct)) *
|
||||
carryingPrincipalFallback;
|
||||
|
||||
const denominator =
|
||||
(1 + input.notaryFeeRate) *
|
||||
(1 +
|
||||
targetNetMarginPct +
|
||||
vatRate * (1 + targetNetMarginPct) +
|
||||
input.carryingAnnualRate * (input.carryingMonths / 12));
|
||||
|
||||
if (denominator <= 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
let maxPurchase = numerator / denominator;
|
||||
|
||||
const refine = (candidate: number): number => {
|
||||
const trial: JugeInputs = {
|
||||
...input,
|
||||
purchasePrice: candidate,
|
||||
carryingPrincipal: input.carryingPrincipal ?? candidate,
|
||||
};
|
||||
const { netMarginPct } = evaluateDeal(trial);
|
||||
return netMarginPct - targetNetMarginPct;
|
||||
};
|
||||
|
||||
maxPurchase = binarySearchPurchase(input, targetNetMarginPct, vatRate);
|
||||
|
||||
void refine;
|
||||
return Math.max(0, Math.round(maxPurchase));
|
||||
}
|
||||
|
||||
/**
|
||||
* Recherche dichotomique robuste (le lien marge ↔ prix d'achat est affine par morceaux
|
||||
* mais les arrondis / TVA max(0,·) peuvent créer des irrégularités légères).
|
||||
*/
|
||||
export function maxPurchaseForTargetNetMarginPctRobust(
|
||||
input: Omit<JugeInputs, 'purchasePrice'>,
|
||||
targetNetMarginPct: number,
|
||||
): number {
|
||||
return binarySearchPurchase(input, targetNetMarginPct);
|
||||
}
|
||||
|
||||
function binarySearchPurchase(
|
||||
input: Omit<JugeInputs, 'purchasePrice'>,
|
||||
targetNetMarginPct: number,
|
||||
vatRate: number = input.vatOnMarginRate ?? DEFAULT_VAT_ON_MARGIN_RATE,
|
||||
): number {
|
||||
let low = 0;
|
||||
let high = input.resalePrice * 1.5;
|
||||
for (let i = 0; i < 48; i++) {
|
||||
let high = Math.max(input.resalePrice * 1.5, 1);
|
||||
for (let i = 0; i < 56; i++) {
|
||||
const mid = (low + high) / 2;
|
||||
const { netMarginPct } = evaluateDeal({
|
||||
...input,
|
||||
|
||||
91
src/data/dealSource.ts
Normal file
91
src/data/dealSource.ts
Normal file
@ -0,0 +1,91 @@
|
||||
import { randomUuid } from '../lib/uuid';
|
||||
import type { DealSourceRow } from './types';
|
||||
|
||||
export const SCOUT_SIMULATED_DVF_AVG_M2 = 3500;
|
||||
|
||||
const KEYWORDS = ['succession', 'urgent', 'travaux important'] as const;
|
||||
|
||||
export interface RawListingInput {
|
||||
title?: string;
|
||||
description?: string;
|
||||
price_eur?: number;
|
||||
surface_m2?: number;
|
||||
url?: string;
|
||||
source?: string;
|
||||
}
|
||||
|
||||
export function scoutFilterListings(
|
||||
listings: RawListingInput[],
|
||||
userId: string,
|
||||
): DealSourceRow[] {
|
||||
const out: DealSourceRow[] = [];
|
||||
const now = new Date().toISOString();
|
||||
const avg = SCOUT_SIMULATED_DVF_AVG_M2;
|
||||
|
||||
for (const el of listings) {
|
||||
const price = el.price_eur;
|
||||
const surf = el.surface_m2;
|
||||
if (price == null || surf == null || surf <= 0) continue;
|
||||
const txt = `${el.description ?? ''} ${el.title ?? ''}`.toLowerCase();
|
||||
const matched: string[] = [];
|
||||
for (const k of KEYWORDS) {
|
||||
if (txt.includes(k)) matched.push(k);
|
||||
}
|
||||
const okKw = matched.length > 0;
|
||||
const pm2 = price / surf;
|
||||
const okPm2 = pm2 < avg;
|
||||
if (!okKw || !okPm2) continue;
|
||||
|
||||
const score =
|
||||
40 +
|
||||
Math.max(0, ((avg - pm2) / avg) * 50) +
|
||||
matched.length * 10;
|
||||
const grade = score >= 80 ? 'A' : score >= 55 ? 'B' : 'C';
|
||||
|
||||
out.push({
|
||||
id: randomUuid(),
|
||||
user_id: userId,
|
||||
title: (el.title?.trim() || 'Sans titre').slice(0, 500),
|
||||
description: el.description ?? null,
|
||||
source_url: el.url?.trim() || null,
|
||||
source_name: el.source?.trim() || null,
|
||||
price_eur: price,
|
||||
surface_m2: surf,
|
||||
price_per_m2_eur: pm2,
|
||||
dvf_avg_m2_simulated: avg,
|
||||
distress_keywords: matched,
|
||||
opportunity_score: Math.round(score * 100) / 100,
|
||||
grade,
|
||||
raw_payload: el as unknown as Record<string, unknown>,
|
||||
created_at: now,
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export const SCOUT_SAMPLE_JSON: RawListingInput[] = [
|
||||
{
|
||||
title: 'Maison succession à rénover',
|
||||
description: 'Urgent vente succession, travaux importants à prévoir',
|
||||
price_eur: 198000,
|
||||
surface_m2: 95,
|
||||
url: 'https://example.com/a1',
|
||||
source: 'simulation',
|
||||
},
|
||||
{
|
||||
title: 'Appartement centre',
|
||||
description: 'Bel appartement rénové',
|
||||
price_eur: 320000,
|
||||
surface_m2: 65,
|
||||
url: 'https://example.com/a2',
|
||||
source: 'simulation',
|
||||
},
|
||||
{
|
||||
title: 'Longère DPE G urgent',
|
||||
description: 'Succession — urgent — gros travaux importants',
|
||||
price_eur: 142000,
|
||||
surface_m2: 120,
|
||||
url: 'https://example.com/a3',
|
||||
source: 'simulation',
|
||||
},
|
||||
];
|
||||
54
src/data/defaults.ts
Normal file
54
src/data/defaults.ts
Normal file
@ -0,0 +1,54 @@
|
||||
import { randomUuid } from '../lib/uuid';
|
||||
import type { DossierRow, DossierVisitFindingRow } from './types';
|
||||
import { VISIT_FINDING_SEED } from './visitWorks';
|
||||
|
||||
export const LOCAL_USER_ID = '11111111-1111-1111-1111-111111111111';
|
||||
|
||||
export function newDossierTemplate(userId: string): DossierRow {
|
||||
const now = new Date().toISOString();
|
||||
return {
|
||||
id: randomUuid(),
|
||||
user_id: userId,
|
||||
title: 'Nouveau dossier',
|
||||
status: 'draft',
|
||||
address_line: null,
|
||||
city: null,
|
||||
postal_code: null,
|
||||
insee_code: null,
|
||||
surface_m2: 90,
|
||||
land_surface_m2: null,
|
||||
rooms_count: null,
|
||||
dpe_class: null,
|
||||
purchase_price_target: 150_000,
|
||||
resale_price_estimate: 240_000,
|
||||
dvf_reference_price_m2: 2800,
|
||||
works_estimate_total: 25_000,
|
||||
works_visit_adjustment: 0,
|
||||
notary_fee_rate: 0.077,
|
||||
sale_agency_fee_rate: 0.05,
|
||||
misc_acquisition_cost: 2000,
|
||||
misc_sale_cost: 1500,
|
||||
carrying_months: 6,
|
||||
carrying_annual_rate: 0.055,
|
||||
carrying_principal: null,
|
||||
plu_zone_code: null,
|
||||
plu_notes: null,
|
||||
parcel_subdivision_candidate: false,
|
||||
deficit_foncier_candidate: false,
|
||||
under_promise_at: null,
|
||||
teaser_pdf_url: null,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
};
|
||||
}
|
||||
|
||||
export function seedVisitRowsForDossier(dossierId: string): DossierVisitFindingRow[] {
|
||||
return VISIT_FINDING_SEED.map((d) => ({
|
||||
id: randomUuid(),
|
||||
dossier_id: dossierId,
|
||||
finding_code: d.code,
|
||||
checked: false,
|
||||
works_delta_override_eur: null,
|
||||
checked_at: null,
|
||||
}));
|
||||
}
|
||||
104
src/data/types.ts
Normal file
104
src/data/types.ts
Normal file
@ -0,0 +1,104 @@
|
||||
export type DossierStatusDb =
|
||||
| 'draft'
|
||||
| 'sourcing'
|
||||
| 'analysis'
|
||||
| 'visit'
|
||||
| 'offer'
|
||||
| 'under_promise'
|
||||
| 'resale'
|
||||
| 'closed_won'
|
||||
| 'closed_lost';
|
||||
|
||||
export interface VisitFindingDefinitionRow {
|
||||
code: string;
|
||||
label: string;
|
||||
default_works_delta_eur: number;
|
||||
severity: number;
|
||||
sort_order: number;
|
||||
}
|
||||
|
||||
export interface DossierVisitFindingRow {
|
||||
id: string;
|
||||
dossier_id: string;
|
||||
finding_code: string;
|
||||
checked: boolean;
|
||||
works_delta_override_eur: number | null;
|
||||
checked_at: string | null;
|
||||
}
|
||||
|
||||
export interface DossierRow {
|
||||
id: string;
|
||||
user_id: string;
|
||||
title: string;
|
||||
status: DossierStatusDb;
|
||||
address_line: string | null;
|
||||
city: string | null;
|
||||
postal_code: string | null;
|
||||
insee_code: string | null;
|
||||
surface_m2: number | null;
|
||||
land_surface_m2: number | null;
|
||||
rooms_count: number | null;
|
||||
dpe_class: string | null;
|
||||
purchase_price_target: number | null;
|
||||
resale_price_estimate: number | null;
|
||||
dvf_reference_price_m2: number | null;
|
||||
works_estimate_total: number | null;
|
||||
works_visit_adjustment: number | null;
|
||||
notary_fee_rate: number | null;
|
||||
sale_agency_fee_rate: number | null;
|
||||
misc_acquisition_cost: number | null;
|
||||
misc_sale_cost: number | null;
|
||||
carrying_months: number | null;
|
||||
carrying_annual_rate: number | null;
|
||||
carrying_principal: number | null;
|
||||
plu_zone_code: string | null;
|
||||
plu_notes: string | null;
|
||||
parcel_subdivision_candidate: boolean;
|
||||
deficit_foncier_candidate: boolean;
|
||||
under_promise_at: string | null;
|
||||
teaser_pdf_url: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface InvestisseurRow {
|
||||
id: string;
|
||||
user_id: string;
|
||||
display_name: string;
|
||||
email: string | null;
|
||||
phone: string | null;
|
||||
min_margin_pct: number;
|
||||
max_ticket_eur: number | null;
|
||||
zones: string[] | null;
|
||||
strategies: string[] | null;
|
||||
notes: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export type DealGrade = 'A' | 'B' | 'C';
|
||||
|
||||
export interface DealSourceRow {
|
||||
id: string;
|
||||
user_id: string;
|
||||
title: string;
|
||||
description: string | null;
|
||||
source_url: string | null;
|
||||
source_name: string | null;
|
||||
price_eur: number | null;
|
||||
surface_m2: number;
|
||||
price_per_m2_eur: number;
|
||||
dvf_avg_m2_simulated: number;
|
||||
distress_keywords: string[];
|
||||
opportunity_score: number;
|
||||
grade: DealGrade;
|
||||
raw_payload?: Record<string, unknown> | null;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface LocalDbSnapshot {
|
||||
dossiers: DossierRow[];
|
||||
dossier_visit_findings: DossierVisitFindingRow[];
|
||||
investisseurs: InvestisseurRow[];
|
||||
deals_sources: DealSourceRow[];
|
||||
}
|
||||
95
src/data/visitWorks.ts
Normal file
95
src/data/visitWorks.ts
Normal file
@ -0,0 +1,95 @@
|
||||
import type {
|
||||
DossierVisitFindingRow,
|
||||
VisitFindingDefinitionRow,
|
||||
} from './types';
|
||||
|
||||
export const VISIT_FINDING_SEED: VisitFindingDefinitionRow[] = [
|
||||
{
|
||||
code: 'structural_crack',
|
||||
label: 'Fissure structurelle / désordre porteur',
|
||||
default_works_delta_eur: 15000,
|
||||
severity: 5,
|
||||
sort_order: 10,
|
||||
},
|
||||
{
|
||||
code: 'roof_full_replace',
|
||||
label: 'Toiture à refaire (complète)',
|
||||
default_works_delta_eur: 35000,
|
||||
severity: 5,
|
||||
sort_order: 20,
|
||||
},
|
||||
{
|
||||
code: 'roof_partial',
|
||||
label: 'Toiture partielle / zinguerie lourde',
|
||||
default_works_delta_eur: 8000,
|
||||
severity: 3,
|
||||
sort_order: 30,
|
||||
},
|
||||
{
|
||||
code: 'humidity_basement',
|
||||
label: 'Infiltrations cave / vide sanitaire',
|
||||
default_works_delta_eur: 12000,
|
||||
severity: 3,
|
||||
sort_order: 40,
|
||||
},
|
||||
{
|
||||
code: 'electrical_rewire',
|
||||
label: 'Rénovation électrique complète',
|
||||
default_works_delta_eur: 15000,
|
||||
severity: 4,
|
||||
sort_order: 50,
|
||||
},
|
||||
{
|
||||
code: 'asbestos',
|
||||
label: 'Présence amiante / désamiantage à prévoir',
|
||||
default_works_delta_eur: 10000,
|
||||
severity: 4,
|
||||
sort_order: 60,
|
||||
},
|
||||
{
|
||||
code: 'septic_non_conform',
|
||||
label: 'Assainissement non conforme',
|
||||
default_works_delta_eur: 12000,
|
||||
severity: 3,
|
||||
sort_order: 70,
|
||||
},
|
||||
{
|
||||
code: 'facade_insulation',
|
||||
label: 'ITE / ravalement lourd',
|
||||
default_works_delta_eur: 25000,
|
||||
severity: 3,
|
||||
sort_order: 80,
|
||||
},
|
||||
{
|
||||
code: 'heat_pump_full',
|
||||
label: 'Chauffage à refaire (pompe à chaleur + réseau)',
|
||||
default_works_delta_eur: 18000,
|
||||
severity: 2,
|
||||
sort_order: 90,
|
||||
},
|
||||
];
|
||||
|
||||
export function definitionMap(
|
||||
defs: VisitFindingDefinitionRow[],
|
||||
): Map<string, VisitFindingDefinitionRow> {
|
||||
return new Map(defs.map((d) => [d.code, d]));
|
||||
}
|
||||
|
||||
export function sumCheckedVisitWorksEUR(
|
||||
findings: DossierVisitFindingRow[],
|
||||
defs: VisitFindingDefinitionRow[],
|
||||
): number {
|
||||
const map = definitionMap(defs);
|
||||
let sum = 0;
|
||||
for (const f of findings) {
|
||||
if (!f.checked) continue;
|
||||
const def = map.get(f.finding_code);
|
||||
if (!def) continue;
|
||||
const delta =
|
||||
f.works_delta_override_eur != null
|
||||
? f.works_delta_override_eur
|
||||
: def.default_works_delta_eur;
|
||||
sum += delta;
|
||||
}
|
||||
return sum;
|
||||
}
|
||||
@ -1,5 +1,7 @@
|
||||
import type { DealTrafficLight } from './dealSignals';
|
||||
|
||||
export type { DealTrafficLight } from './dealSignals';
|
||||
|
||||
/** Statuts alignés sur `public.dossier_status` (Supabase). */
|
||||
export type DossierStatus =
|
||||
| 'draft'
|
||||
@ -36,5 +38,3 @@ export interface DossierDealSnapshot {
|
||||
dvfDiscountPct: number | null;
|
||||
scoreDeal: number;
|
||||
}
|
||||
|
||||
export type { DealTrafficLight };
|
||||
|
||||
44
src/domain/mapDossierToJuge.ts
Normal file
44
src/domain/mapDossierToJuge.ts
Normal file
@ -0,0 +1,44 @@
|
||||
import type { DossierFinancialInputs } from './dossier';
|
||||
import type { DossierRow } from '../data/types';
|
||||
import type { JugeInputs } from '../core/juge/marginCalculator';
|
||||
|
||||
export function dossierFinancialToJugeInput(
|
||||
row: DossierFinancialInputs,
|
||||
checklistWorksExtraEur = 0,
|
||||
): JugeInputs {
|
||||
return {
|
||||
purchasePrice: row.purchasePriceTarget,
|
||||
resalePrice: row.resalePriceEstimate,
|
||||
surfaceM2: row.surfaceM2,
|
||||
dvfReferencePriceM2: row.dvfReferencePriceM2 ?? undefined,
|
||||
worksTotal:
|
||||
row.worksEstimateTotal +
|
||||
row.worksVisitAdjustment +
|
||||
checklistWorksExtraEur,
|
||||
notaryFeeRate: row.notaryFeeRate,
|
||||
saleAgencyFeeRate: row.saleAgencyFeeRate,
|
||||
miscAcquisitionCost: row.miscAcquisitionCost,
|
||||
miscSaleCost: row.miscSaleCost,
|
||||
carryingMonths: row.carryingMonths,
|
||||
carryingAnnualRate: row.carryingAnnualRate,
|
||||
carryingPrincipal: row.carryingPrincipal ?? row.purchasePriceTarget,
|
||||
};
|
||||
}
|
||||
|
||||
export function dossierRowToFinancialInputs(row: DossierRow): DossierFinancialInputs {
|
||||
return {
|
||||
purchasePriceTarget: row.purchase_price_target ?? 0,
|
||||
resalePriceEstimate: row.resale_price_estimate ?? 0,
|
||||
surfaceM2: row.surface_m2 ?? 0,
|
||||
dvfReferencePriceM2: row.dvf_reference_price_m2,
|
||||
worksEstimateTotal: row.works_estimate_total ?? 0,
|
||||
worksVisitAdjustment: row.works_visit_adjustment ?? 0,
|
||||
notaryFeeRate: row.notary_fee_rate ?? 0.077,
|
||||
saleAgencyFeeRate: row.sale_agency_fee_rate ?? 0.05,
|
||||
miscAcquisitionCost: row.misc_acquisition_cost ?? 0,
|
||||
miscSaleCost: row.misc_sale_cost ?? 0,
|
||||
carryingMonths: row.carrying_months ?? 6,
|
||||
carryingAnnualRate: row.carrying_annual_rate ?? 0.05,
|
||||
carryingPrincipal: row.carrying_principal,
|
||||
};
|
||||
}
|
||||
74
src/hooks/useDealsSourcesGradeAAlerts.ts
Normal file
74
src/hooks/useDealsSourcesGradeAAlerts.ts
Normal file
@ -0,0 +1,74 @@
|
||||
import type { SupabaseClient } from '@supabase/supabase-js';
|
||||
import * as Notifications from 'expo-notifications';
|
||||
import { useEffect } from 'react';
|
||||
import type { DealSourceRow } from '../data/types';
|
||||
|
||||
Notifications.setNotificationHandler({
|
||||
handleNotification: async () => ({
|
||||
shouldShowAlert: true,
|
||||
shouldPlaySound: false,
|
||||
shouldSetBadge: false,
|
||||
shouldShowBanner: true,
|
||||
shouldShowList: true,
|
||||
}),
|
||||
});
|
||||
|
||||
export async function ensureNotificationPermission(): Promise<boolean> {
|
||||
const { status: existing } = await Notifications.getPermissionsAsync();
|
||||
if (existing === 'granted') return true;
|
||||
const { status } = await Notifications.requestPermissionsAsync();
|
||||
return status === 'granted';
|
||||
}
|
||||
|
||||
export function notifyGradeADealLocal(title: string): void {
|
||||
void Notifications.scheduleNotificationAsync({
|
||||
content: {
|
||||
title: 'Opportunité Grade A',
|
||||
body: title.slice(0, 160),
|
||||
},
|
||||
trigger: null,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Abonnement Realtime : alerte locale quand une ligne Grade A est insérée (Supabase).
|
||||
* Activer Realtime sur `deals_sources` dans le dashboard Supabase.
|
||||
*/
|
||||
export function useDealsSourcesGradeAAlerts(
|
||||
supabase: SupabaseClient | null,
|
||||
userId: string | undefined,
|
||||
enabled: boolean,
|
||||
): void {
|
||||
useEffect(() => {
|
||||
if (!enabled || !supabase || !userId) return;
|
||||
|
||||
const channel = supabase
|
||||
.channel(`realtime:deals_sources:${userId}`)
|
||||
.on(
|
||||
'postgres_changes',
|
||||
{
|
||||
event: 'INSERT',
|
||||
schema: 'public',
|
||||
table: 'deals_sources',
|
||||
filter: `user_id=eq.${userId}`,
|
||||
},
|
||||
(payload) => {
|
||||
const row = payload.new as DealSourceRow;
|
||||
if (row?.grade === 'A') {
|
||||
void Notifications.scheduleNotificationAsync({
|
||||
content: {
|
||||
title: 'Nouvelle opportunité Grade A',
|
||||
body: (row.title ?? 'Deal').slice(0, 160),
|
||||
},
|
||||
trigger: null,
|
||||
});
|
||||
}
|
||||
},
|
||||
)
|
||||
.subscribe();
|
||||
|
||||
return () => {
|
||||
void supabase.removeChannel(channel);
|
||||
};
|
||||
}, [supabase, userId, enabled]);
|
||||
}
|
||||
39
src/hooks/useDossierJuge.ts
Normal file
39
src/hooks/useDossierJuge.ts
Normal file
@ -0,0 +1,39 @@
|
||||
import { useMemo } from 'react';
|
||||
import type { DossierRow, DossierVisitFindingRow, VisitFindingDefinitionRow } from '../data/types';
|
||||
import { sumCheckedVisitWorksEUR } from '../data/visitWorks';
|
||||
import { evaluateDeal, maxPurchaseForTargetNetMarginPct, MIN_NET_MARGIN_PCT } from '../core/juge';
|
||||
import {
|
||||
dossierFinancialToJugeInput,
|
||||
dossierRowToFinancialInputs,
|
||||
} from '../domain/mapDossierToJuge';
|
||||
|
||||
export function useDossierJuge(
|
||||
dossier: DossierRow | undefined,
|
||||
findings: DossierVisitFindingRow[],
|
||||
definitions: VisitFindingDefinitionRow[],
|
||||
) {
|
||||
return useMemo(() => {
|
||||
if (!dossier) return null;
|
||||
const checklist = sumCheckedVisitWorksEUR(findings, definitions);
|
||||
const fin = dossierRowToFinancialInputs(dossier);
|
||||
const jugeInput = dossierFinancialToJugeInput(fin, checklist);
|
||||
const result = evaluateDeal(jugeInput);
|
||||
const maxPurchase = maxPurchaseForTargetNetMarginPct(
|
||||
{
|
||||
resalePrice: jugeInput.resalePrice,
|
||||
surfaceM2: jugeInput.surfaceM2,
|
||||
dvfReferencePriceM2: jugeInput.dvfReferencePriceM2,
|
||||
worksTotal: jugeInput.worksTotal,
|
||||
notaryFeeRate: jugeInput.notaryFeeRate,
|
||||
saleAgencyFeeRate: jugeInput.saleAgencyFeeRate,
|
||||
miscAcquisitionCost: jugeInput.miscAcquisitionCost,
|
||||
miscSaleCost: jugeInput.miscSaleCost,
|
||||
carryingMonths: jugeInput.carryingMonths,
|
||||
carryingAnnualRate: jugeInput.carryingAnnualRate,
|
||||
carryingPrincipal: jugeInput.carryingPrincipal,
|
||||
},
|
||||
MIN_NET_MARGIN_PCT,
|
||||
);
|
||||
return { result, maxPurchase, checklistWorks: checklist };
|
||||
}, [dossier, findings, definitions]);
|
||||
}
|
||||
14
src/lib/supabaseFactory.ts
Normal file
14
src/lib/supabaseFactory.ts
Normal file
@ -0,0 +1,14 @@
|
||||
import 'react-native-url-polyfill/auto';
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||
import { createClient, type SupabaseClient } from '@supabase/supabase-js';
|
||||
|
||||
export function makeSupabase(url: string, anonKey: string): SupabaseClient {
|
||||
return createClient(url, anonKey, {
|
||||
auth: {
|
||||
storage: AsyncStorage,
|
||||
autoRefreshToken: true,
|
||||
persistSession: true,
|
||||
detectSessionInUrl: false,
|
||||
},
|
||||
});
|
||||
}
|
||||
7
src/lib/uuid.ts
Normal file
7
src/lib/uuid.ts
Normal file
@ -0,0 +1,7 @@
|
||||
export function randomUuid(): string {
|
||||
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
|
||||
const r = (Math.random() * 16) | 0;
|
||||
const v = c === 'x' ? r : (r & 0x3) | 0x8;
|
||||
return v.toString(16);
|
||||
});
|
||||
}
|
||||
37
src/services/matchInvestors.ts
Normal file
37
src/services/matchInvestors.ts
Normal file
@ -0,0 +1,37 @@
|
||||
import type { DossierRow, InvestisseurRow } from '../data/types';
|
||||
import type { JugeResult } from '../core/juge';
|
||||
|
||||
export function matchInvestisseurs(
|
||||
dossier: DossierRow,
|
||||
juge: JugeResult,
|
||||
list: InvestisseurRow[],
|
||||
limit = 5,
|
||||
): InvestisseurRow[] {
|
||||
const purchase = dossier.purchase_price_target ?? 0;
|
||||
const marginPct100 = juge.netMarginPct * 100;
|
||||
const city = (dossier.city ?? '').toLowerCase().trim();
|
||||
|
||||
return list
|
||||
.filter((inv) => {
|
||||
if (marginPct100 + 1e-6 < inv.min_margin_pct) {
|
||||
return false;
|
||||
}
|
||||
if (
|
||||
inv.max_ticket_eur != null &&
|
||||
purchase > 0 &&
|
||||
purchase > inv.max_ticket_eur
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
const zones = inv.zones;
|
||||
if (zones && zones.length > 0 && city) {
|
||||
const z = zones.map((x) => x.toLowerCase().trim());
|
||||
const ok = z.some(
|
||||
(zone) => city.includes(zone) || zone.includes(city),
|
||||
);
|
||||
if (!ok) return false;
|
||||
}
|
||||
return true;
|
||||
})
|
||||
.slice(0, limit);
|
||||
}
|
||||
81
src/services/teaserPdf.ts
Normal file
81
src/services/teaserPdf.ts
Normal file
@ -0,0 +1,81 @@
|
||||
import type { DossierRow } from '../data/types';
|
||||
import type { JugeResult } from '../core/juge';
|
||||
import * as Print from 'expo-print';
|
||||
import * as Sharing from 'expo-sharing';
|
||||
import { Platform } from 'react-native';
|
||||
|
||||
function escapeHtml(s: string): string {
|
||||
return s
|
||||
.replaceAll('&', '&')
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>')
|
||||
.replaceAll('"', '"');
|
||||
}
|
||||
|
||||
export function buildTeaserHtml(
|
||||
dossier: DossierRow,
|
||||
juge: JugeResult,
|
||||
investisseurs: string[],
|
||||
): string {
|
||||
const title = escapeHtml(dossier.title);
|
||||
const addr = escapeHtml(
|
||||
[dossier.address_line, dossier.postal_code, dossier.city]
|
||||
.filter(Boolean)
|
||||
.join(', ') || 'Adresse à compléter',
|
||||
);
|
||||
const bullets = investisseurs
|
||||
.map((n) => `<li>${escapeHtml(n)}</li>`)
|
||||
.join('');
|
||||
|
||||
return `<!DOCTYPE html>
|
||||
<html lang="fr">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<style>
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; color: #0b1220; padding: 32px; }
|
||||
h1 { font-size: 26px; margin-bottom: 8px; }
|
||||
.tag { display: inline-block; background: #0b1220; color: #fff; padding: 6px 12px; border-radius: 999px; font-size: 12px; letter-spacing: 0.04em; }
|
||||
.grid { display: flex; flex-wrap: wrap; gap: 16px; margin-top: 24px; }
|
||||
.card { flex: 1 1 40%; border: 1px solid #dce6ff; border-radius: 12px; padding: 16px; background: #f7f9ff; }
|
||||
.k { font-size: 11px; text-transform: uppercase; color: #5c6b8a; letter-spacing: 0.06em; }
|
||||
.v { font-size: 20px; font-weight: 700; margin-top: 4px; }
|
||||
ul { margin-top: 12px; }
|
||||
.footer { margin-top: 40px; font-size: 11px; color: #5c6b8a; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="tag">MDB-Turbo — Teaser investisseur</div>
|
||||
<h1>${title}</h1>
|
||||
<p>${addr}</p>
|
||||
<div class="grid">
|
||||
<div class="card"><div class="k">Marge nette (estim.)</div><div class="v">${(juge.netMarginPct * 100).toFixed(1)} %</div></div>
|
||||
<div class="card"><div class="k">Score deal</div><div class="v">${juge.scoreDeal} / 100</div></div>
|
||||
<div class="card"><div class="k">Prix / m² (achat cible)</div><div class="v">${Math.round(juge.purchasePricePerM2).toLocaleString('fr-FR')} €</div></div>
|
||||
<div class="card"><div class="k">Break-even revente</div><div class="v">${Number.isFinite(juge.breakEvenResalePrice) ? `${Math.round(juge.breakEvenResalePrice).toLocaleString('fr-FR')} €` : '—'}</div></div>
|
||||
</div>
|
||||
<h2 style="margin-top:32px;font-size:18px;">Investisseurs ciblés (matching critères)</h2>
|
||||
<ul>${bullets || '<li>(aucun match — complétez la base investisseurs)</li>'}</ul>
|
||||
<p class="footer">Document généré par MDB-Turbo — chiffres indicatifs, non contractuels. Validez fiscalité / juridique avec vos conseils.</p>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
export async function shareTeaserPdf(
|
||||
dossier: DossierRow,
|
||||
juge: JugeResult,
|
||||
investisseurNames: string[],
|
||||
): Promise<void> {
|
||||
const html = buildTeaserHtml(dossier, juge, investisseurNames);
|
||||
const { uri } = await Print.printToFileAsync({ html });
|
||||
if (Platform.OS === 'web') {
|
||||
return;
|
||||
}
|
||||
const can = await Sharing.isAvailableAsync();
|
||||
if (!can) {
|
||||
return;
|
||||
}
|
||||
await Sharing.shareAsync(uri, {
|
||||
mimeType: 'application/pdf',
|
||||
dialogTitle: 'Teaser investisseur',
|
||||
});
|
||||
}
|
||||
12
src/theme/colors.ts
Normal file
12
src/theme/colors.ts
Normal file
@ -0,0 +1,12 @@
|
||||
export const colors = {
|
||||
bg: '#0b1220',
|
||||
bgCard: '#121c2f',
|
||||
border: '#243352',
|
||||
text: '#f4f7ff',
|
||||
textMuted: '#9fb0d0',
|
||||
accent: '#3d8bfd',
|
||||
success: '#3fb950',
|
||||
warning: '#d29922',
|
||||
danger: '#f85149',
|
||||
flash: '#7ee787',
|
||||
};
|
||||
Reference in New Issue
Block a user