// ----------------------------------------------------------------------------- // Lou-Ka — Agrégateur de logements à louer (province de Québec) // Auteur : Simon-Pierre Boucher — contact@spboucher.ai // fiche/BuildingDossier.tsx : « Dossier de l'immeuble » — toutes les données // d'intelligence locative en accordéons compacts (aucune n'est supprimée) : // historique Lou-Ka + vies antérieures, passeport de l'immeuble, gestionnaire // (réputation Google analysée), historique au TAL (SOQUIJ), vie en hiver. // Chaque volet affiche un état vide propre quand la donnée manque. // ----------------------------------------------------------------------------- import { Gestionnaire, Hiver, HistoriqueLouka, Immeuble, Recyclees, TalHistory, fmtPrice, sourceName, } from "../api"; import { IcoBuilding, IcoShield, IcoSnow, IcoUser } from "../components/Icons"; import { Accordion, SectionCard, SkeletonLines, StatTile, StatusBadge, Tone, NBSP } from "./ui"; import { Res } from "./useFicheData"; const NBSP_ = NBSP; const fmtTs = (ts: number | null | undefined, m: "short" | "long" = "short") => ts ? new Date(ts * 1000).toLocaleDateString("fr-CA", m === "short" ? { day: "numeric", month: "short", year: "numeric" } : { month: "long", year: "numeric" }) : "—"; const EVENT: Record = { description: "Description modifiée", superficie: "Superficie modifiée", dispo: "Disponibilité modifiée", inclusions: "Inclusions modifiées", photos: "Photos modifiées", disparition: "Annonce retirée", reapparition: "Annonce republiée", }; function Vide({ children }: { children: React.ReactNode }) { return

{children}

