356 lines
10 KiB
TypeScript
356 lines
10 KiB
TypeScript
"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" }}>
|
||
ЖК в районе «{districtName}» подсвечены
|
||
</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>
|
||
);
|
||
}
|