Lou·Ka — tous les logements à louer du Québec, un seul endroit.
HTML 98.9%
Python 0.6%
1// -----------------------------------------------------------------------------2// Lou-Ka — Agrégateur de logements à louer (province de Québec)3// Auteur : Simon-Pierre Boucher — contact@spboucher.ai4// fiche/BuildingDossier.tsx : « Dossier de l'immeuble » — toutes les données5// d'intelligence locative en accordéons compacts (aucune n'est supprimée) :6// historique Lou-Ka + vies antérieures, passeport de l'immeuble, gestionnaire7// (réputation Google analysée), historique au TAL (SOQUIJ), vie en hiver.8// Chaque volet affiche un état vide propre quand la donnée manque.9// -----------------------------------------------------------------------------10import {11 Gestionnaire, Hiver, HistoriqueLouka, Immeuble, Recyclees, TalHistory, fmtPrice, sourceName,12} from "../api";13import { IcoBuilding, IcoShield, IcoSnow, IcoUser } from "../components/Icons";14import { Accordion, SectionCard, SkeletonLines, StatTile, StatusBadge, Tone, NBSP } from "./ui";15import { Res } from "./useFicheData";1617const NBSP_ = NBSP;18const fmtTs = (ts: number | null | undefined, m: "short" | "long" = "short") =>19 ts ? new Date(ts * 1000).toLocaleDateString("fr-CA", m === "short" ? { day: "numeric", month: "short", year: "numeric" } : { month: "long", year: "numeric" }) : "—";20const EVENT: Record<string, string> = {21 description: "Description modifiée", superficie: "Superficie modifiée", dispo: "Disponibilité modifiée",22 inclusions: "Inclusions modifiées", photos: "Photos modifiées", disparition: "Annonce retirée", reapparition: "Annonce republiée",23};2425function Vide({ children }: { children: React.ReactNode }) { return <p style={{ color: "var(--lk-muted)", margin: 0 }}>{children}</p>; }2627function Historique({ r, rec }: { r: Res<HistoriqueLouka>; rec: Res<Recyclees> }) {28 if (r.status === "loading" || r.status === "idle") return <SkeletonLines n={3} />;29 const d = r.status === "ok" ? r.data : null;30 if (!d) return <Vide>Historique indisponible pour cette annonce.</Vide>;31 const items = d.timeline.slice(0, 12);32 const matches = rec.status === "ok" ? rec.data.matches : [];33 const var_ = d.variation != null ? `${d.variation > 0 ? "+" : "−"}${Math.abs(Math.round(d.variation * 100))}${NBSP_}%` : null;34 return (35 <>36 <div className="lk-kpis cols-3" style={{ marginBottom: 10 }}>37 <StatTile value={fmtTs(d.premiere_observation)} label="Suivie depuis" anim={false} />38 <StatTile value={`${d.jours_en_ligne}${NBSP_}j`} label="En ligne" anim={false} />39 <StatTile value={d.modifications} label="Modifications observées" anim={false} />40 </div>41 {d.prix_initial != null && d.prix_actuel != null && d.prix_initial !== d.prix_actuel && (42 <p style={{ margin: "0 0 8px" }}>Prix initial {fmtPrice(d.prix_initial)} → <b>{fmtPrice(d.prix_actuel)}</b>{var_ ? ` (${var_})` : ""}</p>43 )}44 {items.length > 0 ? (45 <ul className="lk-tl">46 {items.map((it, i) => (47 <li key={`${it.ts}-${it.type}-${i}`} className={it.type}>48 <span className="lk-tl-date">{fmtTs(it.ts)}</span>49 {it.type === "prix"50 ? (it.prix_avant != null51 ? <>Prix {it.prix_avant > (it.prix ?? 0) ? "baissé" : "monté"} de {fmtPrice(it.prix_avant)} à <b>{fmtPrice(it.prix ?? null)}</b></>52 : <>Premier prix observé : <b>{fmtPrice(it.prix ?? null)}</b></>)53 : (EVENT[it.type] ?? it.type)}54 </li>55 ))}56 </ul>57 ) : <Vide>Aucune modification observée depuis la première synchronisation.</Vide>}58 {matches.length > 0 && (59 <div style={{ marginTop: 12 }}>60 <b style={{ fontSize: 13 }}>Vies antérieures probables de ce logement</b>61 {matches.slice(0, 3).map((m) => (62 <div key={m.uid} className="lk-quote" style={{ borderLeftColor: "var(--lk-orange)" }}>63 <StatusBadge tone="warn">republication probable · {m.confiance}{NBSP_}%</StatusBadge>{" "}64 {m.prix != null && <>affiché {fmtPrice(m.prix)}</>}{m.derniere_observation != null && <> jusqu'en {fmtTs(m.derniere_observation, "long")}</>}65 <footer>{m.signaux.join(" · ")}</footer>66 </div>67 ))}68 <p className="lk-fine">{rec.status === "ok" ? rec.data.methode : ""} — inférence, sans fusion automatique.</p>69 </div>70 )}71 <p className="lk-fine">{d.methode}</p>72 </>73 );74}7576function Passeport({ im }: { im: Immeuble | null }) {77 if (!im || im.annonces_total < 2)78 return <Vide>Une seule annonce observée pour cet immeuble : pas encore de passeport (unités, rotation, pression sur les loyers).</Vide>;79 const rot = im.rotation, pres = im.pression_loyers;80 const ROT: Record<string, Tone> = { faible: "good", normale: "neutral", "élevée": "warn", "très élevée": "bad" };81 return (82 <>83 <div className="lk-kpis" style={{ marginBottom: 10 }}>84 <StatTile value={im.annonces_total} label={`Annonces observées · ${im.annonces_actives} active${im.annonces_actives > 1 ? "s" : ""}`} anim={false} />85 <StatTile value={`≥${NBSP_}${im.unites_estimees}`} label="Unités estimées" anim={false} />86 {im.loyer_median != null && <StatTile value={fmtPrice(im.loyer_median)} label="Loyer médian (actives)" anim={false} />}87 {im.pi2_median != null && <StatTile value={`${im.pi2_median.toFixed(2).replace(".", ",")}${NBSP_}$`} label="Médiane $/pi²" anim={false} />}88 </div>89 {im.loyer_median_par_cc && Object.keys(im.loyer_median_par_cc).length > 0 && (90 <p style={{ margin: "0 0 8px" }}>Par nombre de chambres : {Object.entries(im.loyer_median_par_cc).map(([cc, v]) => `${cc}${NBSP_}ch. ${fmtPrice(v)}`).join(" · ")}</p>91 )}92 <div className="lk-status" style={{ marginBottom: 6 }}>93 <span>Rotation</span>94 {rot.statut === "calculated"95 ? <><StatusBadge tone={ROT[rot.classe ?? ""] ?? "neutral"}>{rot.classe}</StatusBadge><small style={{ color: "var(--lk-muted)" }}>{rot.annonces_12m} annonce{(rot.annonces_12m ?? 0) > 1 ? "s" : ""} / 12 mois pour ≥{NBSP_}{rot.unites_estimees} unités</small></>96 : <StatusBadge tone="neutral">données insuffisantes</StatusBadge>}97 </div>98 <div className="lk-status">99 <span>Pression sur les loyers</span>100 {pres101 ? <><StatusBadge tone={pres.variation_12m > 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</StatusBadge>102 <small style={{ color: "var(--lk-muted)" }}>médiane d'entrée {fmtPrice(pres.mediane_12_24m)} → {fmtPrice(pres.mediane_12m)}</small></>103 : <StatusBadge tone="neutral">échantillon insuffisant</StatusBadge>}104 </div>105 <p className="lk-fine">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)."}</p>106 </>107 );108}109110function GestionnaireVue({ r, source }: { r: Res<Gestionnaire>; source: string }) {111 if (r.status === "loading" || r.status === "idle") return <SkeletonLines n={3} />;112 const d = r.status === "ok" ? r.data : null;113 if (!d) return <Vide>Gestionnaire : <b>{sourceName(source)}</b>. Aucune fiche de réputation associée.</Vide>;114 const g = d.google_maps, avis = d.avis, dist = avis?.distribution;115 const total = dist ? Object.values(dist).reduce((a, b) => a + b, 0) : 0;116 const recents = (d.avis_recents ?? []).filter((a) => a.texte).slice(0, 3);117 const SENT: Record<string, Tone> = { "négatif": "bad", neutre: "neutral", positif: "good" };118 return (119 <>120 <p style={{ margin: "0 0 8px" }}>121 <b style={{ fontSize: 15 }}>{d.nom ?? sourceName(source)}</b> — {d.annonces_actives}{NBSP_}annonce{d.annonces_actives > 1 ? "s" : ""} active{d.annonces_actives > 1 ? "s" : ""} sur Lou-Ka122 {d.site_web && <> · <a href={d.site_web} target="_blank" rel="noopener noreferrer">site web ↗</a></>}123 </p>124 {g && g.statut !== "non_associe" && g.note != null ? (125 <>126 <div className="lk-status" style={{ marginBottom: 8 }}>127 <span className="lk-star">★ {g.note.toFixed(1).replace(".", ",")}</span>128 <small style={{ color: "var(--lk-muted)" }}>{g.nombre_avis}{NBSP_}avis Google — fiche «{NBSP_}{g.nom}{NBSP_}»</small>129 {avis?.tendance && <StatusBadge tone={avis.tendance === "en amélioration" ? "good" : avis.tendance === "en dégradation" ? "warn" : "neutral"}>{avis.tendance}</StatusBadge>}130 </div>131 {dist && total > 0 && (132 <div className="lk-rows" aria-label="Distribution des notes analysées">133 {[5, 4, 3, 2, 1].map((n) => {134 const c = dist[String(n)] ?? 0;135 return (136 <div className="lk-row" key={n}>137 <span className="lk-row-name">{n}★</span>138 <span className="lk-row-bar" aria-hidden="true"><i className={n <= 2 ? "bad" : n >= 4 ? "good" : ""} style={{ width: `${Math.round((100 * c) / total)}%` }} /></span>139 <span className="lk-row-val">{c}</span>140 </div>141 );142 })}143 </div>144 )}145 {avis?.moyenne_12m != null && <p style={{ margin: "8px 0 0" }}>Moyenne des 12 derniers mois : <b>{avis.moyenne_12m.toFixed(1).replace(".", ",")}</b> ({avis.n_12m} avis)</p>}146 {(avis?.plaintes_frequentes?.length ?? 0) > 0 && (147 <div className="lk-tags" style={{ marginTop: 8 }}>148 <span style={{ fontSize: 12.5, color: "var(--lk-text-2)" }}>Plaintes récurrentes :</span>149 {avis!.plaintes_frequentes!.map((t) => <span className="lk-tag" key={t}>{t}</span>)}150 </div>151 )}152 {recents.map((a, i) => (153 <blockquote className="lk-quote" key={i}>154 <StatusBadge tone={SENT[a.analyse.sentiment ?? ""] ?? "neutral"}>{a.note != null ? `${a.note}★` : "—"}</StatusBadge>{" "}{a.texte}155 <footer>{a.date ? new Date(a.date).toLocaleDateString("fr-CA", { month: "long", year: "numeric" }) : ""}{a.reponse_proprietaire && " · le gestionnaire a répondu"}</footer>156 </blockquote>157 ))}158 <p className="lk-fine">159 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-Ka160 {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.161 </p>162 </>163 ) : <Vide>Aucune fiche Google associée avec confiance à ce gestionnaire.</Vide>}164 </>165 );166}167168function Tal({ r }: { r: Res<TalHistory> }) {169 if (r.status === "loading" || r.status === "idle") return <SkeletonLines n={2} />;170 const d = r.status === "ok" ? r.data : null;171 if (!d || d.status === "na" || d.status === "error") return <Vide>Vérification au Tribunal administratif du logement non disponible pour cette adresse.</Vide>;172 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" }); };173 const decisions = d.decisions ?? [];174 return (175 <>176 {d.status === "pending" && <p style={{ margin: 0 }}><StatusBadge tone="neutral">Vérification en cours</StatusBadge> Adresse en file de vérification auprès des décisions publiées du TAL.</p>}177 {d.status === "ok" && decisions.length === 0 && <p style={{ margin: 0 }}><StatusBadge tone="good">Aucune décision trouvée</StatusBadge> Aucune décision publiée du TAL repérée à cette adresse.</p>}178 {d.status === "ok" && decisions.length > 0 && (179 <>180 <p style={{ margin: "0 0 8px" }}>181 <StatusBadge tone={(d.eviction ?? 0) > 0 || (d.contre_locataire ?? 0) > 0 ? "warn" : "neutral"}>{d.n} décision{(d.n ?? 0) > 1 ? "s" : ""}</StatusBadge>{" "}182 {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)}` : ""}.183 </p>184 <ul className="lk-list">185 {decisions.slice(0, 8).map((dec) => (186 <li className="lk-item" key={dec.url} style={{ alignItems: "flex-start" }}>187 <div className="lk-item-main">188 <div className="lk-item-t">{fmt(dec.date)}{dec.demandeur ? ` · demande du ${dec.demandeur}` : ""}{dec.verdict ? ` · ${dec.verdict}` : ""}</div>189 {dec.tags.length > 0 && <div className="lk-tags">{dec.tags.map((t) => <span className="lk-tag" key={t}>{t}</span>)}</div>}190 <a href={dec.url} target="_blank" rel="noopener noreferrer" className="lk-item-s" style={{ display: "inline-block", marginTop: 3 }}>{dec.citation || "Texte intégral"} — SOQUIJ</a>191 </div>192 </li>193 ))}194 </ul>195 </>196 )}197 <p className="lk-fine">198 Décisions publiques du <a href="https://www.tal.gouv.qc.ca/" target="_blank" rel="noopener noreferrer">Tribunal administratif du logement</a>, diffusées par SOQUIJ et repérées par199 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é.200 </p>201 </>202 );203}204205function HiverVue({ h }: { h: Hiver | null }) {206 if (!h) return <Vide>Score hiver non calculable (commodités de proximité inconnues pour cette adresse).</Vide>;207 const CLS: Record<string, Tone> = { "très pratique": "good", pratique: "good", exigeant: "warn", difficile: "bad" };208 return (209 <>210 <div className="lk-status" style={{ marginBottom: 8 }}>211 <span className="lk-market-big" style={{ fontSize: 26 }}>{h.score}<small>/ 100</small></span>212 <StatusBadge tone={CLS[h.classe] ?? "neutral"} lg>{h.classe}</StatusBadge>213 <small style={{ color: "var(--lk-muted)" }}>quotidien à pied, même à −20{NBSP_}°C</small>214 </div>215 <div className="lk-rows">216 {h.detail.map((c) => (217 <div className="lk-row" key={c.critere}>218 <span className="lk-row-name">{c.critere}<small>{c.distance_m != null ? ` ≈ ${c.minutes} min à pied${c.nom ? ` (${c.nom})` : ""}` : c.note ? ` ${c.note}` : ""}</small></span>219 <span className="lk-row-bar" aria-hidden="true"><i style={{ width: `${c.score}%` }} /></span>220 <span className="lk-row-val">{c.score}</span>221 </div>222 ))}223 </div>224 <p className="lk-fine">{h.methode}</p>225 </>226 );227}228229export default function BuildingDossier({ source, immeuble, hiver, historique, recyclees, tal, gestionnaire }: {230 source: string; immeuble: Immeuble | null; hiver: Hiver | null;231 historique: Res<HistoriqueLouka>; recyclees: Res<Recyclees>; tal: Res<TalHistory>; gestionnaire: Res<Gestionnaire>;232}) {233 const talOk = tal.status === "ok" ? tal.data : null;234 const talMeta = talOk?.status === "ok" ? (talOk.n ? `${talOk.n} décision${talOk.n > 1 ? "s" : ""}` : "aucune") : talOk?.status === "pending" ? "en cours" : undefined;235 const histo = historique.status === "ok" ? historique.data : null;236 return (237 <SectionCard id="dossier" title="Dossier de l'immeuble" icon={<IcoBuilding size={18} />}238 sub="Ce que Lou-Ka observe réellement : historique, immeuble, gestionnaire, litiges, hiver">239 <Accordion title={<><IcoBuilding size={15} style={{ verticalAlign: -2, marginRight: 8, color: "var(--lk-muted)" }} />Historique Lou-Ka</>}240 meta={histo ? `${histo.jours_en_ligne}${NBSP_}j en ligne` : undefined}>241 <Historique r={historique} rec={recyclees} />242 </Accordion>243 <Accordion title={<><IcoBuilding size={15} style={{ verticalAlign: -2, marginRight: 8, color: "var(--lk-muted)" }} />Passeport de l'immeuble</>}244 meta={immeuble && immeuble.annonces_total >= 2 ? `${immeuble.annonces_total} annonces` : undefined}>245 <Passeport im={immeuble} />246 </Accordion>247 <Accordion title={<><IcoUser size={15} style={{ verticalAlign: -2, marginRight: 8, color: "var(--lk-muted)" }} />Qui gère ce logement ?</>}248 meta={gestionnaire.status === "ok" && gestionnaire.data.google_maps?.note != null ? `★ ${gestionnaire.data.google_maps.note.toFixed(1).replace(".", ",")}` : sourceName(source)}>249 <GestionnaireVue r={gestionnaire} source={source} />250 </Accordion>251 <Accordion title={<><IcoShield size={15} style={{ verticalAlign: -2, marginRight: 8, color: "var(--lk-muted)" }} />Historique au TAL</>} meta={talMeta}>252 <Tal r={tal} />253 </Accordion>254 <Accordion title={<><IcoSnow size={15} style={{ verticalAlign: -2, marginRight: 8, color: "var(--lk-muted)" }} />Vie quotidienne en hiver</>}255 meta={hiver ? `${hiver.score}/100 · ${hiver.classe}` : undefined}>256 <HiverVue h={hiver} />257 </Accordion>258 </SectionCard>259 );260}261