SPB Git forge

spb/uqo-chat

Public
14commits 1branches 0releases
1.4 MBsize
maindefault branch
17 days agolast push
Python 64.6% TypeScript 33.7% CSS 0.8%
7.3 KB · 106 lines tsx
Raw Blame History
1import type { ToolCallView } from '@/lib/types';2import { fmtCAD, fmtNum, fmtPct } from '@/lib/format';34const LABELS: Record<string, string> = {5  value: 'Valeur', land_value: 'Valeur du terrain', cost_new: 'Coût neuf', total_depreciation: 'Dépréciation totale',6  depreciated_cost: 'Coût déprécié', depreciation_ratio: 'Taux de dépréciation', ratio: 'Ratio', noi: 'RNE', cap_rate: 'TGA',7  gim: 'MRB', land_value_pv: 'Valeur du terrain (VA)', per_lot: 'Par lot', gross_sales: 'Recettes brutes', costs: 'Coûts',8  profit: 'Profit', net_undiscounted: 'Net non actualisé', building_income: 'Revenu attribuable au bâtiment',9  residual_income: 'Revenu résiduel (terrain)', index_ratio: 'Ratio d’indices', direct_costs: 'Coûts directs', extras: 'Extras',10  indirect_costs: 'Coûts indirects', entrepreneur_profit: 'Profit de l’entrepreneur', cost_per_unit_all_in: 'Coût unitaire tout compris',11  effective_age: 'Âge effectif', annual_rate: 'Taux annuel', depreciation: 'Dépréciation', land_ratio: 'Ratio terrain',12  curable_physical: 'Physique récupérable', short_lived: 'Courte vie', long_lived: 'Longue vie', functional: 'Fonctionnelle', external: 'Externe',13  min: 'Minimum', max: 'Maximum', mean: 'Moyenne (indicatif)', median: 'Médiane', weighted: 'Pondération réconciliée',14  least_adjusted: 'Comparable le moins ajusté', least_adjusted_price: 'Prix ajusté (moins ajusté)',15};16const PCT = new Set(['depreciation_ratio', 'ratio', 'cap_rate', 'annual_rate', 'land_ratio', 'time_pct', 'net_pct', 'gross_pct']);17const PLAIN = new Set(['gim', 'index_ratio', 'effective_age', 'unit']);1819function fmt(k: string, v: unknown): string {20  if (v === null || v === undefined) return '—';21  if (typeof v === 'number') {22    if (PCT.has(k)) return fmtPct(v, 2);23    if (PLAIN.has(k)) return fmtNum(v, 2);24    return Math.abs(v) >= 1000 ? fmtCAD(v, 0) : fmtNum(v, 2);25  }26  return String(v);27}2829interface Comp { name: string; price: number; time_pct: number; time_adjusted: number; adjustments: Record<string, number>; net: number; adjusted_price: number; net_pct: number; gross_pct: number; reliable: boolean }3031export function AppraisalCalcCard({ call }: { call: ToolCallView }) {32  const p = call.payload as { function?: string; result?: Record<string, unknown> };33  const r = p.result || {};34  const table = r.table as unknown[][] | undefined;35  const comps = r.comparables as Comp[] | undefined;36  const stats = r.stats as Record<string, unknown> | undefined;37  const components = r.components as Record<string, number> | undefined;38  const scalarKeys = Object.keys(r).filter((k) => !['formula', 'table', 'comparables', 'stats', 'components', 'note'].includes(k) && typeof r[k] !== 'object');39  const adjKeys = comps ? [...new Set(comps.flatMap((c) => Object.keys(c.adjustments)))] : [];40  return (41    <div className="space-y-3 text-sm">42      {r.formula ? <div className="rounded-lg bg-uqo-blue-light/60 px-3 py-2 font-mono text-xs text-uqo-blue-dark">{String(r.formula)}</div> : null}43      {scalarKeys.length > 0 && (44        <dl className="grid grid-cols-2 sm:grid-cols-3 gap-2">45          {scalarKeys.map((k) => (46            <div key={k} className={`rounded-xl border px-3 py-2 ${k === 'value' || k === 'land_value' || k === 'land_value_pv' ? 'border-uqo-green bg-[#f2f9ef]' : 'border-neutral-line bg-white'}`}>47              <dt className="text-[11px] text-neutral-muted">{LABELS[k] || k.replace(/_/g, ' ')}</dt>48              <dd className="font-semibold tabular-nums">{fmt(k, r[k])}</dd>49            </div>50          ))}51        </dl>52      )}53      {components && (54        <dl className="grid grid-cols-2 sm:grid-cols-5 gap-2">55          {Object.entries(components).map(([k, v]) => <div key={k} className="rounded-xl border border-neutral-line bg-white px-3 py-2"><dt className="text-[11px] text-neutral-muted">{LABELS[k] || k}</dt><dd className="font-semibold tabular-nums">{fmtCAD(v, 0)}</dd></div>)}56        </dl>57      )}58      {table && (59        <div className="rounded-xl border border-neutral-line overflow-x-auto scroll-thin bg-white">60          <table className="text-xs min-w-full">61            <tbody>62              {table.map((row, i) => {63                const last = i === table.length - 1;64                return (65                  <tr key={i} className={`border-b border-neutral-line/60 last:border-0 ${last ? 'font-semibold text-uqo-blue-dark bg-uqo-blue-light/40' : ''}`}>66                    {row.map((c, j) => <td key={j} className={`px-3 py-1.5 ${typeof c === 'number' ? 'text-right tabular-nums whitespace-nowrap' : ''}`}>{typeof c === 'number' ? fmtCAD(c, 0) : String(c ?? '')}</td>)}67                  </tr>68                );69              })}70            </tbody>71          </table>72        </div>73      )}74      {comps && (75        <div className="rounded-xl border border-neutral-line overflow-x-auto scroll-thin bg-white">76          <table className="text-xs min-w-full">77            <thead className="bg-uqo-blue-light text-uqo-blue-dark">78              <tr><th className="px-2 py-1.5 text-left">Comparable</th><th className="px-2 py-1.5 text-right">Prix</th><th className="px-2 py-1.5 text-right">Temps</th>{adjKeys.map((k) => <th key={k} className="px-2 py-1.5 text-right capitalize">{k}</th>)}<th className="px-2 py-1.5 text-right">Prix ajusté</th><th className="px-2 py-1.5 text-right">Net %</th><th className="px-2 py-1.5 text-right">Brut %</th><th /></tr>79            </thead>80            <tbody>81              {comps.map((c) => (82                <tr key={c.name} className="border-t border-neutral-line/60">83                  <td className="px-2 py-1.5 font-medium whitespace-nowrap">{c.name}</td>84                  <td className="px-2 py-1.5 text-right tabular-nums">{fmtCAD(c.price, 0)}</td>85                  <td className="px-2 py-1.5 text-right tabular-nums">{fmtPct(c.time_pct, 1)}</td>86                  {adjKeys.map((k) => <td key={k} className={`px-2 py-1.5 text-right tabular-nums ${(c.adjustments[k] || 0) < 0 ? 'text-semantic-error' : (c.adjustments[k] || 0) > 0 ? 'text-[#1f7a1f]' : 'text-neutral-muted'}`}>{c.adjustments[k] !== undefined ? fmtCAD(c.adjustments[k], 0) : '—'}</td>)}87                  <td className="px-2 py-1.5 text-right tabular-nums font-semibold">{fmtCAD(c.adjusted_price, 0)}</td>88                  <td className="px-2 py-1.5 text-right tabular-nums">{fmtPct(c.net_pct, 1)}</td>89                  <td className="px-2 py-1.5 text-right tabular-nums">{fmtPct(c.gross_pct, 1)}</td>90                  <td className="px-2 py-1.5">{c.reliable ? <span className="text-[10px] rounded bg-[#e9f6d9] text-[#3f7a0a] px-1.5 py-0.5">fiable</span> : <span className="text-[10px] rounded bg-amber-50 text-amber-700 px-1.5 py-0.5">à pondérer</span>}</td>91                </tr>92              ))}93            </tbody>94          </table>95        </div>96      )}97      {stats && (98        <dl className="grid grid-cols-2 sm:grid-cols-4 gap-2">99          {Object.entries(stats).map(([k, v]) => <div key={k} className={`rounded-xl border px-3 py-2 ${k === 'weighted' ? 'border-uqo-green bg-[#f2f9ef]' : 'border-neutral-line bg-white'}`}><dt className="text-[11px] text-neutral-muted">{LABELS[k] || k}</dt><dd className="font-semibold tabular-nums">{typeof v === 'number' ? fmtCAD(v, 0) : String(v)}</dd></div>)}100        </dl>101      )}102      {r.note ? <p className="text-xs text-neutral-muted">{String(r.note)}</p> : null}103    </div>104  );105}106