Backend (~330 LOC):
- POST /api/v1/parcels/{cad_num}/best-layouts/pdf via WeasyPrint
- services/exporters/layout_tz_pdf.py — HTML template с XSS-escape (html.escape)
для cad_num/parcel_address/rationale_text/time_window
- 5 unit tests (skip on Windows если WeasyPrint deps missing)
Frontend (~800 LOC):
- BestLayoutsBlock.tsx — DataQualityCard + TopLayoutsTable + RecommendationCard
+ PDF download button (Blob → revokeObjectURL)
- useBestLayouts.ts — TanStack useMutation hook
- types/best-layouts.ts — manual TS types
- MarketTab.tsx +4 LOC integration после competitors
Pre-push fixes per review:
- XSS escape user-data в PDF HTML
- sold_pct_of_supply — убрать double ×100 (backend уже 0..100)
- alert() → pdfError state
- isNaN → Number.isNaN
- 7 inline-styles → Tailwind utilities
Phase 2.1 complete. Phase 2.2 (layout_type/balcony_count via B2B Объектив
#52) — follow-up issue.
91 lines
2.7 KiB
TypeScript
91 lines
2.7 KiB
TypeScript
"use client";
|
||
|
||
import type { ParcelAnalysis } from "@/types/site-finder";
|
||
import { SectionLabel } from "@/components/ui/SectionLabel";
|
||
import { EmptyState } from "@/components/ui/EmptyState";
|
||
import { MarketTrendBlock } from "./MarketTrendBlock";
|
||
import { BestLayoutsBlock } from "./BestLayoutsBlock";
|
||
import { CompetitorTable } from "./CompetitorTable";
|
||
import { Pipeline24moBlock } from "./Pipeline24moBlock";
|
||
import { SuccessRecommendationBlock } from "./SuccessRecommendationBlock";
|
||
import { VelocityBlock } from "./VelocityBlock";
|
||
|
||
interface Props {
|
||
data: ParcelAnalysis;
|
||
}
|
||
|
||
export function MarketTab({ data }: Props) {
|
||
const hasTrend = "market_trend" in data;
|
||
const hasRecommendation = "success_recommendation" in data;
|
||
const hasPipeline = data.pipeline_24mo !== undefined;
|
||
const hasVelocity = "velocity" in data;
|
||
const hasAny =
|
||
hasTrend ||
|
||
hasRecommendation ||
|
||
hasPipeline ||
|
||
hasVelocity ||
|
||
data.competitors.length > 0;
|
||
|
||
return (
|
||
<div style={{ display: "flex", flexDirection: "column", gap: 20 }}>
|
||
{/* D4 (#36) — Pipeline 24mo */}
|
||
{data.pipeline_24mo && <Pipeline24moBlock data={data.pipeline_24mo} />}
|
||
|
||
{/* Market trend */}
|
||
{hasTrend && (
|
||
<div
|
||
style={{
|
||
border: "1px solid #e5e7eb",
|
||
borderRadius: 10,
|
||
padding: "14px 18px",
|
||
background: "#fff",
|
||
}}
|
||
>
|
||
<MarketTrendBlock trend={data.market_trend} />
|
||
</div>
|
||
)}
|
||
|
||
{/* D2 (#34) — Velocity-score */}
|
||
{hasVelocity && (
|
||
<div
|
||
style={{
|
||
border: "1px solid #e5e7eb",
|
||
borderRadius: 10,
|
||
padding: "14px 18px",
|
||
background: "#fff",
|
||
}}
|
||
>
|
||
<VelocityBlock velocity={data.velocity} />
|
||
</div>
|
||
)}
|
||
|
||
{/* Competitors */}
|
||
{data.competitors.length > 0 && (
|
||
<div>
|
||
<SectionLabel style={{ marginBottom: 12 }}>Конкуренты</SectionLabel>
|
||
<CompetitorTable
|
||
competitors={data.competitors}
|
||
districtName={data.district?.district_name}
|
||
/>
|
||
</div>
|
||
)}
|
||
|
||
{/* Success recommendation */}
|
||
{hasRecommendation && (
|
||
<div>
|
||
<SectionLabel style={{ marginBottom: 12 }}>
|
||
Что хорошо продаётся
|
||
</SectionLabel>
|
||
<SuccessRecommendationBlock
|
||
recommendation={data.success_recommendation}
|
||
/>
|
||
</div>
|
||
)}
|
||
|
||
{/* Issue #113 — data-driven ТЗ на проектирование */}
|
||
<BestLayoutsBlock cadNum={data.cad_num} />
|
||
|
||
{!hasAny && <EmptyState message="Рыночные данные недоступны" />}
|
||
</div>
|
||
);
|
||
}
|