"use client"; import { Fragment, useState } from "react"; import { useBestLayouts } from "@/hooks/useBestLayouts"; import { API_BASE_URL } from "@/lib/api"; import type { BestLayoutsRequest, BestLayoutsResponse, Confidence, LayoutTzMixRow, TimeWindow, TopLayoutRow, } from "@/types/best-layouts"; // ── Constants ───────────────────────────────────────────────────────────────── const CONFIDENCE_STYLES: Record = { high: { cls: "bg-emerald-100 text-emerald-800", label: "Высокое" }, medium: { cls: "bg-amber-100 text-amber-800", label: "Среднее" }, low: { cls: "bg-rose-100 text-rose-800", label: "Низкое" }, }; const TIME_WINDOW_LABELS: Record = { last_month: "Последний месяц", last_quarter: "Последний квартал", last_year: "Последний год", }; // Период «распроданности» для провенанса — соответствует выбранному time_window // (backend считает sold_pct_of_supply по сделкам этого окна). const TIME_WINDOW_OVERSOLD_PERIOD: Record = { last_month: "за последний месяц", last_quarter: "за последний квартал", last_year: "за последний год", }; const ROOM_BUCKET_LABELS: Record = { studio: "Студия", "1": "1-комн.", "2": "2-комн.", "3": "3-комн.", "4+": "4+ комн.", }; // ── Sub-components ──────────────────────────────────────────────────────────── function DataQualityCard({ dq }: { dq: BestLayoutsResponse["data_quality"] }) { const conf = CONFIDENCE_STYLES[dq.confidence]; return (
Качество данных: {conf.label} Покрытие {dq.velocity_coverage_pct.toFixed(0)}% ( {dq.objects_with_velocity_data} из {dq.objects_total_in_radius} ЖК)
); } const COMPETITOR_PREVIEW_LIMIT = 5; function salesPeriodMonths(supply: number, velocity: number): number | null { if (velocity <= 0) return null; return Math.ceil(supply / velocity); } function SalesPeriodCell({ months }: { months: number | null }) { if (months === null) return ; if (months < 12) return ( {months} мес ); if (months <= 24) return {months} мес; return {months} мес; } function CompetitorDrillIn({ ids, colSpan, }: { ids: number[]; colSpan: number; }) { const [showAll, setShowAll] = useState(false); const visible = showAll ? ids : ids.slice(0, COMPETITOR_PREVIEW_LIMIT); const hidden = ids.length - COMPETITOR_PREVIEW_LIMIT; return (
ЖК-источники: {visible.map((id) => ( {id} ))} {!showAll && hidden > 0 && ( )} {showAll && ids.length > COMPETITOR_PREVIEW_LIMIT && ( )}
); } function TopLayoutsTable({ rows, oversoldPeriodLabel, }: { rows: TopLayoutRow[]; oversoldPeriodLabel: string; }) { const [expandedRows, setExpandedRows] = useState>(new Set()); if (rows.length === 0) { return (
Данных недостаточно для ранжирования планировок
); } function toggleRow(signature: string) { setExpandedRows((prev) => { const next = new Set(prev); if (next.has(signature)) { next.delete(signature); } else { next.add(signature); } return next; }); } const TOTAL_COLS = 9; // # Тип Площадь Скорость СрПлощадь СрЦена СрокПродажи Продано Chevron const headers = [ "#", "Тип", "Площадь", "Скорость / мес", "Средн. площадь, м²", "Средн. цена, ₽/м²", "Срок продажи (мес)", "Продано, %", "", ]; return (
Топ планировок ({rows.length})
{headers.map((h, idx) => ( ))} {rows.map((row, i) => { const isExpanded = expandedRows.has(row.signature); const months = salesPeriodMonths( row.supply_units_in_radius, row.velocity_per_month, ); const hasCompetitors = row.competitor_obj_ids.length > 0; return ( { if (hasCompetitors) toggleRow(row.signature); }} onKeyDown={(e) => { if ( hasCompetitors && (e.key === "Enter" || e.key === " ") ) { e.preventDefault(); toggleRow(row.signature); } }} tabIndex={hasCompetitors ? 0 : undefined} role={hasCompetitors ? "button" : undefined} aria-expanded={hasCompetitors ? isExpanded : undefined} style={{ background: i % 2 === 0 ? "var(--bg-card)" : "var(--bg-card-alt)", borderBottom: isExpanded ? "none" : "1px solid var(--border-soft)", cursor: hasCompetitors ? "pointer" : "default", }} title={ hasCompetitors ? "Нажмите, чтобы увидеть ЖК-источники" : undefined } > {isExpanded && hasCompetitors && ( )} ); })}
{h}
{row.rank} {ROOM_BUCKET_LABELS[row.room_bucket] ?? row.room_bucket} {row.area_bin} м² {row.velocity_per_month.toFixed(2)} {row.avg_area_m2.toFixed(1)} {row.avg_price_per_m2_rub != null ? Math.round(row.avg_price_per_m2_rub).toLocaleString( "ru-RU", ) : "—"} {row.sold_pct_of_supply != null ? ( {`${(row.sold_pct_of_supply ?? 0).toFixed(0)}%`} {row.is_oversold && ( >100% )} ) : ( "—" )} {hasCompetitors && ( )}
); } function UnitMixBar({ mix }: { mix: LayoutTzMixRow[] }) { const COLORS = [ "var(--viz-1)", "var(--viz-5)", "var(--viz-3)", "var(--viz-4)", "var(--viz-2)", "var(--viz-3)", ]; return (
{/* Horizontal stacked bar */}
{mix.map((row, i) => (
))}
{/* Legend */}
{mix.map((row, i) => (
{ROOM_BUCKET_LABELS[row.room_bucket] ?? row.room_bucket} {row.pct} %
))}
); } function MixTable({ mix }: { mix: LayoutTzMixRow[] }) { return ( {["Тип", "Доля, %", "Кол-во квартир", "Ср. площадь, м²"].map((h) => ( ))} {mix.map((row, i) => ( ))}
{h}
{ROOM_BUCKET_LABELS[row.room_bucket] ?? row.room_bucket} {row.pct}% {row.abs_units != null ? row.abs_units.toLocaleString("ru-RU") : "—"} {row.avg_target_area_m2 != null ? row.avg_target_area_m2.toFixed(1) : "—"}
); } function RecommendationCard({ rec, }: { rec: BestLayoutsResponse["recommendation_for_tz"]; }) { const maxPct = rec.mix.length > 0 ? Math.max(...rec.mix.map((r) => r.pct)) : 0; return (
Рекомендация ТЗ
{/* SF-09: предупреждение о перекосе рынка — показываем только если cap не смог применить */} {rec.cap_skipped && (
Рекомендация имеет сильный перекос ({maxPct}% в одном формате) — рынок настолько асимметричен, что cap не применён. Проверьте competitive density и district pipeline.
)} {/* Rationale text — plain text only, no dangerouslySetInnerHTML */}

{rec.rationale_text}

{/* Unit-mix bar chart */} {rec.mix.length > 0 && (
Unit-mix
)} {/* Mix table */} {rec.mix.length > 0 && (
)} {/* Weighted avg price */} {rec.weighted_avg_price_per_m2_rub != null && (
Средневзвешенная цена: {Math.round(rec.weighted_avg_price_per_m2_rub).toLocaleString( "ru-RU", )}{" "} ₽/м²
)} {/* Meta */}
Основано на {rec.based_on_obj_count} ЖК ·{" "} {rec.based_on_total_deals.toLocaleString("ru-RU")} сделках · период{" "} {new Date(rec.data_window_start).toLocaleDateString("ru-RU")} —{" "} {new Date(rec.data_window_end).toLocaleDateString("ru-RU")}
); } // ── Main component ───────────────────────────────────────────────────────────── interface Props { cadNum: string; selectedCompetitorObjIds?: number[]; } export function BestLayoutsBlock({ cadNum, selectedCompetitorObjIds }: Props) { const [radiusKm, setRadiusKm] = useState(1.0); const [timeWindow, setTimeWindow] = useState("last_quarter"); const [targetTotalFlats, setTargetTotalFlats] = useState("300"); const [minVelocity, setMinVelocity] = useState(0.5); const [isPdfLoading, setIsPdfLoading] = useState(false); const [pdfError, setPdfError] = useState(null); // Request that produced the currently shown `data` — PDF must use this, // not live control state (controls may have changed without re-running «Рассчитать»). const [lastRequest, setLastRequest] = useState( null, ); const { mutate, data, isPending, error } = useBestLayouts(cadNum); function buildRequest(): BestLayoutsRequest { const parsed = parseInt(targetTotalFlats, 10); return { radius_km: radiusKm, time_window: timeWindow, filter_competitor_obj_ids: selectedCompetitorObjIds && selectedCompetitorObjIds.length > 0 ? selectedCompetitorObjIds : null, min_velocity_per_month: minVelocity, target_total_flats: !Number.isNaN(parsed) && parsed > 0 ? Math.min(Math.max(parsed, 1), 10000) : null, }; } function handleCalculate() { const req = buildRequest(); setLastRequest(req); mutate(req); } async function handleDownloadPdf() { // Use the request that produced the shown `data`, not live control state. if (!lastRequest) return; setIsPdfLoading(true); try { const req = lastRequest; const res = await fetch( `${API_BASE_URL}/api/v1/parcels/${encodeURIComponent(cadNum)}/best-layouts/pdf`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(req), }, ); if (!res.ok) { throw new Error(`Ошибка генерации PDF: ${res.status}`); } const blob = await res.blob(); const url = URL.createObjectURL(blob); const a = document.createElement("a"); a.href = url; a.download = `tz-layout-${cadNum.replace(/:/g, "-")}-${new Date().toISOString().split("T")[0]}.pdf`; document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(url); } catch (e) { setPdfError(e instanceof Error ? e.message : "Не удалось скачать PDF"); } finally { setIsPdfLoading(false); } } return (
{/* Header */}
Анализ планировок data-driven ТЗ на проектирование
{data && (
{pdfError && ( PDF: {pdfError} )}
)}
{/* Controls */}
{/* Radius slider */}
setRadiusKm(parseFloat(e.target.value))} style={{ width: 160, accentColor: "var(--accent)" }} />
{/* Min velocity slider */}
setMinVelocity(parseFloat(e.target.value))} style={{ width: 160, accentColor: "var(--accent)" }} />
{/* Time window radio */}
Период анализа
{(Object.keys(TIME_WINDOW_LABELS) as TimeWindow[]).map((tw) => ( ))}
{/* Target flats input */}
setTargetTotalFlats(e.target.value)} placeholder="300" style={{ padding: "5px 10px", border: "1px solid var(--border-strong)", borderRadius: 6, fontSize: 13, width: 110, color: "var(--fg-primary)", }} />
{/* Calculate button */} {selectedCompetitorObjIds && selectedCompetitorObjIds.length > 0 && ( Фильтр: {selectedCompetitorObjIds.length} выбр. ЖК )}
{/* Content area */}
{/* Loading skeleton */} {isPending && (
{[80, 60, 40].map((w) => (
))}
)} {/* Error */} {error && !isPending && (
{error instanceof Error ? error.message : "Ошибка получения данных"}
)} {/* Results */} {data && !isPending && (
)} {/* Idle state */} {!isPending && !error && !data && (
Настройте параметры и нажмите «Рассчитать»
)}
); }