feat(sf-fe-a8): Section 3.2 Планировки + 3.3 Остатки/Velocity + CompetitorTable drawer pattern #357

Merged
lekss361 merged 1 commit from feat/sf-fe-a8-section3-2-3-3-competitors into main 2026-05-18 01:14:32 +00:00
2 changed files with 389 additions and 57 deletions

View file

@ -8,6 +8,7 @@ type StatusFilter = "all" | "building" | "ready";
interface Props {
competitors: ParcelAnalysisCompetitor[];
districtName: string | null | undefined;
onRowClick?: (competitor: ParcelAnalysisCompetitor) => void;
}
const CLASS_COLORS: Record<string, string> = {
@ -69,7 +70,11 @@ function StatusBadge({ status }: { status: string | null | undefined }) {
);
}
export function CompetitorTable({ competitors, districtName }: Props) {
export function CompetitorTable({
competitors,
districtName,
onRowClick,
}: Props) {
const [filter, setFilter] = useState<StatusFilter>("all");
const buildingCount = competitors.filter(
@ -193,7 +198,7 @@ export function CompetitorTable({ competitors, districtName }: Props) {
"Квартир",
"Район",
"Расст., м",
].map((h) => (
].map((h, idx) => (
<th
key={h}
style={{
@ -203,6 +208,16 @@ export function CompetitorTable({ competitors, districtName }: Props) {
fontWeight: 600,
color: "#374151",
whiteSpace: "nowrap",
// Sticky first column
...(idx === 0
? {
position: "sticky",
left: 0,
background: "#f6f7f9",
zIndex: 1,
boxShadow: "2px 0 4px rgba(0,0,0,0.04)",
}
: {}),
}}
>
{h}
@ -214,16 +229,31 @@ export function CompetitorTable({ competitors, districtName }: Props) {
{top20.map((c, i) => {
const sameDistrict =
districtName && c.district_name === districtName;
return (
<tr
key={c.obj_id}
style={{
background: sameDistrict
const rowBg = sameDistrict
? "#eff6ff"
: i % 2
? "#fafbfc"
: "#fff",
: "#fff";
return (
<tr
key={c.obj_id}
onClick={() => onRowClick?.(c)}
style={{
background: rowBg,
borderBottom: "1px solid #f3f4f6",
cursor: onRowClick ? "pointer" : "default",
}}
onMouseEnter={(e) => {
if (onRowClick) {
(
e.currentTarget as HTMLTableRowElement
).style.background = "#e0e7ff";
}
}}
onMouseLeave={(e) => {
(
e.currentTarget as HTMLTableRowElement
).style.background = rowBg;
}}
>
<td
@ -235,6 +265,11 @@ export function CompetitorTable({ competitors, districtName }: Props) {
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
// Sticky first column
position: "sticky",
left: 0,
background: rowBg,
boxShadow: "2px 0 4px rgba(0,0,0,0.04)",
}}
>
{c.comm_name ?? `ЖК #${c.obj_id}`}

View file

@ -1,13 +1,24 @@
"use client";
import { useState } from "react";
import { useState, type ReactNode } from "react";
import { X, Building2, MapPin, Users, Ruler } from "lucide-react";
import { WeightProfilePanel } from "@/components/site-finder/WeightProfilePanel";
import { BestLayoutsBlock } from "@/components/site-finder/BestLayoutsBlock";
import { Pipeline24moBlock } from "@/components/site-finder/Pipeline24moBlock";
import { VelocityBlock } from "@/components/site-finder/VelocityBlock";
import { MarketTrendBlock } from "@/components/site-finder/MarketTrendBlock";
import { CompetitorTable } from "@/components/site-finder/CompetitorTable";
import { Drawer } from "@/components/ui/Drawer";
import { Badge } from "@/components/ui/Badge";
import {
POI_DEFAULT_WEIGHTS,
type PoiCategoryKey,
} from "@/lib/api/weightProfiles";
import type { ParcelAnalysis } from "@/types/site-finder";
import type {
ParcelAnalysis,
ParcelAnalysisCompetitor,
} from "@/types/site-finder";
// ── Types ─────────────────────────────────────────────────────────────────────
@ -256,65 +267,333 @@ function Section31Settings({
);
}
// ── Section 3.2 placeholder ────────────────────────────────────────────────────
// ── Section 3.2 «Планировки» ───────────────────────────────────────────────────
function Section32Placeholder() {
function Section32Layouts({ cad }: { cad: string }) {
return (
<div id="section-3-2" style={{ scrollMarginTop: 72 }}>
<div style={{ marginBottom: 16 }}>
<h3
style={{
fontSize: 16,
fontWeight: 600,
color: "var(--fg-primary, #111111)",
margin: "0 0 12px",
margin: 0,
}}
>
3.2 Планировки конкурентов
3.2 Планировки
</h3>
<div
<p
style={{
background: "var(--bg-card-alt, #FAFBFC)",
border: "1px dashed var(--border-strong, #D1D5DB)",
borderRadius: 12,
padding: "32px 24px",
textAlign: "center",
color: "var(--fg-tertiary, #73767E)",
fontSize: 13,
color: "var(--fg-secondary, #5B6066)",
margin: "4px 0 0",
}}
>
Раздел появится в A8 планировки конкурентов (BestLayoutsBlock)
Data-driven ТЗ на планировочный микс на основе сделок конкурентов в
радиусе
</p>
</div>
<BestLayoutsBlock cadNum={cad} />
</div>
);
}
// ── Section 3.3 placeholder ────────────────────────────────────────────────────
// ── Section 3.3 «Остатки и скорость» ──────────────────────────────────────────
function Section33RemainVelocity({ data }: { data: ParcelAnalysis }) {
const hasPipeline = data.pipeline_24mo != null;
const hasVelocity = data.velocity != null;
const hasTrend = data.market_trend != null;
const isEmpty = !hasPipeline && !hasVelocity && !hasTrend;
function Section33Placeholder() {
return (
<div id="section-3-3" style={{ scrollMarginTop: 72 }}>
<div style={{ marginBottom: 16 }}>
<h3
style={{
fontSize: 16,
fontWeight: 600,
color: "var(--fg-primary, #111111)",
margin: "0 0 12px",
margin: 0,
}}
>
3.3 Остатки и скорость продаж
3.3 Остатки и скорость
</h3>
<p
style={{
fontSize: 13,
color: "var(--fg-secondary, #5B6066)",
margin: "4px 0 0",
}}
>
Pipeline конкурентов на 24 мес · темп продаж · тренд цен
</p>
</div>
{isEmpty ? (
<div
style={{
background: "var(--bg-card-alt, #FAFBFC)",
border: "1px dashed var(--border-strong, #D1D5DB)",
border: "1px solid var(--border-card, #E6E8EC)",
borderRadius: 12,
padding: "32px 24px",
textAlign: "center",
padding: "24px",
color: "var(--fg-tertiary, #73767E)",
fontSize: 13,
}}
>
Раздел появится в A8 Pipeline24mo / Velocity / MarketTrend
Данные по остаткам и скорости продаж отсутствуют для этого участка
</div>
) : (
<div
style={{
display: "grid",
gridTemplateColumns: "repeat(auto-fit, minmax(280px, 1fr))",
gap: 16,
}}
>
{hasPipeline && <Pipeline24moBlock data={data.pipeline_24mo!} />}
{hasVelocity && (
<div
style={{
background: "var(--bg-card, #FFFFFF)",
border: "1px solid var(--border-card, #E6E8EC)",
borderRadius: 10,
padding: "14px 18px",
}}
>
<VelocityBlock velocity={data.velocity} />
</div>
)}
{hasTrend && <MarketTrendBlock trend={data.market_trend} />}
</div>
)}
</div>
);
}
// ── Competitor detail drawer ───────────────────────────────────────────────────
const STATUS_BADGE_MAP: Record<
string,
{ variant: "success" | "neutral" | "warning"; label: string }
> = {
Строящиеся: { variant: "warning", label: "Строящийся" },
Сданные: { variant: "success", label: "Сдан" },
};
function CompetitorDetailDrawer({
competitor,
onClose,
}: {
competitor: ParcelAnalysisCompetitor | null;
onClose: () => void;
}) {
const isOpen = competitor !== null;
return (
<Drawer open={isOpen} onClose={onClose} side="right" width="420px">
{competitor && (
<>
{/* Header */}
<div
style={{
padding: "16px 20px 12px",
borderBottom: "1px solid var(--border-soft, #EEF0F3)",
display: "flex",
alignItems: "flex-start",
gap: 8,
}}
>
<Building2
size={16}
strokeWidth={1.5}
style={{
color: "var(--fg-secondary, #5B6066)",
marginTop: 3,
flexShrink: 0,
}}
/>
<div style={{ flex: 1, minWidth: 0 }}>
<div
style={{
fontSize: 15,
fontWeight: 600,
color: "var(--fg-primary, #111111)",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{competitor.comm_name ?? `ЖК #${competitor.obj_id}`}
</div>
{competitor.dev_name && (
<div
style={{
fontSize: 12,
color: "var(--fg-secondary, #5B6066)",
marginTop: 2,
}}
>
{competitor.dev_name}
</div>
)}
</div>
<button
type="button"
onClick={onClose}
aria-label="Закрыть"
style={{
background: "none",
border: "none",
cursor: "pointer",
padding: 4,
color: "var(--fg-tertiary, #73767E)",
flexShrink: 0,
}}
>
<X size={18} strokeWidth={1.5} />
</button>
</div>
{/* Status + badges row */}
<div
style={{
padding: "12px 20px",
borderBottom: "1px solid var(--border-soft, #EEF0F3)",
display: "flex",
flexWrap: "wrap",
gap: 8,
alignItems: "center",
}}
>
{competitor.site_status && (
<Badge
variant={
STATUS_BADGE_MAP[competitor.site_status]?.variant ?? "neutral"
}
>
{STATUS_BADGE_MAP[competitor.site_status]?.label ??
competitor.site_status}
</Badge>
)}
{competitor.obj_class && (
<Badge variant="info">{competitor.obj_class}</Badge>
)}
</div>
{/* KPI grid */}
<div
style={{
padding: "16px 20px",
display: "grid",
gridTemplateColumns: "1fr 1fr",
gap: 12,
}}
>
<KpiCell
icon={<MapPin size={14} strokeWidth={1.5} />}
label="Расстояние"
value={`${Math.round(competitor.distance_m).toLocaleString("ru-RU")} м`}
/>
<KpiCell
icon={<Users size={14} strokeWidth={1.5} />}
label="Квартир"
value={
competitor.flat_count != null
? competitor.flat_count.toLocaleString("ru-RU")
: "—"
}
/>
{competitor.district_name && (
<KpiCell
icon={<MapPin size={14} strokeWidth={1.5} />}
label="Район"
value={competitor.district_name}
/>
)}
{competitor.ready_dt && (
<KpiCell
icon={<Ruler size={14} strokeWidth={1.5} />}
label="Срок сдачи"
value={new Date(competitor.ready_dt).toLocaleDateString(
"ru-RU",
{
year: "numeric",
month: "2-digit",
},
)}
/>
)}
</div>
{/* Caveat */}
<div
style={{
margin: "0 20px 20px",
padding: "10px 14px",
background: "var(--bg-card-alt, #FAFBFC)",
border: "1px solid var(--border-soft, #EEF0F3)",
borderRadius: 8,
fontSize: 12,
color: "var(--fg-tertiary, #73767E)",
}}
>
Детальные данные (sold%, цены, план. продажи) появятся после
расширения B5
</div>
</>
)}
</Drawer>
);
}
function KpiCell({
icon,
label,
value,
}: {
icon: ReactNode;
label: string;
value: string;
}) {
return (
<div
style={{
background: "var(--bg-card-alt, #FAFBFC)",
border: "1px solid var(--border-card, #E6E8EC)",
borderRadius: 8,
padding: "10px 12px",
}}
>
<div
style={{
display: "flex",
alignItems: "center",
gap: 4,
fontSize: 11,
fontWeight: 500,
textTransform: "uppercase" as const,
letterSpacing: "0.04em",
color: "var(--fg-secondary, #5B6066)",
marginBottom: 4,
}}
>
<span style={{ display: "flex", color: "var(--fg-tertiary, #73767E)" }}>
{icon}
</span>
{label}
</div>
<div
style={{
fontSize: 14,
fontWeight: 600,
color: "var(--fg-primary, #111111)",
fontVariantNumeric: "tabular-nums",
}}
>
{value}
</div>
</div>
);
@ -348,8 +627,11 @@ export function Section3SettingsAndCompetitors({ cad, data }: Props) {
maxAge6mo: false,
onlyApartments: false,
});
const [selectedCompetitor, setSelectedCompetitor] =
useState<ParcelAnalysisCompetitor | null>(null);
const filteredCompetitors = applyFilters(data, filters);
const districtName = data.district?.district_name ?? null;
return (
<section id="section-3" style={{ scrollMarginTop: 72 }}>
@ -387,8 +669,18 @@ export function Section3SettingsAndCompetitors({ cad, data }: Props) {
{/* Sub-sections */}
<div style={{ display: "flex", flexDirection: "column", gap: 24 }}>
<Section31Settings filters={filters} onFiltersChange={setFilters} />
<Section32Placeholder />
<Section33Placeholder />
{/* Competitor table — moved before 3.2/3.3 for context */}
{filteredCompetitors.length > 0 && (
<CompetitorTable
competitors={filteredCompetitors}
districtName={districtName}
onRowClick={setSelectedCompetitor}
/>
)}
<Section32Layouts cad={cad} />
<Section33RemainVelocity data={data} />
</div>
{/* Filtered competitors count hint */}
@ -405,10 +697,15 @@ export function Section3SettingsAndCompetitors({ cad, data }: Props) {
}}
>
Фильтр по радиусу: показано {filteredCompetitors.length} из{" "}
{data.competitors.length} конкурентов. Разделы 3.2 и 3.3 отобразят
данные с учётом выборки после A8.
{data.competitors.length} конкурентов
</div>
)}
{/* Competitor detail drawer */}
<CompetitorDetailDrawer
competitor={selectedCompetitor}
onClose={() => setSelectedCompetitor(null)}
/>
</section>
);
}