feat(sf-fe-a8): Section 3.2 Планировки + 3.3 Остатки/Velocity + CompetitorTable drawer pattern #357
2 changed files with 389 additions and 57 deletions
|
|
@ -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;
|
||||
const rowBg = sameDistrict
|
||||
? "#eff6ff"
|
||||
: i % 2
|
||||
? "#fafbfc"
|
||||
: "#fff";
|
||||
return (
|
||||
<tr
|
||||
key={c.obj_id}
|
||||
onClick={() => onRowClick?.(c)}
|
||||
style={{
|
||||
background: sameDistrict
|
||||
? "#eff6ff"
|
||||
: i % 2
|
||||
? "#fafbfc"
|
||||
: "#fff",
|
||||
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}`}
|
||||
|
|
|
|||
|
|
@ -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 }}>
|
||||
<h3
|
||||
style={{
|
||||
fontSize: 16,
|
||||
fontWeight: 600,
|
||||
color: "var(--fg-primary, #111111)",
|
||||
margin: "0 0 12px",
|
||||
}}
|
||||
>
|
||||
3.2 Планировки конкурентов
|
||||
</h3>
|
||||
<div
|
||||
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,
|
||||
}}
|
||||
>
|
||||
Раздел появится в A8 — планировки конкурентов (BestLayoutsBlock)
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<h3
|
||||
style={{
|
||||
fontSize: 16,
|
||||
fontWeight: 600,
|
||||
color: "var(--fg-primary, #111111)",
|
||||
margin: 0,
|
||||
}}
|
||||
>
|
||||
3.2 Планировки
|
||||
</h3>
|
||||
<p
|
||||
style={{
|
||||
fontSize: 13,
|
||||
color: "var(--fg-secondary, #5B6066)",
|
||||
margin: "4px 0 0",
|
||||
}}
|
||||
>
|
||||
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 }}>
|
||||
<h3
|
||||
style={{
|
||||
fontSize: 16,
|
||||
fontWeight: 600,
|
||||
color: "var(--fg-primary, #111111)",
|
||||
margin: "0 0 12px",
|
||||
}}
|
||||
>
|
||||
3.3 Остатки и скорость продаж
|
||||
</h3>
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<h3
|
||||
style={{
|
||||
fontSize: 16,
|
||||
fontWeight: 600,
|
||||
color: "var(--fg-primary, #111111)",
|
||||
margin: 0,
|
||||
}}
|
||||
>
|
||||
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 solid var(--border-card, #E6E8EC)",
|
||||
borderRadius: 12,
|
||||
padding: "24px",
|
||||
color: "var(--fg-tertiary, #73767E)",
|
||||
fontSize: 13,
|
||||
}}
|
||||
>
|
||||
Данные по остаткам и скорости продаж отсутствуют для этого участка
|
||||
</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={{
|
||||
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,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 4,
|
||||
fontSize: 11,
|
||||
fontWeight: 500,
|
||||
textTransform: "uppercase" as const,
|
||||
letterSpacing: "0.04em",
|
||||
color: "var(--fg-secondary, #5B6066)",
|
||||
marginBottom: 4,
|
||||
}}
|
||||
>
|
||||
Раздел появится в A8 — Pipeline24mo / Velocity / MarketTrend
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue