diff --git a/tradein-mvp/backend/app/schemas/trade_in.py b/tradein-mvp/backend/app/schemas/trade_in.py index 361bc042..a8351d17 100644 --- a/tradein-mvp/backend/app/schemas/trade_in.py +++ b/tradein-mvp/backend/app/schemas/trade_in.py @@ -46,6 +46,13 @@ class AnalogLot(BaseModel): distance_m: int | None = None # расстояние до целевой квартиры в метрах +class CianChartPoint(BaseModel): + """Одна точка 7-месячного chart Cian Valuation Calculator.""" + + date: str + price: float + + class CianValuationSummary(BaseModel): """Cian Valuation Calculator данные для UI. @@ -55,7 +62,7 @@ class CianValuationSummary(BaseModel): sale_price_rub: int | None = None rent_price_rub: int | None = None - chart: list[dict[str, Any]] = Field(default_factory=list) + chart: list[CianChartPoint] = Field(default_factory=list) chart_change_pct: float | None = None chart_change_direction: Literal["increase", "decrease", "neutral"] | None = None diff --git a/tradein-mvp/backend/app/services/estimator.py b/tradein-mvp/backend/app/services/estimator.py index d0ea031c..0d61ff54 100644 --- a/tradein-mvp/backend/app/services/estimator.py +++ b/tradein-mvp/backend/app/services/estimator.py @@ -977,9 +977,20 @@ async def estimate_quality(payload: TradeInEstimateInput, db: Session) -> Aggreg CianValuationSummary( sale_price_rub=int(cian_val.sale_price_rub) if cian_val.sale_price_rub else None, rent_price_rub=int(cian_val.rent_price_rub) if cian_val.rent_price_rub else None, - chart=list(cian_val.chart or []), + chart=[ + { + "date": p.get("month_date") or p.get("date") or "", + "price": p["price"], + } + for p in (cian_val.chart or []) + if p.get("price") is not None + ], chart_change_pct=cian_val.chart_change_pct, - chart_change_direction=cian_val.chart_change_direction, + chart_change_direction=( + cian_val.chart_change_direction + if cian_val.chart_change_direction in {"increase", "decrease", "neutral"} + else None + ), ) if cian_val is not None else None diff --git a/tradein-mvp/frontend/src/app/page.tsx b/tradein-mvp/frontend/src/app/page.tsx index 500f55b8..0e781d03 100644 --- a/tradein-mvp/frontend/src/app/page.tsx +++ b/tradein-mvp/frontend/src/app/page.tsx @@ -16,6 +16,7 @@ import { Topbar } from "@/components/trade-in/Topbar"; import { SourcesProgress } from "@/components/trade-in/SourcesProgress"; import { HeroSummary } from "@/components/trade-in/HeroSummary"; import { IMVBenchmark } from "@/components/trade-in/IMVBenchmark"; +import { CianValuationCard } from "@/components/trade-in/CianValuationCard"; import { HouseInfoCard } from "@/components/trade-in/HouseInfoCard"; import { PlacementHistoryCard } from "@/components/trade-in/PlacementHistoryCard"; import { PhotoUpload } from "@/components/trade-in/PhotoUpload"; @@ -166,6 +167,7 @@ export default function TradeInPage() { benchmark={imvBenchmark.data} isLoading={imvBenchmark.isPending} /> + = 1_000_000) return `${(n / 1_000_000).toFixed(2)} млн ₽`; + return new Intl.NumberFormat("ru-RU").format(n) + " ₽"; +} + +function Sparkline({ points }: { points: Array<{ date: string; price: number }> }) { + if (points.length < 2) return null; + const W = 240, H = 60, PAD = 4; + const xs = points.map((_, i) => PAD + (i * (W - 2 * PAD)) / (points.length - 1)); + const prices = points.map((p) => p.price); + const min = Math.min(...prices), max = Math.max(...prices); + const range = max - min || 1; + const ys = prices.map((p) => H - PAD - ((p - min) / range) * (H - 2 * PAD)); + const d = xs.map((x, i) => `${i === 0 ? "M" : "L"}${x},${ys[i]}`).join(" "); + const firstDate = points[0].date.slice(0, 7); // "YYYY-MM" + const lastDate = points[points.length - 1].date.slice(0, 7); + return ( +
+ +
+ {firstDate} + {lastDate} +
+
+ ); +} + +function ChangeBadge({ + pct, + direction, +}: { + pct: number | null; + direction: CianValuationSummary["chart_change_direction"]; +}) { + if (pct == null || direction == null) return null; + + const arrow = direction === "increase" ? "↑" : direction === "decrease" ? "↓" : "—"; + const colorClass = + direction === "increase" + ? "cian-valuation__badge--up" + : direction === "decrease" + ? "cian-valuation__badge--down" + : "cian-valuation__badge--neutral"; + + return ( + + {arrow} {Math.abs(pct).toFixed(1)}% + + ); +} + +export function CianValuationCard({ data }: Props) { + if (!data || data.chart.length === 0) return null; + + return ( +
+
+ Оценка Cian + +
+ +
+
+
Продажа
+
+ {formatRub(data.sale_price_rub)} +
+
+ {data.rent_price_rub != null && ( +
+
Аренда / мес
+
+ {formatRub(data.rent_price_rub)} +
+
+ )} +
+ + +
+ ); +} diff --git a/tradein-mvp/frontend/src/components/trade-in/trade-in.css b/tradein-mvp/frontend/src/components/trade-in/trade-in.css index 041f0390..229fa5af 100644 --- a/tradein-mvp/frontend/src/components/trade-in/trade-in.css +++ b/tradein-mvp/frontend/src/components/trade-in/trade-in.css @@ -1162,6 +1162,90 @@ color: #374151; } +/* Cian valuation card */ + +.cian-valuation { + margin: 16px 0; + padding: 16px; + border: 1px solid #e5e7eb; + border-radius: 12px; + background: #fff; +} + +.cian-valuation__head { + display: flex; + align-items: center; + gap: 10px; + margin-bottom: 12px; +} + +.cian-valuation__title { + font-size: 13px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.05em; + color: #6b7280; +} + +.cian-valuation__badge { + font-size: 12px; + font-weight: 600; + padding: 2px 7px; + border-radius: 6px; +} + +.cian-valuation__badge--up { + background: #d1fae5; + color: #065f46; +} + +.cian-valuation__badge--down { + background: #fee2e2; + color: #991b1b; +} + +.cian-valuation__badge--neutral { + background: #f3f4f6; + color: #374151; +} + +.cian-valuation__row { + display: flex; + gap: 24px; + margin-bottom: 16px; + flex-wrap: wrap; +} + +.cian-valuation__metric-label { + font-size: 12px; + color: #6b7280; + margin-bottom: 2px; +} + +.cian-valuation__metric-value { + font-size: 15px; + font-weight: 500; + color: #111827; +} + +.cian-valuation__metric-value--primary { + font-size: 20px; + font-weight: 700; +} + +.cian-valuation__sparkline { + color: #2563eb; +} + +.cian-valuation__sparkline-dates { + display: flex; + justify-content: space-between; + font-size: 11px; + color: #9ca3af; + width: 240px; + margin-top: 2px; +} + /* House info card */ .house-info-card { diff --git a/tradein-mvp/frontend/src/types/trade-in.ts b/tradein-mvp/frontend/src/types/trade-in.ts index 2f1ea8be..94a69057 100644 --- a/tradein-mvp/frontend/src/types/trade-in.ts +++ b/tradein-mvp/frontend/src/types/trade-in.ts @@ -46,6 +46,14 @@ export interface AnalogLot { distance_m: number | null; // расстояние до целевой квартиры в метрах } +export interface CianValuationSummary { + sale_price_rub: number | null; + rent_price_rub: number | null; + chart: Array<{ date: string; price: number }>; + chart_change_pct: number | null; + chart_change_direction: 'increase' | 'decrease' | 'neutral' | null; +} + export interface AggregatedEstimate { estimate_id: string; // UUID median_price_rub: number; @@ -75,6 +83,8 @@ export interface AggregatedEstimate { house_type: HouseType | null; repair_state: RepairState | null; has_balcony: boolean | null; + // ── Cian valuation surface ── + cian_valuation?: CianValuationSummary | null; } // ── Stage 4a/4b response types ──