gendesign/frontend/src/components/site-finder/CompetitorTable.tsx
lekss361 4d651f00ef
All checks were successful
Deploy / build-backend (push) Has been skipped
Deploy / deploy (push) Successful in 48s
Deploy / changes (push) Successful in 4s
Deploy / build-worker (push) Has been skipped
Deploy / build-frontend (push) Successful in 2m43s
feat(sf-fe-a8): Section 3.2 Планировки + 3.3 Остатки/Velocity + CompetitorTable drawer pattern (#357)
2026-05-18 01:14:32 +00:00

356 lines
10 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;
onRowClick?: (competitor: ParcelAnalysisCompetitor) => void;
}
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,
onRowClick,
}: 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, idx) => (
<th
key={h}
style={{
padding: "8px 12px",
textAlign: "left",
borderBottom: "1px solid #e5e7eb",
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}
</th>
))}
</tr>
</thead>
<tbody>
{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: 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
style={{
padding: "7px 12px",
fontWeight: sameDistrict ? 600 : 400,
color: "#111827",
maxWidth: 200,
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}`}
</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>
);
}