Merge pull request 'feat(ptica): Отчёты + Сравнение cockpit tabs [PR#3]' (#1833) from feat/ptica-cockpit-pr3-reports-compare into main
Reviewed-on: #1833
This commit is contained in:
commit
708d8b5aed
4 changed files with 1123 additions and 9 deletions
|
|
@ -27,7 +27,8 @@ import type { PticaTab } from "@/components/site-finder/ptica/PticaShell";
|
|||
import { PticaHero } from "@/components/site-finder/ptica/PticaHero";
|
||||
import { DevelopmentScan } from "@/components/site-finder/ptica/DevelopmentScan";
|
||||
import { PticaScenarios } from "@/components/site-finder/ptica/scenarios/PticaScenarios";
|
||||
import { PticaPlaceholderPanel } from "@/components/site-finder/ptica/PticaPlaceholderPanel";
|
||||
import { PticaReports } from "@/components/site-finder/ptica/reports/PticaReports";
|
||||
import { PticaCompare } from "@/components/site-finder/ptica/compare/PticaCompare";
|
||||
|
||||
interface Props {
|
||||
cad: string;
|
||||
|
|
@ -93,12 +94,8 @@ export function PticaPageContent({ cad }: Props) {
|
|||
onHorizonChange={setHorizon}
|
||||
/>
|
||||
)}
|
||||
{(tab === "reports" || tab === "compare") && (
|
||||
<PticaPlaceholderPanel
|
||||
label="В разработке"
|
||||
hint="Раздел появится в следующем релизе платформы ПТИЦА."
|
||||
/>
|
||||
)}
|
||||
{tab === "reports" && <PticaReports cad={cad} analysis={analysis} />}
|
||||
{tab === "compare" && <PticaCompare cad={cad} />}
|
||||
</PticaShell>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1064,6 +1064,336 @@
|
|||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* ===================== REPORTS (Отчёты) ===================== */
|
||||
.reportsRoot,
|
||||
.compareRoot {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
.reportGrid {
|
||||
display: grid;
|
||||
grid-template-columns: 1.2fr 1fr;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
/* export-format cards */
|
||||
.exportGrid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 10px;
|
||||
}
|
||||
.exportCard {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
padding: 14px;
|
||||
border: var(--line) solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--surface-2);
|
||||
color: inherit;
|
||||
transition:
|
||||
border-color 0.15s,
|
||||
box-shadow 0.15s,
|
||||
opacity 0.15s;
|
||||
}
|
||||
.exportCard:hover:not(:disabled):not(.exportCardDisabled) {
|
||||
border-color: var(--accent-cyan);
|
||||
box-shadow: 0 0 14px var(--glow);
|
||||
}
|
||||
.exportCard:disabled {
|
||||
opacity: 0.55;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.exportCardDisabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.fmt {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
flex: none;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border-radius: var(--radius-xs);
|
||||
font-family: var(--font-mono);
|
||||
font-weight: 700;
|
||||
font-size: 11px;
|
||||
color: #fff;
|
||||
}
|
||||
.fmtPdf {
|
||||
background: var(--accent-red);
|
||||
}
|
||||
.fmtCsv {
|
||||
background: var(--accent-green);
|
||||
}
|
||||
.fmtDocx {
|
||||
background: var(--accent-blue);
|
||||
}
|
||||
.fmtPptx {
|
||||
background: var(--accent-orange);
|
||||
}
|
||||
.fmtMuted {
|
||||
background: var(--surface-muted);
|
||||
color: var(--text-soft);
|
||||
}
|
||||
.exportMeta {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
.exportTitle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
font-weight: 600;
|
||||
font-size: 12px;
|
||||
color: var(--text);
|
||||
}
|
||||
.exportDesc {
|
||||
font-size: 10px;
|
||||
color: var(--text-soft);
|
||||
}
|
||||
.exportTag {
|
||||
padding: 1px 6px;
|
||||
border-radius: 999px;
|
||||
font-size: 8px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--accent-yellow);
|
||||
border: var(--line) solid currentColor;
|
||||
}
|
||||
.exportTagSoon {
|
||||
padding: 1px 6px;
|
||||
border-radius: 999px;
|
||||
font-size: 8px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--text-soft);
|
||||
border: var(--line) solid var(--border);
|
||||
}
|
||||
.exportIco {
|
||||
flex: none;
|
||||
color: var(--text-soft);
|
||||
}
|
||||
.reportError {
|
||||
margin: 12px 0 0;
|
||||
font-size: 11px;
|
||||
color: var(--accent-red);
|
||||
}
|
||||
|
||||
/* report-section checklist */
|
||||
.tocList {
|
||||
display: grid;
|
||||
gap: 2px;
|
||||
}
|
||||
.tocItem {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 8px 0;
|
||||
border-bottom: var(--line) dashed var(--border-faint);
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.tocIndex {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10px;
|
||||
color: var(--text-soft);
|
||||
flex: none;
|
||||
}
|
||||
.tocLabel {
|
||||
flex: 1;
|
||||
color: var(--text);
|
||||
}
|
||||
.tocEyebrow {
|
||||
font-size: 8px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--text-soft);
|
||||
}
|
||||
.sourceLine {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
margin-top: 14px;
|
||||
font-size: 9px;
|
||||
color: var(--text-soft);
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
/* ===================== COMPARE (Сравнение) ===================== */
|
||||
.compareBar {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.compareInput {
|
||||
flex: 0 1 320px;
|
||||
padding: 9px 12px;
|
||||
background: var(--surface-2);
|
||||
border: var(--line) solid var(--border);
|
||||
border-radius: var(--radius-xs);
|
||||
color: var(--text);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 12px;
|
||||
outline: none;
|
||||
}
|
||||
.compareInput:focus {
|
||||
border-color: var(--accent-cyan);
|
||||
}
|
||||
.compareInput:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.compareAddBtn {
|
||||
padding: 9px 16px;
|
||||
border: var(--line) solid var(--accent-blue);
|
||||
background: transparent;
|
||||
color: var(--text);
|
||||
border-radius: var(--radius-xs);
|
||||
text-transform: uppercase;
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.08em;
|
||||
white-space: nowrap;
|
||||
transition:
|
||||
background 0.15s,
|
||||
opacity 0.15s;
|
||||
}
|
||||
.compareAddBtn:hover:not(:disabled) {
|
||||
background: var(--accent-blue);
|
||||
color: #fff;
|
||||
}
|
||||
.compareAddBtn:disabled {
|
||||
opacity: 0.45;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.chipRow {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
margin-left: 4px;
|
||||
}
|
||||
.chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 4px 10px;
|
||||
border: var(--line) solid var(--border);
|
||||
border-radius: 999px;
|
||||
font-size: 10px;
|
||||
}
|
||||
.chip .mono {
|
||||
font-size: 10px;
|
||||
}
|
||||
.chipPinned {
|
||||
border-color: var(--accent-cyan);
|
||||
}
|
||||
.chipTag {
|
||||
font-size: 8px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--accent-cyan);
|
||||
}
|
||||
.chipRemove {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border: none;
|
||||
background: none;
|
||||
padding: 0;
|
||||
color: var(--text-soft);
|
||||
}
|
||||
.chipRemove:hover {
|
||||
color: var(--text);
|
||||
}
|
||||
.compareInputError {
|
||||
margin: 10px 0 0;
|
||||
font-size: 11px;
|
||||
color: var(--accent-red);
|
||||
}
|
||||
.compareHint {
|
||||
margin: 0;
|
||||
padding: 8px 2px;
|
||||
font-size: 11px;
|
||||
line-height: 1.5;
|
||||
color: var(--text-soft);
|
||||
}
|
||||
|
||||
.compareTableWrap {
|
||||
overflow-x: auto;
|
||||
}
|
||||
.compareTable {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 11px;
|
||||
min-width: 560px;
|
||||
}
|
||||
.compareTable th,
|
||||
.compareTable td {
|
||||
padding: 9px 11px;
|
||||
text-align: right;
|
||||
border-bottom: var(--line) solid var(--border-faint);
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
.compareTable th:first-child,
|
||||
.compareTable td:first-child {
|
||||
text-align: left;
|
||||
color: var(--text-muted);
|
||||
font-family: var(--font-ui);
|
||||
position: sticky;
|
||||
left: 0;
|
||||
background: var(--surface);
|
||||
}
|
||||
.compareTable thead th {
|
||||
color: var(--text-strong);
|
||||
font-family: var(--font-ui);
|
||||
font-size: 11px;
|
||||
vertical-align: top;
|
||||
}
|
||||
.compareTable thead th .mono {
|
||||
font-size: 11px;
|
||||
word-break: break-all;
|
||||
}
|
||||
.compareDistrict {
|
||||
display: block;
|
||||
margin-top: 3px;
|
||||
font-family: var(--font-ui);
|
||||
font-weight: 400;
|
||||
font-size: 9px;
|
||||
color: var(--text-soft);
|
||||
}
|
||||
.compareBest {
|
||||
color: var(--accent-green);
|
||||
font-weight: 600;
|
||||
}
|
||||
.compareVerdictRow td {
|
||||
background: var(--surface-2);
|
||||
}
|
||||
.verdictBuy {
|
||||
color: var(--accent-green);
|
||||
font-weight: 600;
|
||||
}
|
||||
.verdictWatch {
|
||||
color: var(--accent-yellow);
|
||||
font-weight: 600;
|
||||
}
|
||||
.verdictSkip {
|
||||
color: var(--accent-red);
|
||||
font-weight: 600;
|
||||
}
|
||||
.cellMuted {
|
||||
color: var(--text-soft);
|
||||
}
|
||||
.cellError {
|
||||
color: var(--accent-red);
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
/* ===================== RESPONSIVE ===================== */
|
||||
@media (max-width: 1500px) {
|
||||
.hero {
|
||||
|
|
@ -1080,7 +1410,8 @@
|
|||
.scanGrid {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
.forecastGrid {
|
||||
.forecastGrid,
|
||||
.reportGrid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
|
@ -1102,7 +1433,8 @@
|
|||
display: none;
|
||||
}
|
||||
.scenarioGrid,
|
||||
.forecastGrid {
|
||||
.forecastGrid,
|
||||
.exportGrid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,406 @@
|
|||
"use client";
|
||||
|
||||
/**
|
||||
* PticaCompare — ПТИЦА «Сравнение» tab (PR#3). Dark-styled, but REUSES the real
|
||||
* shortlist + compare data flow rather than inventing data:
|
||||
*
|
||||
* • Shortlist persistence — compare-shortlist.ts (gd_compare_shortlist in
|
||||
* localStorage, isValidCad, COMPARE_MAX). The CURRENT cad is auto-pinned and
|
||||
* can't be removed; the rest are user-managed chips.
|
||||
* • Per-cad metrics — ParcelCompareLoader (the SAME headless loader the
|
||||
* /site-finder/compare page uses), which runs useParcelAnalyzeQuery per cad
|
||||
* and lifts a flattened CompareColumn up. Cache is shared with the analysis
|
||||
* page, so the current parcel is served warm.
|
||||
*
|
||||
* The table is a dark reimplementation (CompareTable ships light tokens) but
|
||||
* consumes the identical CompareColumn shape and the same winner-highlight +
|
||||
* verdict-colour logic. Embedded — not a link-fallback.
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { X } from "lucide-react";
|
||||
|
||||
import { ParcelCompareLoader } from "@/components/site-finder/compare/ParcelCompareLoader";
|
||||
import type { CompareColumn } from "@/components/site-finder/compare/CompareTable";
|
||||
import {
|
||||
COMPARE_MAX,
|
||||
getShortlist,
|
||||
isValidCad,
|
||||
saveShortlist,
|
||||
} from "@/lib/compare-shortlist";
|
||||
import type { GateVerdictLabel } from "@/types/site-finder";
|
||||
import styles from "@/app/site-finder/analysis/[cad]/ptica/ptica.module.css";
|
||||
|
||||
interface Props {
|
||||
cad: string;
|
||||
}
|
||||
|
||||
// ── Metric-row model (mirrors compare/CompareTable winner logic) ──────────────
|
||||
|
||||
type WinnerDir = "high" | "low" | "none";
|
||||
|
||||
interface MetricRow {
|
||||
key: string;
|
||||
label: string;
|
||||
winner: WinnerDir;
|
||||
value: (c: CompareColumn) => number | null;
|
||||
render: (c: CompareColumn) => React.ReactNode;
|
||||
}
|
||||
|
||||
const DASH = "—";
|
||||
|
||||
const CONFIDENCE_RU: Record<"high" | "medium" | "low", string> = {
|
||||
high: "высокая",
|
||||
medium: "средняя",
|
||||
low: "низкая",
|
||||
};
|
||||
|
||||
// Verdict → semantic colour class (green/amber/red like the prototype).
|
||||
const VERDICT_CLASS: Record<GateVerdictLabel, string> = {
|
||||
Можно: styles.verdictBuy,
|
||||
Нельзя: styles.verdictSkip,
|
||||
"С ограничениями": styles.verdictWatch,
|
||||
"Нужна проверка": styles.verdictWatch,
|
||||
};
|
||||
|
||||
function fmtScore(v: number | null | undefined): string {
|
||||
return v != null && Number.isFinite(v) ? v.toFixed(1) : DASH;
|
||||
}
|
||||
|
||||
function fmtPrice(v: number | null | undefined): string {
|
||||
return v != null && Number.isFinite(v)
|
||||
? `${Math.round(v).toLocaleString("ru")} ₽`
|
||||
: DASH;
|
||||
}
|
||||
|
||||
function fmtPipeline(v: number | null | undefined): string {
|
||||
return v != null && Number.isFinite(v)
|
||||
? `${Math.round(v).toLocaleString("ru")} кв.`
|
||||
: DASH;
|
||||
}
|
||||
|
||||
const METRIC_ROWS: MetricRow[] = [
|
||||
{
|
||||
key: "verdict",
|
||||
label: "Вердикт МКД",
|
||||
winner: "none",
|
||||
value: () => null,
|
||||
render: (c) =>
|
||||
c.verdictLabel ? (
|
||||
<span className={VERDICT_CLASS[c.verdictLabel]}>{c.verdictLabel}</span>
|
||||
) : (
|
||||
DASH
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "score",
|
||||
label: "Инвест-балл",
|
||||
winner: "high",
|
||||
value: (c) => c.score ?? null,
|
||||
render: (c) => (
|
||||
<>
|
||||
{fmtScore(c.score)}
|
||||
{c.scoreLabel ? (
|
||||
<span className={styles.cellMuted}> · {c.scoreLabel}</span>
|
||||
) : null}
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "velocity",
|
||||
label: "Скорость продаж",
|
||||
winner: "high",
|
||||
// velocity_score=0 при velocity_data_available=false — это «нет данных»,
|
||||
// а не реальный 0 → исключаем из подсветки и рендерим «—».
|
||||
value: (c) =>
|
||||
c.velocityDataAvailable === false ? null : (c.velocityScore ?? null),
|
||||
render: (c) =>
|
||||
c.velocityDataAvailable !== false &&
|
||||
c.velocityScore != null &&
|
||||
Number.isFinite(c.velocityScore)
|
||||
? `${Math.round(c.velocityScore * 100)}%`
|
||||
: DASH,
|
||||
},
|
||||
{
|
||||
key: "price",
|
||||
label: "Медиана ₽ / м²",
|
||||
// Дешевле ≠ лучше для девелопера — валентность неоднозначна, не подсвечиваем.
|
||||
winner: "none",
|
||||
value: (c) => c.medianPricePerM2 ?? null,
|
||||
render: (c) => fmtPrice(c.medianPricePerM2),
|
||||
},
|
||||
{
|
||||
key: "pipeline",
|
||||
label: "Пайплайн 24 мес",
|
||||
// Больше конкурирующего предложения → хуже. Меньше выигрывает.
|
||||
winner: "low",
|
||||
value: (c) => c.pipeline24mo ?? null,
|
||||
render: (c) => fmtPipeline(c.pipeline24mo),
|
||||
},
|
||||
{
|
||||
key: "confidence",
|
||||
label: "Достоверность",
|
||||
winner: "high",
|
||||
value: (c) => c.confidenceValue ?? null,
|
||||
render: (c) =>
|
||||
c.confidenceLabel ? (
|
||||
<>
|
||||
{c.confidenceValue != null
|
||||
? `${Math.round(c.confidenceValue * 100)}% · `
|
||||
: ""}
|
||||
{CONFIDENCE_RU[c.confidenceLabel]}
|
||||
</>
|
||||
) : (
|
||||
DASH
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
/** Cads that win a comparable row (ties win jointly); empty when not comparable. */
|
||||
function winnersFor(row: MetricRow, columns: CompareColumn[]): Set<string> {
|
||||
if (row.winner === "none") return new Set();
|
||||
const vals = columns
|
||||
.map((c) => ({ cad: c.cad, v: row.value(c) }))
|
||||
.filter((x): x is { cad: string; v: number } => x.v != null);
|
||||
if (vals.length < 2) return new Set();
|
||||
const best =
|
||||
row.winner === "high"
|
||||
? Math.max(...vals.map((x) => x.v))
|
||||
: Math.min(...vals.map((x) => x.v));
|
||||
return new Set(vals.filter((x) => x.v === best).map((x) => x.cad));
|
||||
}
|
||||
|
||||
// Short tail of a cad for compact column headers («…0204016:10»).
|
||||
function shortCad(cad: string): string {
|
||||
const parts = cad.split(":");
|
||||
return parts.length > 2 ? `…${parts.slice(2).join(":")}` : cad;
|
||||
}
|
||||
|
||||
// ── Component ─────────────────────────────────────────────────────────────────
|
||||
|
||||
export function PticaCompare({ cad }: Props) {
|
||||
// Extra cads (beyond the pinned current one), hydrated from localStorage after
|
||||
// mount (SSR-safe: first client render matches the server's empty list).
|
||||
const [extras, setExtras] = useState<string[]>([]);
|
||||
const [hydrated, setHydrated] = useState(false);
|
||||
const [input, setInput] = useState("");
|
||||
const [inputError, setInputError] = useState<string | null>(null);
|
||||
const [columns, setColumns] = useState<Record<string, CompareColumn>>({});
|
||||
|
||||
useEffect(() => {
|
||||
// The current cad is always pinned first; the shortlist supplies the rest.
|
||||
setExtras(getShortlist().filter((c) => c !== cad));
|
||||
setHydrated(true);
|
||||
}, [cad]);
|
||||
|
||||
// Persist the union (current pinned + extras) so it survives navigation, but
|
||||
// only after hydration so the initial empty state never clobbers a saved list.
|
||||
useEffect(() => {
|
||||
if (hydrated) saveShortlist([cad, ...extras]);
|
||||
}, [cad, extras, hydrated]);
|
||||
|
||||
const handleResult = useCallback((col: CompareColumn) => {
|
||||
setColumns((prev) => {
|
||||
const existing = prev[col.cad];
|
||||
if (
|
||||
existing &&
|
||||
existing.status === col.status &&
|
||||
existing.errorMsg === col.errorMsg &&
|
||||
existing.districtName === col.districtName &&
|
||||
existing.verdictLabel === col.verdictLabel &&
|
||||
existing.score === col.score &&
|
||||
existing.scoreLabel === col.scoreLabel &&
|
||||
existing.velocityScore === col.velocityScore &&
|
||||
existing.velocityDataAvailable === col.velocityDataAvailable &&
|
||||
existing.medianPricePerM2 === col.medianPricePerM2 &&
|
||||
existing.pipeline24mo === col.pipeline24mo &&
|
||||
existing.confidenceLabel === col.confidenceLabel &&
|
||||
existing.confidenceValue === col.confidenceValue
|
||||
) {
|
||||
return prev;
|
||||
}
|
||||
return { ...prev, [col.cad]: col };
|
||||
});
|
||||
}, []);
|
||||
|
||||
// The pinned current cad counts toward COMPARE_MAX.
|
||||
const atMax = 1 + extras.length >= COMPARE_MAX;
|
||||
|
||||
function addCad(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
const next = input.trim();
|
||||
if (!isValidCad(next)) {
|
||||
setInputError("Неверный формат. Ожидается 66:41:0204016:10.");
|
||||
return;
|
||||
}
|
||||
if (next === cad || extras.includes(next)) {
|
||||
setInputError("Этот участок уже в сравнении.");
|
||||
return;
|
||||
}
|
||||
if (atMax) {
|
||||
setInputError(`Можно сравнить не более ${COMPARE_MAX} участков.`);
|
||||
return;
|
||||
}
|
||||
setExtras((prev) => [...prev, next]);
|
||||
setInput("");
|
||||
setInputError(null);
|
||||
}
|
||||
|
||||
const removeCad = useCallback((target: string) => {
|
||||
setExtras((prev) => prev.filter((c) => c !== target));
|
||||
setColumns((prev) => {
|
||||
const nextCols = { ...prev };
|
||||
delete nextCols[target];
|
||||
return nextCols;
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Ordered cads: pinned current first, then the user-added extras.
|
||||
const allCads = useMemo(() => [cad, ...extras], [cad, extras]);
|
||||
|
||||
const orderedColumns: CompareColumn[] = useMemo(
|
||||
() =>
|
||||
allCads.map((c) => columns[c] ?? { cad: c, status: "loading" as const }),
|
||||
[allCads, columns],
|
||||
);
|
||||
|
||||
const winnerByRow = useMemo(
|
||||
() =>
|
||||
new Map<string, Set<string>>(
|
||||
METRIC_ROWS.map((row) => [row.key, winnersFor(row, orderedColumns)]),
|
||||
),
|
||||
[orderedColumns],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={styles.compareRoot}>
|
||||
<div className={styles.viewHead}>
|
||||
<h2>
|
||||
Сравнение участков
|
||||
<span className={styles.viewSub}>
|
||||
shortlist · до {COMPARE_MAX} объектов · COMPARE
|
||||
</span>
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
{/* Headless loaders — one per cad, lift analyze metrics up (real flow). */}
|
||||
{allCads.map((c) => (
|
||||
<ParcelCompareLoader key={c} cad={c} onResult={handleResult} />
|
||||
))}
|
||||
|
||||
{/* ── Add bar + chips ──────────────────────────────────────────────── */}
|
||||
<div className={styles.panel}>
|
||||
<form className={styles.compareBar} onSubmit={addCad}>
|
||||
<input
|
||||
type="text"
|
||||
value={input}
|
||||
onChange={(e) => {
|
||||
setInput(e.target.value);
|
||||
setInputError(null);
|
||||
}}
|
||||
placeholder="Добавить кадастровый номер · 66:41:…"
|
||||
aria-label="Кадастровый номер для сравнения"
|
||||
disabled={atMax}
|
||||
className={styles.compareInput}
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!input.trim() || atMax}
|
||||
className={styles.compareAddBtn}
|
||||
>
|
||||
Добавить
|
||||
</button>
|
||||
|
||||
<div className={styles.chipRow}>
|
||||
<span className={`${styles.chip} ${styles.chipPinned}`}>
|
||||
<span className="mono">{cad}</span>
|
||||
<span className={styles.chipTag}>текущий</span>
|
||||
</span>
|
||||
{extras.map((c) => (
|
||||
<span key={c} className={styles.chip}>
|
||||
<span className="mono">{c}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeCad(c)}
|
||||
aria-label={`Убрать ${c} из сравнения`}
|
||||
className={styles.chipRemove}
|
||||
>
|
||||
<X size={11} strokeWidth={1.6} />
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</form>
|
||||
{inputError && (
|
||||
<p className={styles.compareInputError} role="alert">
|
||||
{inputError}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── Comparison table ─────────────────────────────────────────────── */}
|
||||
<div className={`${styles.panel} ${styles.compareTableWrap}`}>
|
||||
{extras.length === 0 ? (
|
||||
<p className={styles.compareHint}>
|
||||
Добавьте ещё участки, чтобы поставить метрики рядом. Лучшее значение
|
||||
в строке подсвечивается; список сохраняется между сессиями.
|
||||
</p>
|
||||
) : (
|
||||
<table className={styles.compareTable}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Показатель</th>
|
||||
{orderedColumns.map((c) => (
|
||||
<th key={c.cad} title={c.cad}>
|
||||
<span className="mono">{shortCad(c.cad)}</span>
|
||||
{c.districtName && c.districtName !== DASH ? (
|
||||
<span className={styles.compareDistrict}>
|
||||
{c.districtName}
|
||||
</span>
|
||||
) : null}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{METRIC_ROWS.map((row) => {
|
||||
const winners = winnerByRow.get(row.key) ?? new Set<string>();
|
||||
const isVerdict = row.key === "verdict";
|
||||
return (
|
||||
<tr
|
||||
key={row.key}
|
||||
className={isVerdict ? styles.compareVerdictRow : undefined}
|
||||
>
|
||||
<td>{row.label}</td>
|
||||
{orderedColumns.map((c) => {
|
||||
const best = winners.has(c.cad);
|
||||
return (
|
||||
<td
|
||||
key={c.cad}
|
||||
className={best ? styles.compareBest : undefined}
|
||||
>
|
||||
{c.status === "loading" ? (
|
||||
<span className={styles.cellMuted}>…</span>
|
||||
) : c.status === "error" ? (
|
||||
<span
|
||||
className={styles.cellError}
|
||||
title={c.errorMsg ?? "Ошибка анализа"}
|
||||
>
|
||||
ошибка
|
||||
</span>
|
||||
) : (
|
||||
row.render(c)
|
||||
)}
|
||||
</td>
|
||||
);
|
||||
})}
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,379 @@
|
|||
"use client";
|
||||
|
||||
/**
|
||||
* PticaReports — ПТИЦА «Отчёты» tab (PR#3). Dark-styled export cockpit that
|
||||
* REUSES the real export infrastructure rather than faking downloads:
|
||||
*
|
||||
* • PDF снимок — GET /api/v1/parcels/{cad}/snapshot.pdf (same endpoint as
|
||||
* analysis/ExportButtons → blob → triggerDownload).
|
||||
* • CSV сырьё — client-side CSV generation from the analyze response
|
||||
* (replicating ExportButtons' buildCsvRows / escapeCsvCell logic; no
|
||||
* backend endpoint, no hardcoded URL).
|
||||
* • §22 .docx / .pptx — GET /api/v1/parcels/{cad}/forecast/export?format=…
|
||||
* (same endpoint as ForecastExportButtons). Gated on the async forecast
|
||||
* being ready (useParcelForecastQuery): until then the cards are disabled
|
||||
* with an honest «готовится» tag.
|
||||
*
|
||||
* Full-report DOCX / PPTX (the prototype's editable-Word / presentation cards)
|
||||
* have NO backend endpoint — the only Word/PowerPoint export the backend ships
|
||||
* is the §22 forecast above. So those two cards render DISABLED with a «скоро»
|
||||
* tag (honest — we never trigger a download that 404s).
|
||||
*
|
||||
* The «Состав отчёта» list is a visual checklist of the PDF snapshot's sections
|
||||
* (matches the prototype TOC); the PDF snapshot is the real multi-section
|
||||
* deliverable behind it.
|
||||
*/
|
||||
|
||||
import { useState } from "react";
|
||||
import { FileText, Download, Presentation, FileType, Lock } from "lucide-react";
|
||||
|
||||
import { API_BASE_URL } from "@/lib/api";
|
||||
import { triggerDownload } from "@/lib/download";
|
||||
import { useParcelForecastQuery } from "@/lib/site-finder-api";
|
||||
import type { ParcelAnalysis } from "@/types/site-finder";
|
||||
import styles from "@/app/site-finder/analysis/[cad]/ptica/ptica.module.css";
|
||||
|
||||
interface Props {
|
||||
cad: string;
|
||||
analysis: ParcelAnalysis;
|
||||
}
|
||||
|
||||
// ── CSV generation (replicates analysis/ExportButtons row + escape logic) ─────
|
||||
|
||||
function buildCsvRows(a: ParcelAnalysis): string[][] {
|
||||
return [
|
||||
["Поле", "Значение"],
|
||||
["Кадастровый номер", a.cad_num],
|
||||
["Балл", String(a.score)],
|
||||
["Оценка", a.score_label ?? ""],
|
||||
["Вердикт МКД", a.gate_verdict?.verdict_label ?? ""],
|
||||
["Район", a.district?.district_name ?? ""],
|
||||
[
|
||||
"Медиана ₽/м²",
|
||||
a.district?.median_price_per_m2
|
||||
? String(a.district.median_price_per_m2)
|
||||
: "",
|
||||
],
|
||||
[
|
||||
"Скорость продаж",
|
||||
a.velocity?.velocity_data_available !== false &&
|
||||
a.velocity?.velocity_score != null
|
||||
? String(a.velocity.velocity_score)
|
||||
: "",
|
||||
],
|
||||
[
|
||||
"Пайплайн 24 мес (кв.)",
|
||||
a.pipeline_24mo?.flats_total != null
|
||||
? String(a.pipeline_24mo.flats_total)
|
||||
: "",
|
||||
],
|
||||
[
|
||||
"Достоверность",
|
||||
a.confidence != null ? String(a.confidence) : (a.confidence_label ?? ""),
|
||||
],
|
||||
["POI (кол-во)", String(a.poi_count)],
|
||||
["Конкурентов", String(a.competitors.length)],
|
||||
];
|
||||
}
|
||||
|
||||
// CSV-injection mitigation (OWASP / CWE-1236) — same guard as ExportButtons.
|
||||
function escapeCsvCell(cell: string): string {
|
||||
let safe = cell;
|
||||
if (/^[=+\-@\t\r]/.test(safe)) {
|
||||
safe = "'" + safe;
|
||||
}
|
||||
const needsQuotes = /[",\n\r]/.test(safe);
|
||||
if (needsQuotes) {
|
||||
return `"${safe.replace(/"/g, '""')}"`;
|
||||
}
|
||||
return safe;
|
||||
}
|
||||
|
||||
function generateCsvBlob(rows: string[][]): Blob {
|
||||
const csvContent = rows
|
||||
.map((row) => row.map(escapeCsvCell).join(","))
|
||||
.join("\r\n");
|
||||
// UTF-8 BOM → Excel-friendly (ui-microcopy export rule).
|
||||
return new Blob(["" + csvContent], { type: "text/csv;charset=utf-8" });
|
||||
}
|
||||
|
||||
// Cadastral numbers contain ":" — illegal/awkward in filenames on some OSes.
|
||||
function sanitizeCad(cad: string): string {
|
||||
return cad.replace(/:/g, "_");
|
||||
}
|
||||
|
||||
// ── Report-section checklist (mirrors the PDF snapshot's TOC) ─────────────────
|
||||
|
||||
interface ReportSection {
|
||||
label: string;
|
||||
eyebrow: string;
|
||||
}
|
||||
|
||||
const REPORT_SECTIONS: ReportSection[] = [
|
||||
{ label: "Паспорт участка", eyebrow: "PASSPORT" },
|
||||
{ label: "Градостроительный потенциал", eyebrow: "BUILDABILITY" },
|
||||
{ label: "Инженерная обеспеченность", eyebrow: "UTILITIES" },
|
||||
{ label: "Рынок и конкуренты", eyebrow: "MARKET" },
|
||||
{ label: "Прогноз §22", eyebrow: "FORECAST" },
|
||||
{ label: "Финансовая модель", eyebrow: "FINANCE" },
|
||||
];
|
||||
|
||||
// ── Component ─────────────────────────────────────────────────────────────────
|
||||
|
||||
type DownloadKind = "pdf" | "csv" | "forecast-docx" | "forecast-pptx";
|
||||
|
||||
export function PticaReports({ cad, analysis }: Props) {
|
||||
const [busy, setBusy] = useState<DownloadKind | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const { data: forecastData } = useParcelForecastQuery(cad);
|
||||
const forecastReady = forecastData?.status === "ready";
|
||||
|
||||
async function handlePdf() {
|
||||
setBusy("pdf");
|
||||
setError(null);
|
||||
try {
|
||||
const res = await fetch(
|
||||
`${API_BASE_URL}/api/v1/parcels/${encodeURIComponent(
|
||||
cad,
|
||||
)}/snapshot.pdf`,
|
||||
);
|
||||
if (!res.ok) {
|
||||
throw new Error(`Ошибка сервера ${res.status}`);
|
||||
}
|
||||
const blob = await res.blob();
|
||||
triggerDownload(blob, `gendesign_${sanitizeCad(cad)}.pdf`);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Ошибка загрузки PDF");
|
||||
} finally {
|
||||
setBusy(null);
|
||||
}
|
||||
}
|
||||
|
||||
function handleCsv() {
|
||||
setBusy("csv");
|
||||
setError(null);
|
||||
try {
|
||||
const rows = buildCsvRows(analysis);
|
||||
triggerDownload(
|
||||
generateCsvBlob(rows),
|
||||
`gendesign_${sanitizeCad(cad)}.csv`,
|
||||
);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Ошибка генерации CSV");
|
||||
} finally {
|
||||
setBusy(null);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleForecastExport(format: "docx" | "pptx") {
|
||||
const kind: DownloadKind =
|
||||
format === "docx" ? "forecast-docx" : "forecast-pptx";
|
||||
setBusy(kind);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await fetch(
|
||||
`${API_BASE_URL}/api/v1/parcels/${encodeURIComponent(
|
||||
cad,
|
||||
)}/forecast/export?format=${format}`,
|
||||
);
|
||||
if (!res.ok) {
|
||||
setError(
|
||||
res.status === 404
|
||||
? "Прогноз §22 ещё не готов"
|
||||
: "Не удалось получить отчёт",
|
||||
);
|
||||
return;
|
||||
}
|
||||
const blob = await res.blob();
|
||||
triggerDownload(blob, `gendesign_forecast_${sanitizeCad(cad)}.${format}`);
|
||||
} catch {
|
||||
setError("Не удалось получить отчёт");
|
||||
} finally {
|
||||
setBusy(null);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.reportsRoot}>
|
||||
<div className={styles.viewHead}>
|
||||
<h2>
|
||||
Отчёты
|
||||
<span className={styles.viewSub}>экспорт и генерация · EXPORT</span>
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div className={styles.reportGrid}>
|
||||
{/* ── Export-format cards ───────────────────────────────────────── */}
|
||||
<div className={styles.panel}>
|
||||
<div className={styles.cardHead}>
|
||||
<h3 className={styles.cardTitle}>
|
||||
Экспорт <span className={styles.cardTitleSub}>FORMATS</span>
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<div className={styles.exportGrid}>
|
||||
{/* PDF snapshot — REAL (/snapshot.pdf) */}
|
||||
<button
|
||||
type="button"
|
||||
className={styles.exportCard}
|
||||
onClick={() => void handlePdf()}
|
||||
disabled={busy !== null}
|
||||
aria-label="Скачать снимок отчёта в формате PDF"
|
||||
>
|
||||
<span className={`${styles.fmt} ${styles.fmtPdf}`}>PDF</span>
|
||||
<span className={styles.exportMeta}>
|
||||
<span className={styles.exportTitle}>
|
||||
{busy === "pdf" ? "Готовим…" : "Снимок отчёта"}
|
||||
</span>
|
||||
<span className={styles.exportDesc}>Все секции · PDF</span>
|
||||
</span>
|
||||
<FileText
|
||||
size={16}
|
||||
strokeWidth={1.5}
|
||||
className={styles.exportIco}
|
||||
/>
|
||||
</button>
|
||||
|
||||
{/* CSV — REAL (client-side) */}
|
||||
<button
|
||||
type="button"
|
||||
className={styles.exportCard}
|
||||
onClick={handleCsv}
|
||||
disabled={busy !== null}
|
||||
aria-label="Скачать сырьё в формате CSV"
|
||||
>
|
||||
<span className={`${styles.fmt} ${styles.fmtCsv}`}>CSV</span>
|
||||
<span className={styles.exportMeta}>
|
||||
<span className={styles.exportTitle}>
|
||||
{busy === "csv" ? "Генерация…" : "Сырьё"}
|
||||
</span>
|
||||
<span className={styles.exportDesc}>Метрики анализа</span>
|
||||
</span>
|
||||
<Download
|
||||
size={16}
|
||||
strokeWidth={1.5}
|
||||
className={styles.exportIco}
|
||||
/>
|
||||
</button>
|
||||
|
||||
{/* §22 forecast DOCX — REAL, gated on forecast ready */}
|
||||
<button
|
||||
type="button"
|
||||
className={styles.exportCard}
|
||||
onClick={() => void handleForecastExport("docx")}
|
||||
disabled={busy !== null || !forecastReady}
|
||||
aria-label="Скачать прогноз §22 в формате Word"
|
||||
>
|
||||
<span className={`${styles.fmt} ${styles.fmtDocx}`}>DOCX</span>
|
||||
<span className={styles.exportMeta}>
|
||||
<span className={styles.exportTitle}>
|
||||
{busy === "forecast-docx" ? "Готовим…" : "Прогноз §22"}
|
||||
{!forecastReady && (
|
||||
<span className={styles.exportTag}>готовится</span>
|
||||
)}
|
||||
</span>
|
||||
<span className={styles.exportDesc}>Word · сценарии</span>
|
||||
</span>
|
||||
<FileType
|
||||
size={16}
|
||||
strokeWidth={1.5}
|
||||
className={styles.exportIco}
|
||||
/>
|
||||
</button>
|
||||
|
||||
{/* §22 forecast PPTX — REAL, gated on forecast ready */}
|
||||
<button
|
||||
type="button"
|
||||
className={styles.exportCard}
|
||||
onClick={() => void handleForecastExport("pptx")}
|
||||
disabled={busy !== null || !forecastReady}
|
||||
aria-label="Скачать прогноз §22 в формате презентации"
|
||||
>
|
||||
<span className={`${styles.fmt} ${styles.fmtPptx}`}>PPTX</span>
|
||||
<span className={styles.exportMeta}>
|
||||
<span className={styles.exportTitle}>
|
||||
{busy === "forecast-pptx" ? "Готовим…" : "Прогноз §22"}
|
||||
{!forecastReady && (
|
||||
<span className={styles.exportTag}>готовится</span>
|
||||
)}
|
||||
</span>
|
||||
<span className={styles.exportDesc}>Для инвесткомитета</span>
|
||||
</span>
|
||||
<Presentation
|
||||
size={16}
|
||||
strokeWidth={1.5}
|
||||
className={styles.exportIco}
|
||||
/>
|
||||
</button>
|
||||
|
||||
{/* Full-report DOCX — NO endpoint → honest disabled «скоро» */}
|
||||
<div
|
||||
className={`${styles.exportCard} ${styles.exportCardDisabled}`}
|
||||
aria-disabled="true"
|
||||
>
|
||||
<span className={`${styles.fmt} ${styles.fmtMuted}`}>DOCX</span>
|
||||
<span className={styles.exportMeta}>
|
||||
<span className={styles.exportTitle}>
|
||||
Полный отчёт
|
||||
<span className={styles.exportTagSoon}>скоро</span>
|
||||
</span>
|
||||
<span className={styles.exportDesc}>Редактируемый Word</span>
|
||||
</span>
|
||||
<Lock size={16} strokeWidth={1.5} className={styles.exportIco} />
|
||||
</div>
|
||||
|
||||
{/* Full-report PPTX — NO endpoint → honest disabled «скоро» */}
|
||||
<div
|
||||
className={`${styles.exportCard} ${styles.exportCardDisabled}`}
|
||||
aria-disabled="true"
|
||||
>
|
||||
<span className={`${styles.fmt} ${styles.fmtMuted}`}>PPTX</span>
|
||||
<span className={styles.exportMeta}>
|
||||
<span className={styles.exportTitle}>
|
||||
Презентация
|
||||
<span className={styles.exportTagSoon}>скоро</span>
|
||||
</span>
|
||||
<span className={styles.exportDesc}>Полный отчёт · слайды</span>
|
||||
</span>
|
||||
<Lock size={16} strokeWidth={1.5} className={styles.exportIco} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<p className={styles.reportError} role="alert">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── Report-section checklist ──────────────────────────────────── */}
|
||||
<div className={styles.panel}>
|
||||
<div className={styles.cardHead}>
|
||||
<h3 className={styles.cardTitle}>
|
||||
Состав отчёта{" "}
|
||||
<span className={styles.cardTitleSub}>SECTIONS</span>
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<div className={styles.tocList}>
|
||||
{REPORT_SECTIONS.map((section, i) => (
|
||||
<div key={section.eyebrow} className={styles.tocItem}>
|
||||
<span className={styles.tocIndex}>
|
||||
{String(i + 1).padStart(2, "0")}
|
||||
</span>
|
||||
<span className={styles.tocLabel}>{section.label}</span>
|
||||
<span className={styles.tocEyebrow}>{section.eyebrow}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className={styles.sourceLine}>
|
||||
<span>GENDESIGN · PDF снимок</span>
|
||||
<span className="mono">{cad}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue