refactor(sf-19): BestLayouts inline hex → Tailwind / CSS tokens

Replace all hardcoded hex colors in BestLayoutsBlock.tsx with design
tokens (var(--accent), var(--fg-*), var(--bg-*), var(--border-*),
var(--success-soft) etc.) and Tailwind utilities for CONFIDENCE_STYLES
badge classes (bg-emerald-100/text-emerald-800, bg-amber-100/text-amber-800,
bg-rose-100/text-rose-800). No logic or behavior changes.

Epic #271 item #19.
This commit is contained in:
lekss361 2026-05-17 17:04:47 +03:00
parent f0589bcdff
commit f0edc8cfcd

View file

@ -14,13 +14,10 @@ import type {
// ── Constants ─────────────────────────────────────────────────────────────────
const CONFIDENCE_STYLES: Record<
Confidence,
{ bg: string; fg: string; label: string }
> = {
high: { bg: "#dcfce7", fg: "#166534", label: "Высокое" },
medium: { bg: "#fef3c7", fg: "#854d0e", label: "Среднее" },
low: { bg: "#fee2e2", fg: "#991b1b", label: "Низкое" },
const CONFIDENCE_STYLES: Record<Confidence, { cls: string; label: string }> = {
high: { cls: "bg-emerald-100 text-emerald-800", label: "Высокое" },
medium: { cls: "bg-amber-100 text-amber-800", label: "Среднее" },
low: { cls: "bg-rose-100 text-rose-800", label: "Низкое" },
};
const TIME_WINDOW_LABELS: Record<TimeWindow, string> = {
@ -40,17 +37,16 @@ const ROOM_BUCKET_LABELS: Record<string, string> = {
// ── Sub-components ────────────────────────────────────────────────────────────
function DataQualityCard({ dq }: { dq: BestLayoutsResponse["data_quality"] }) {
const style = CONFIDENCE_STYLES[dq.confidence];
const conf = CONFIDENCE_STYLES[dq.confidence];
return (
<div className="border border-gray-200 rounded-xl px-[18px] py-[14px] bg-white flex items-center gap-4 flex-wrap">
<span className="font-semibold text-[13px] text-gray-700 mr-1">
Качество данных:
</span>
<span
style={{ background: style.bg, color: style.fg }}
className="px-[10px] py-[2px] rounded-md text-xs font-semibold"
className={`px-[10px] py-[2px] rounded-md text-xs font-semibold ${conf.cls}`}
>
{style.label}
{conf.label}
</span>
<span className="text-xs text-gray-500">
Покрытие {dq.velocity_coverage_pct.toFixed(0)}% (
@ -68,7 +64,7 @@ function salesPeriodMonths(supply: number, velocity: number): number | null {
}
function SalesPeriodCell({ months }: { months: number | null }) {
if (months === null) return <span style={{ color: "#9ca3af" }}></span>;
if (months === null) return <span className="text-gray-400"></span>;
if (months < 12)
return (
<span className="text-emerald-600 font-semibold tabular-nums">
@ -92,7 +88,7 @@ function CompetitorDrillIn({
const hidden = ids.length - COMPETITOR_PREVIEW_LIMIT;
return (
<tr style={{ background: "#f0f4ff" }}>
<tr className="bg-blue-50">
<td colSpan={colSpan} style={{ padding: "8px 16px" }}>
<div
style={{
@ -103,7 +99,10 @@ function CompetitorDrillIn({
fontSize: 11,
}}
>
<span style={{ color: "#6b7280", fontWeight: 600, marginRight: 4 }}>
<span
className="text-gray-500 font-semibold"
style={{ marginRight: 4 }}
>
ЖК-источники:
</span>
{visible.map((id) => (
@ -186,7 +185,7 @@ function TopLayoutsTable({ rows }: { rows: TopLayoutRow[] }) {
style={{ width: "100%", borderCollapse: "collapse", fontSize: 12 }}
>
<thead>
<tr style={{ background: "#f6f7f9" }}>
<tr className="bg-gray-50">
{headers.map((h, idx) => (
<th
key={idx}
@ -225,8 +224,11 @@ function TopLayoutsTable({ rows }: { rows: TopLayoutRow[] }) {
role={hasCompetitors ? "button" : undefined}
aria-expanded={hasCompetitors ? isExpanded : undefined}
style={{
background: i % 2 === 0 ? "#fff" : "#fafbfc",
borderBottom: isExpanded ? "none" : "1px solid #f3f4f6",
background:
i % 2 === 0 ? "var(--bg-card)" : "var(--bg-card-alt)",
borderBottom: isExpanded
? "none"
: "1px solid var(--border-soft)",
cursor: hasCompetitors ? "pointer" : "default",
}}
title={
@ -239,7 +241,7 @@ function TopLayoutsTable({ rows }: { rows: TopLayoutRow[] }) {
style={{
padding: "7px 12px",
fontWeight: 700,
color: "#1d4ed8",
color: "var(--accent)",
fontVariantNumeric: "tabular-nums",
}}
>
@ -249,18 +251,23 @@ function TopLayoutsTable({ rows }: { rows: TopLayoutRow[] }) {
style={{
padding: "7px 12px",
fontWeight: 500,
color: "#111827",
color: "var(--fg-primary)",
}}
>
{ROOM_BUCKET_LABELS[row.room_bucket] ?? row.room_bucket}
</td>
<td style={{ padding: "7px 12px", color: "#374151" }}>
<td
style={{
padding: "7px 12px",
color: "var(--fg-secondary)",
}}
>
{row.area_bin} м²
</td>
<td
style={{
padding: "7px 12px",
color: "#374151",
color: "var(--fg-secondary)",
fontVariantNumeric: "tabular-nums",
}}
>
@ -270,7 +277,7 @@ function TopLayoutsTable({ rows }: { rows: TopLayoutRow[] }) {
style={{
padding: "7px 12px",
textAlign: "right",
color: "#374151",
color: "var(--fg-secondary)",
fontVariantNumeric: "tabular-nums",
}}
>
@ -280,7 +287,7 @@ function TopLayoutsTable({ rows }: { rows: TopLayoutRow[] }) {
style={{
padding: "7px 12px",
textAlign: "right",
color: "#374151",
color: "var(--fg-secondary)",
fontVariantNumeric: "tabular-nums",
}}
>
@ -302,7 +309,7 @@ function TopLayoutsTable({ rows }: { rows: TopLayoutRow[] }) {
style={{
padding: "7px 12px",
textAlign: "right",
color: "#374151",
color: "var(--fg-secondary)",
fontVariantNumeric: "tabular-nums",
}}
>
@ -339,7 +346,7 @@ function TopLayoutsTable({ rows }: { rows: TopLayoutRow[] }) {
<td
style={{
padding: "7px 10px",
color: "#9ca3af",
color: "var(--fg-tertiary)",
width: 24,
}}
>
@ -379,12 +386,12 @@ function TopLayoutsTable({ rows }: { rows: TopLayoutRow[] }) {
function UnitMixBar({ mix }: { mix: LayoutTzMixRow[] }) {
const COLORS = [
"#1d4ed8",
"#7c3aed",
"#059669",
"#d97706",
"#dc2626",
"#0891b2",
"var(--viz-1)",
"var(--viz-5)",
"var(--viz-3)",
"var(--viz-4)",
"var(--viz-2)",
"var(--viz-3)",
];
return (
@ -396,7 +403,7 @@ function UnitMixBar({ mix }: { mix: LayoutTzMixRow[] }) {
height: 24,
borderRadius: 6,
overflow: "hidden",
border: "1px solid #e5e7eb",
border: "1px solid var(--border-card)",
marginBottom: 10,
}}
>
@ -428,7 +435,7 @@ function UnitMixBar({ mix }: { mix: LayoutTzMixRow[] }) {
flexShrink: 0,
}}
/>
<span style={{ fontSize: 11, color: "#374151" }}>
<span style={{ fontSize: 11, color: "var(--fg-secondary)" }}>
{ROOM_BUCKET_LABELS[row.room_bucket] ?? row.room_bucket} {row.pct}
%
</span>
@ -443,7 +450,7 @@ function MixTable({ mix }: { mix: LayoutTzMixRow[] }) {
return (
<table style={{ width: "100%", borderCollapse: "collapse", fontSize: 12 }}>
<thead>
<tr style={{ background: "#f6f7f9" }}>
<tr className="bg-gray-50">
{["Тип", "Доля, %", "Кол-во квартир", "Ср. площадь, м²"].map((h) => (
<th
key={h}
@ -459,12 +466,16 @@ function MixTable({ mix }: { mix: LayoutTzMixRow[] }) {
<tr
key={row.room_bucket}
style={{
background: i % 2 === 0 ? "#fff" : "#fafbfc",
borderBottom: "1px solid #f3f4f6",
background: i % 2 === 0 ? "var(--bg-card)" : "var(--bg-card-alt)",
borderBottom: "1px solid var(--border-soft)",
}}
>
<td
style={{ padding: "7px 12px", fontWeight: 500, color: "#111827" }}
style={{
padding: "7px 12px",
fontWeight: 500,
color: "var(--fg-primary)",
}}
>
{ROOM_BUCKET_LABELS[row.room_bucket] ?? row.room_bucket}
</td>
@ -472,7 +483,7 @@ function MixTable({ mix }: { mix: LayoutTzMixRow[] }) {
style={{
padding: "7px 12px",
fontVariantNumeric: "tabular-nums",
color: "#374151",
color: "var(--fg-secondary)",
}}
>
{row.pct}%
@ -481,7 +492,7 @@ function MixTable({ mix }: { mix: LayoutTzMixRow[] }) {
style={{
padding: "7px 12px",
fontVariantNumeric: "tabular-nums",
color: "#374151",
color: "var(--fg-secondary)",
}}
>
{row.abs_units != null
@ -492,7 +503,7 @@ function MixTable({ mix }: { mix: LayoutTzMixRow[] }) {
style={{
padding: "7px 12px",
fontVariantNumeric: "tabular-nums",
color: "#374151",
color: "var(--fg-secondary)",
}}
>
{row.avg_target_area_m2 != null
@ -517,9 +528,9 @@ function RecommendationCard({
return (
<div
style={{
border: "1px solid #e5e7eb",
border: "1px solid var(--border-card)",
borderRadius: 10,
background: "#fff",
background: "var(--bg-card)",
overflow: "hidden",
}}
>
@ -545,7 +556,12 @@ function RecommendationCard({
{/* Rationale text — plain text only, no dangerouslySetInnerHTML */}
<p
style={{ fontSize: 13, color: "#374151", margin: 0, lineHeight: 1.6 }}
style={{
fontSize: 13,
color: "var(--fg-secondary)",
margin: 0,
lineHeight: 1.6,
}}
>
{rec.rationale_text}
</p>
@ -557,7 +573,7 @@ function RecommendationCard({
style={{
fontSize: 12,
fontWeight: 600,
color: "#6b7280",
color: "var(--fg-tertiary)",
marginBottom: 8,
textTransform: "uppercase",
letterSpacing: "0.04em",
@ -573,7 +589,7 @@ function RecommendationCard({
{rec.mix.length > 0 && (
<div
style={{
border: "1px solid #e5e7eb",
border: "1px solid var(--border-card)",
borderRadius: 8,
overflow: "hidden",
}}
@ -590,16 +606,18 @@ function RecommendationCard({
alignItems: "center",
gap: 8,
padding: "10px 14px",
background: "#eff6ff",
background: "var(--accent-soft)",
borderRadius: 8,
fontSize: 13,
}}
>
<span style={{ color: "#6b7280" }}>Средневзвешенная цена:</span>
<span style={{ color: "var(--fg-tertiary)" }}>
Средневзвешенная цена:
</span>
<span
style={{
fontWeight: 700,
color: "#1d4ed8",
color: "var(--accent)",
fontVariantNumeric: "tabular-nums",
}}
>
@ -612,7 +630,7 @@ function RecommendationCard({
)}
{/* Meta */}
<div style={{ fontSize: 11, color: "#9ca3af" }}>
<div style={{ fontSize: 11, color: "var(--fg-tertiary)" }}>
Основано на {rec.based_on_obj_count} ЖК ·{" "}
{rec.based_on_total_deals.toLocaleString("ru-RU")} сделках · период{" "}
{new Date(rec.data_window_start).toLocaleDateString("ru-RU")} {" "}
@ -695,9 +713,9 @@ export function BestLayoutsBlock({ cadNum, selectedCompetitorObjIds }: Props) {
return (
<div
style={{
border: "1px solid #e5e7eb",
border: "1px solid var(--border-card)",
borderRadius: 12,
background: "#fff",
background: "var(--bg-card)",
overflow: "hidden",
}}
>
@ -705,8 +723,8 @@ export function BestLayoutsBlock({ cadNum, selectedCompetitorObjIds }: Props) {
<div
style={{
padding: "14px 20px",
background: "#f9fafb",
borderBottom: "1px solid #e5e7eb",
background: "var(--bg-app)",
borderBottom: "1px solid var(--border-card)",
display: "flex",
justifyContent: "space-between",
alignItems: "center",
@ -715,10 +733,18 @@ export function BestLayoutsBlock({ cadNum, selectedCompetitorObjIds }: Props) {
}}
>
<div>
<span style={{ fontWeight: 600, fontSize: 14, color: "#111827" }}>
<span
style={{
fontWeight: 600,
fontSize: 14,
color: "var(--fg-primary)",
}}
>
Анализ планировок
</span>
<span style={{ fontSize: 12, color: "#6b7280", marginLeft: 8 }}>
<span
style={{ fontSize: 12, color: "var(--fg-tertiary)", marginLeft: 8 }}
>
data-driven ТЗ на проектирование
</span>
</div>
@ -732,8 +758,10 @@ export function BestLayoutsBlock({ cadNum, selectedCompetitorObjIds }: Props) {
disabled={isPdfLoading}
style={{
padding: "7px 16px",
background: isPdfLoading ? "#9ca3af" : "#1d4ed8",
color: "#fff",
background: isPdfLoading
? "var(--fg-tertiary)"
: "var(--accent)",
color: "var(--fg-on-dark)",
border: "none",
borderRadius: 7,
fontSize: 13,
@ -755,7 +783,7 @@ export function BestLayoutsBlock({ cadNum, selectedCompetitorObjIds }: Props) {
<div
style={{
padding: "16px 20px",
borderBottom: "1px solid #f3f4f6",
borderBottom: "1px solid var(--border-soft)",
display: "flex",
flexWrap: "wrap",
gap: 20,
@ -771,7 +799,13 @@ export function BestLayoutsBlock({ cadNum, selectedCompetitorObjIds }: Props) {
minWidth: 160,
}}
>
<label style={{ fontSize: 12, color: "#6b7280", fontWeight: 500 }}>
<label
style={{
fontSize: 12,
color: "var(--fg-tertiary)",
fontWeight: 500,
}}
>
Радиус поиска: {radiusKm.toFixed(1)} км
</label>
<input
@ -781,7 +815,7 @@ export function BestLayoutsBlock({ cadNum, selectedCompetitorObjIds }: Props) {
step={0.1}
value={radiusKm}
onChange={(e) => setRadiusKm(parseFloat(e.target.value))}
style={{ width: 160, accentColor: "#1d4ed8" }}
style={{ width: 160, accentColor: "var(--accent)" }}
/>
</div>
@ -794,7 +828,13 @@ export function BestLayoutsBlock({ cadNum, selectedCompetitorObjIds }: Props) {
minWidth: 160,
}}
>
<label style={{ fontSize: 12, color: "#6b7280", fontWeight: 500 }}>
<label
style={{
fontSize: 12,
color: "var(--fg-tertiary)",
fontWeight: 500,
}}
>
Мин. скорость: {minVelocity.toFixed(1)} кв/мес
</label>
<input
@ -804,13 +844,19 @@ export function BestLayoutsBlock({ cadNum, selectedCompetitorObjIds }: Props) {
step={0.1}
value={minVelocity}
onChange={(e) => setMinVelocity(parseFloat(e.target.value))}
style={{ width: 160, accentColor: "#1d4ed8" }}
style={{ width: 160, accentColor: "var(--accent)" }}
/>
</div>
{/* Time window radio */}
<div style={{ display: "flex", flexDirection: "column", gap: 4 }}>
<span style={{ fontSize: 12, color: "#6b7280", fontWeight: 500 }}>
<span
style={{
fontSize: 12,
color: "var(--fg-tertiary)",
fontWeight: 500,
}}
>
Период анализа
</span>
<div style={{ display: "flex", gap: 10, flexWrap: "wrap" }}>
@ -823,7 +869,8 @@ export function BestLayoutsBlock({ cadNum, selectedCompetitorObjIds }: Props) {
gap: 4,
fontSize: 12,
cursor: "pointer",
color: timeWindow === tw ? "#1d4ed8" : "#374151",
color:
timeWindow === tw ? "var(--accent)" : "var(--fg-secondary)",
fontWeight: timeWindow === tw ? 600 : 400,
}}
>
@ -833,7 +880,7 @@ export function BestLayoutsBlock({ cadNum, selectedCompetitorObjIds }: Props) {
value={tw}
checked={timeWindow === tw}
onChange={() => setTimeWindow(tw)}
style={{ accentColor: "#1d4ed8" }}
style={{ accentColor: "var(--accent)" }}
/>
{TIME_WINDOW_LABELS[tw]}
</label>
@ -843,7 +890,13 @@ export function BestLayoutsBlock({ cadNum, selectedCompetitorObjIds }: Props) {
{/* Target flats input */}
<div style={{ display: "flex", flexDirection: "column", gap: 4 }}>
<label style={{ fontSize: 12, color: "#6b7280", fontWeight: 500 }}>
<label
style={{
fontSize: 12,
color: "var(--fg-tertiary)",
fontWeight: 500,
}}
>
Целевой объём (квартир)
</label>
<input
@ -855,11 +908,11 @@ export function BestLayoutsBlock({ cadNum, selectedCompetitorObjIds }: Props) {
placeholder="300"
style={{
padding: "5px 10px",
border: "1px solid #d1d5db",
border: "1px solid var(--border-strong)",
borderRadius: 6,
fontSize: 13,
width: 110,
color: "#111827",
color: "var(--fg-primary)",
}}
/>
</div>
@ -870,8 +923,8 @@ export function BestLayoutsBlock({ cadNum, selectedCompetitorObjIds }: Props) {
disabled={isPending}
style={{
padding: "7px 20px",
background: isPending ? "#9ca3af" : "#1d4ed8",
color: "#fff",
background: isPending ? "var(--fg-tertiary)" : "var(--accent)",
color: "var(--fg-on-dark)",
border: "none",
borderRadius: 7,
fontSize: 13,
@ -888,8 +941,8 @@ export function BestLayoutsBlock({ cadNum, selectedCompetitorObjIds }: Props) {
<span
style={{
fontSize: 11,
color: "#1d4ed8",
background: "#eff6ff",
color: "var(--accent)",
background: "var(--accent-soft)",
padding: "3px 8px",
borderRadius: 4,
alignSelf: "flex-end",
@ -912,7 +965,7 @@ export function BestLayoutsBlock({ cadNum, selectedCompetitorObjIds }: Props) {
style={{
height: 18,
borderRadius: 6,
background: "#f3f4f6",
background: "var(--bg-card-alt)",
width: `${w}%`,
animation: "pulse 1.5s ease-in-out infinite",
}}
@ -926,10 +979,10 @@ export function BestLayoutsBlock({ cadNum, selectedCompetitorObjIds }: Props) {
<div
style={{
padding: "12px 16px",
background: "#fef2f2",
border: "1px solid #fca5a5",
background: "var(--danger-soft)",
border: "1px solid var(--danger-soft)",
borderRadius: 8,
color: "#dc2626",
color: "var(--danger)",
fontSize: 13,
}}
>
@ -952,7 +1005,7 @@ export function BestLayoutsBlock({ cadNum, selectedCompetitorObjIds }: Props) {
style={{
padding: "24px 0",
textAlign: "center",
color: "#d1d5db",
color: "var(--fg-tertiary)",
fontSize: 13,
}}
>