; } function Historique({ r, rec }: { r: Res; rec: Res }) { if (r.status === "loading" || r.status === "idle") return ; const d = r.status === "ok" ? r.data : null; if (!d) return Historique indisponible pour cette annonce.; const items = d.timeline.slice(0, 12); const matches = rec.status === "ok" ? rec.data.matches : []; const var_ = d.variation != null ? `${d.variation > 0 ? "+" : "−"}${Math.abs(Math.round(d.variation * 100))}${NBSP_}%` : null; return ( <>
{d.prix_initial != null && d.prix_actuel != null && d.prix_initial !== d.prix_actuel && (

Prix initial {fmtPrice(d.prix_initial)} → {fmtPrice(d.prix_actuel)}{var_ ? ` (${var_})` : ""}

)} {items.length > 0 ? (
    {items.map((it, i) => (
  • {fmtTs(it.ts)} {it.type === "prix" ? (it.prix_avant != null ? <>Prix {it.prix_avant > (it.prix ?? 0) ? "baissé" : "monté"} de {fmtPrice(it.prix_avant)} à {fmtPrice(it.prix ?? null)} : <>Premier prix observé : {fmtPrice(it.prix ?? null)}) : (EVENT[it.type] ?? it.type)}
  • ))}
) : Aucune modification observée depuis la première synchronisation.} {matches.length > 0 && (
Vies antérieures probables de ce logement {matches.slice(0, 3).map((m) => (
republication probable · {m.confiance}{NBSP_}%{" "} {m.prix != null && <>affiché {fmtPrice(m.prix)}}{m.derniere_observation != null && <> jusqu'en {fmtTs(m.derniere_observation, "long")}}
{m.signaux.join(" · ")}
))}

{rec.status === "ok" ? rec.data.methode : ""} — inférence, sans fusion automatique.

)}

{d.methode}

); } function Passeport({ im }: { im: Immeuble | null }) { if (!im || im.annonces_total < 2) return Une seule annonce observée pour cet immeuble : pas encore de passeport (unités, rotation, pression sur les loyers).; const rot = im.rotation, pres = im.pression_loyers; const ROT: Record = { faible: "good", normale: "neutral", "élevée": "warn", "très élevée": "bad" }; return ( <>
1 ? "s" : ""}`} anim={false} /> {im.loyer_median != null && } {im.pi2_median != null && }
{im.loyer_median_par_cc && Object.keys(im.loyer_median_par_cc).length > 0 && (

Par nombre de chambres : {Object.entries(im.loyer_median_par_cc).map(([cc, v]) => `${cc}${NBSP_}ch. ${fmtPrice(v)}`).join(" · ")}

)}
Rotation {rot.statut === "calculated" ? <>{rot.classe}{rot.annonces_12m} annonce{(rot.annonces_12m ?? 0) > 1 ? "s" : ""} / 12 mois pour ≥{NBSP_}{rot.unites_estimees} unités : données insuffisantes}
Pression sur les loyers {pres ? <> 0.05 ? "warn" : pres.variation_12m < -0.02 ? "good" : "neutral"}>{pres.variation_12m > 0 ? "+" : "−"}{Math.abs(Math.round(pres.variation_12m * 100))}{NBSP_}% sur 12 mois médiane d'entrée {fmtPrice(pres.mediane_12_24m)} → {fmtPrice(pres.mediane_12m)} : échantillon insuffisant}

Immeuble suivi par Lou-Ka depuis {fmtTs(im.premiere_observation, "long")} — {rot.statut === "calculated" && rot.methode ? rot.methode : "indicateurs calculés uniquement sur les annonces observées par les synchronisations Lou-Ka (pas un recensement du bâtiment)."}

); } function GestionnaireVue({ r, source }: { r: Res; source: string }) { if (r.status === "loading" || r.status === "idle") return ; const d = r.status === "ok" ? r.data : null; if (!d) return Gestionnaire : {sourceName(source)}. Aucune fiche de réputation associée.; const g = d.google_maps, avis = d.avis, dist = avis?.distribution; const total = dist ? Object.values(dist).reduce((a, b) => a + b, 0) : 0; const recents = (d.avis_recents ?? []).filter((a) => a.texte).slice(0, 3); const SENT: Record = { "négatif": "bad", neutre: "neutral", positif: "good" }; return ( <>

{d.nom ?? sourceName(source)} — {d.annonces_actives}{NBSP_}annonce{d.annonces_actives > 1 ? "s" : ""} active{d.annonces_actives > 1 ? "s" : ""} sur Lou-Ka {d.site_web && <> · site web ↗}

{g && g.statut !== "non_associe" && g.note != null ? ( <>
★ {g.note.toFixed(1).replace(".", ",")} {g.nombre_avis}{NBSP_}avis Google — fiche «{NBSP_}{g.nom}{NBSP_}» {avis?.tendance && {avis.tendance}}
{dist && total > 0 && (
{[5, 4, 3, 2, 1].map((n) => { const c = dist[String(n)] ?? 0; return (
{n}★ {c}
); })}
)} {avis?.moyenne_12m != null &&

Moyenne des 12 derniers mois : {avis.moyenne_12m.toFixed(1).replace(".", ",")} ({avis.n_12m} avis)

} {(avis?.plaintes_frequentes?.length ?? 0) > 0 && (
Plaintes récurrentes : {avis!.plaintes_frequentes!.map((t) => {t})}
)} {recents.map((a, i) => (
{a.note != null ? `${a.note}★` : "—"}{" "}{a.texte}
{a.date ? new Date(a.date).toLocaleDateString("fr-CA", { month: "long", year: "numeric" }) : ""}{a.reponse_proprietaire && " · le gestionnaire a répondu"}
))}

Fiche Google Maps associée automatiquement (confiance {Math.round((g.confiance_association ?? 0) * 100)}{NBSP_}% — {g.methode}). Statistiques calculées sur les {avis?.n ?? 0} avis synchronisés dans la base Lou-Ka {g.nombre_avis && avis && avis.n < g.nombre_avis ? ` (échantillon des plus récents ; Google en annonce ${g.nombre_avis})` : ""}. Thèmes détectés par lexique — inférence indicative.

) : Aucune fiche Google associée avec confiance à ce gestionnaire.} ); } function Tal({ r }: { r: Res }) { if (r.status === "loading" || r.status === "idle") return ; const d = r.status === "ok" ? r.data : null; if (!d || d.status === "na" || d.status === "error") return Vérification au Tribunal administratif du logement non disponible pour cette adresse.; const fmt = (s: string | null) => { if (!s) return "—"; const dt = new Date(s + "T00:00:00"); return Number.isNaN(dt.getTime()) ? s : dt.toLocaleDateString("fr-CA", { month: "long", year: "numeric" }); }; const decisions = d.decisions ?? []; return ( <> {d.status === "pending" &&

Vérification en cours Adresse en file de vérification auprès des décisions publiées du TAL.

} {d.status === "ok" && decisions.length === 0 &&

Aucune décision trouvée Aucune décision publiée du TAL repérée à cette adresse.

} {d.status === "ok" && decisions.length > 0 && ( <>

0 || (d.contre_locataire ?? 0) > 0 ? "warn" : "neutral"}>{d.n} décision{(d.n ?? 0) > 1 ? "s" : ""}{" "} {d.contre_locataire ? `dont ${d.contre_locataire} à l'initiative du propriétaire` : "aucune à l'initiative du propriétaire"}{d.last_date ? ` — la plus récente : ${fmt(d.last_date)}` : ""}.

    {decisions.slice(0, 8).map((dec) => (
  • {fmt(dec.date)}{dec.demandeur ? ` · demande du ${dec.demandeur}` : ""}{dec.verdict ? ` · ${dec.verdict}` : ""}
    {dec.tags.length > 0 &&
    {dec.tags.map((t) => {t})}
    } {dec.citation || "Texte intégral"} — SOQUIJ
  • ))}
)}

