gendesign/frontend/src/components/site-finder/CompetitorTable.tsx
lekss361 98323027aa
Some checks failed
CI / backend (pull_request) Failing after 49s
CI / frontend (pull_request) Successful in 1m55s
fix(sf-03): CompetitorTable Status column + filter tabs
После backend PR #276 (site_status в /analyze) — frontend показывает
статус строящиеся/сданные с цветным badge и фильтр-tabs наверху таблицы.

TS type extended manually (backward compat) — codegen подхватит после
backend deploy.

Closes (epic part) #271 item 3
2026-05-17 13:41:15 +03:00

321 lines
9.2 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"use client";
import { useState } from "react";
import type { ParcelAnalysisCompetitor } from "@/types/site-finder";
type StatusFilter = "all" | "building" | "ready";
interface Props {
competitors: ParcelAnalysisCompetitor[];
districtName: string | null | undefined;
}
const CLASS_COLORS: Record<string, string> = {
Комфорт: "#1d4ed8",
"Комфорт+": "#2563eb",
Бизнес: "#7c3aed",
Эконом: "#6b7280",
Премиум: "#b45309",
};
function StatusBadge({ status }: { status: string | null | undefined }) {
if (status === "Строящиеся") {
return (
<span
style={{
padding: "2px 6px",
borderRadius: 4,
fontSize: 11,
fontWeight: 600,
background: "#d1fae5",
color: "#065f46",
whiteSpace: "nowrap",
}}
>
Строящиеся
</span>
);
}
if (status === "Сданные") {
return (
<span
style={{
padding: "2px 6px",
borderRadius: 4,
fontSize: 11,
fontWeight: 500,
background: "#f1f5f9",
color: "#475569",
whiteSpace: "nowrap",
}}
>
Сданные
</span>
);
}
return (
<span
style={{
padding: "2px 6px",
borderRadius: 4,
fontSize: 11,
background: "#f4f4f5",
color: "#71717a",
whiteSpace: "nowrap",
}}
>
</span>
);
}
export function CompetitorTable({ competitors, districtName }: Props) {
const [filter, setFilter] = useState<StatusFilter>("all");
const buildingCount = competitors.filter(
(c) => c.site_status === "Строящиеся",
).length;
const readyCount = competitors.filter(
(c) => c.site_status === "Сданные",
).length;
const filtered = competitors.filter((c) => {
if (filter === "building") return c.site_status === "Строящиеся";
if (filter === "ready") return c.site_status === "Сданные";
return true;
});
const top20 = filtered.slice(0, 20);
const tabBase = {
padding: "4px 12px",
borderRadius: 6,
fontSize: 12,
fontWeight: 500,
cursor: "pointer",
border: "1px solid transparent",
background: "transparent",
color: "#6b7280",
transition: "all 0.15s",
} as const;
const tabActive = {
...tabBase,
background: "#fff",
border: "1px solid #d1d5db",
color: "#111827",
fontWeight: 600,
} as const;
return (
<div
style={{
border: "1px solid #e5e7eb",
borderRadius: 12,
overflow: "hidden",
background: "#fff",
}}
>
{/* Header */}
<div
style={{
padding: "14px 20px",
background: "#f9fafb",
borderBottom: "1px solid #e5e7eb",
display: "flex",
justifyContent: "space-between",
alignItems: "center",
}}
>
<span style={{ fontWeight: 600, fontSize: 14 }}>
Конкуренты ({competitors.length})
</span>
{districtName && (
<span style={{ fontSize: 12, color: "#6b7280" }}>
ЖК в районе &laquo;{districtName}&raquo; подсвечены
</span>
)}
</div>
{/* Filter tabs */}
<div
style={{
padding: "8px 16px",
borderBottom: "1px solid #e5e7eb",
background: "#f9fafb",
display: "flex",
gap: 6,
alignItems: "center",
}}
>
<button
style={filter === "all" ? tabActive : tabBase}
onClick={() => setFilter("all")}
>
Все ({competitors.length})
</button>
<button
style={filter === "building" ? tabActive : tabBase}
onClick={() => setFilter("building")}
>
Строящиеся ({buildingCount})
</button>
<button
style={filter === "ready" ? tabActive : tabBase}
onClick={() => setFilter("ready")}
>
Сданные ({readyCount})
</button>
</div>
{top20.length === 0 ? (
<div style={{ padding: 24, color: "#9ca3af", fontSize: 13 }}>
{filtered.length === 0 && competitors.length > 0
? "Нет конкурентов по выбранному фильтру"
: "Конкурентов не найдено"}
</div>
) : (
<div style={{ overflowX: "auto" }}>
<table
style={{
width: "100%",
borderCollapse: "collapse",
fontSize: 12,
}}
>
<thead>
<tr style={{ background: "#f6f7f9" }}>
{[
"ЖК",
"Девелопер",
"Класс",
"Статус",
"Квартир",
"Район",
"Расст., м",
].map((h) => (
<th
key={h}
style={{
padding: "8px 12px",
textAlign: "left",
borderBottom: "1px solid #e5e7eb",
fontWeight: 600,
color: "#374151",
whiteSpace: "nowrap",
}}
>
{h}
</th>
))}
</tr>
</thead>
<tbody>
{top20.map((c, i) => {
const sameDistrict =
districtName && c.district_name === districtName;
return (
<tr
key={c.obj_id}
style={{
background: sameDistrict
? "#eff6ff"
: i % 2
? "#fafbfc"
: "#fff",
borderBottom: "1px solid #f3f4f6",
}}
>
<td
style={{
padding: "7px 12px",
fontWeight: sameDistrict ? 600 : 400,
color: "#111827",
maxWidth: 200,
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{c.comm_name ?? `ЖК #${c.obj_id}`}
</td>
<td
style={{
padding: "7px 12px",
color: "#6b7280",
maxWidth: 160,
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{c.dev_name ?? "—"}
</td>
<td style={{ padding: "7px 12px" }}>
{c.obj_class ? (
<span
style={{
padding: "2px 6px",
borderRadius: 4,
fontSize: 11,
fontWeight: 600,
background: CLASS_COLORS[c.obj_class] ?? "#9ca3af",
color: "#fff",
}}
>
{c.obj_class}
</span>
) : (
<span style={{ color: "#d1d5db" }}></span>
)}
</td>
<td style={{ padding: "7px 12px" }}>
<StatusBadge status={c.site_status} />
</td>
<td
style={{
padding: "7px 12px",
textAlign: "right",
color: "#374151",
}}
>
{c.flat_count != null
? c.flat_count.toLocaleString("ru-RU")
: "—"}
</td>
<td style={{ padding: "7px 12px", color: "#6b7280" }}>
{c.district_name ?? "—"}
</td>
<td
style={{
padding: "7px 12px",
textAlign: "right",
color: "#374151",
fontVariantNumeric: "tabular-nums",
}}
>
{Math.round(c.distance_m).toLocaleString("ru-RU")}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
{filtered.length > 20 && (
<div
style={{
padding: "8px 20px",
background: "#f9fafb",
borderTop: "1px solid #e5e7eb",
fontSize: 12,
color: "#9ca3af",
}}
>
Показано 20 из {filtered.length} ближайших ЖК
</div>
)}
</div>
);
}