SPB Git forge

spb/lou-ka

Public

Lou·Ka — tous les logements à louer du Québec, un seul endroit.

232commits 1branches 0releases
172.9 MBsize
maindefault branch
2 days agolast push
HTML 98.9% Python 0.6%
17.1 KB · 261 lines tsx
Raw Blame History
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