Décisions publiques du Tribunal administratif du logement, diffusées par SOQUIJ et repérées par correspondance d'adresse — vérification automatisée, indicative, sans valeur juridique ; une décision peut concerner un autre logement du même immeuble. Aucun nom de partie n'est affiché.

); } function HiverVue({ h }: { h: Hiver | null }) { if (!h) return Score hiver non calculable (commodités de proximité inconnues pour cette adresse).; const CLS: Record = { "très pratique": "good", pratique: "good", exigeant: "warn", difficile: "bad" }; return ( <>
{h.score}/ 100 {h.classe} quotidien à pied, même à −20{NBSP_}°C
{h.detail.map((c) => (
{c.critere}{c.distance_m != null ? ` ≈ ${c.minutes} min à pied${c.nom ? ` (${c.nom})` : ""}` : c.note ? ` ${c.note}` : ""} {c.score}
))}

{h.methode}

); } export default function BuildingDossier({ source, immeuble, hiver, historique, recyclees, tal, gestionnaire }: { source: string; immeuble: Immeuble | null; hiver: Hiver | null; historique: Res; recyclees: Res; tal: Res; gestionnaire: Res; }) { const talOk = tal.status === "ok" ? tal.data : null; const talMeta = talOk?.status === "ok" ? (talOk.n ? `${talOk.n} décision${talOk.n > 1 ? "s" : ""}` : "aucune") : talOk?.status === "pending" ? "en cours" : undefined; const histo = historique.status === "ok" ? historique.data : null; return ( } sub="Ce que Lou-Ka observe réellement : historique, immeuble, gestionnaire, litiges, hiver"> Historique Lou-Ka} meta={histo ? `${histo.jours_en_ligne}${NBSP_}j en ligne` : undefined}> Passeport de l'immeuble} meta={immeuble && immeuble.annonces_total >= 2 ? `${immeuble.annonces_total} annonces` : undefined}> Qui gère ce logement ?} meta={gestionnaire.status === "ok" && gestionnaire.data.google_maps?.note != null ? `★ ${gestionnaire.data.google_maps.note.toFixed(1).replace(".", ",")}` : sourceName(source)}> Historique au TAL} meta={talMeta}> Vie quotidienne en hiver} meta={hiver ? `${hiver.score}/100 · ${hiver.classe}` : undefined}> ); }