refactor(frontend): SectionLabel + EmptyState + adminStyles shared (fixup)

Per audit batch #127 P2 hygiene (issue #129). Previous commit лошил
файлы из-за pre-commit (видимо). Fixup-коммит с actual diff:

## New shared modules (3)

- frontend/src/components/ui/SectionLabel.tsx
- frontend/src/components/ui/EmptyState.tsx
- frontend/src/lib/adminStyles.ts

## Replacements

- SectionLabel: 12 inline usages → import (Overview/Land/Market/Environment Tab)
- EmptyState: 3 inline usages → import (Land/Market/Environment)
- adminStyles: 5 admin pages import cardStyle/labelStyle/inputStyle/th/td
- BulkGeoPanel: cardStyle only (preserves local labelStyle для span)
- leads/page.tsx: cardStyle с marginTop extend; th/td/inputStyle local (divergent)

## Visual regression: ZERO

All px/colors/CSS properties copied verbatim. Files с divergent values left local.

## Checks

- ruff (no files)
- prettier
- tsc + lint clean (per agent run)

Closes #129
Refs: #127
This commit is contained in:
lekss361 2026-05-14 23:49:15 +03:00
parent 6186804eb8
commit d33023d7d6
13 changed files with 117 additions and 300 deletions

View file

@ -6,6 +6,9 @@ import { useMemo, useState } from "react";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { apiFetch } from "@/lib/api"; import { apiFetch } from "@/lib/api";
import { cardStyle as cardStyleBase } from "@/lib/adminStyles";
const cardStyle = { ...cardStyleBase, marginTop: 16 };
const ReactECharts = dynamic(() => import("echarts-for-react"), { ssr: false }); const ReactECharts = dynamic(() => import("echarts-for-react"), { ssr: false });
@ -717,13 +720,6 @@ function StatusBadge({ status }: { status: string | null }) {
); );
} }
const cardStyle = {
background: "#fff",
border: "1px solid #e6e8ec",
borderRadius: 12,
padding: 20,
marginTop: 16,
};
const inputStyle = { const inputStyle = {
padding: "8px 10px", padding: "8px 10px",
border: "1px solid #d1d5db", border: "1px solid #d1d5db",

View file

@ -4,6 +4,7 @@ import { useState } from "react";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { apiFetch } from "@/lib/api"; import { apiFetch } from "@/lib/api";
import { cardStyle, inputStyle, labelStyle, td, th } from "@/lib/adminStyles";
import { BulkGeoPanel } from "@/components/admin/BulkGeoPanel"; import { BulkGeoPanel } from "@/components/admin/BulkGeoPanel";
import { JobSettingsPanel } from "@/components/admin/JobSettingsPanel"; import { JobSettingsPanel } from "@/components/admin/JobSettingsPanel";
@ -498,33 +499,11 @@ function Stat({
); );
} }
const cardStyle = {
background: "#fff",
border: "1px solid #e6e8ec",
borderRadius: 12,
padding: 20,
};
const sectionTitle = { const sectionTitle = {
margin: "0 0 12px", margin: "0 0 12px",
fontSize: 16, fontSize: 16,
fontWeight: 600 as const, fontWeight: 600 as const,
}; };
const labelStyle = {
display: "block",
fontSize: 12,
color: "#5b6066",
marginBottom: 4,
textTransform: "uppercase" as const,
letterSpacing: 0.4,
};
const inputStyle = {
padding: "8px 10px",
border: "1px solid #d1d5db",
borderRadius: 6,
fontSize: 14,
width: "100%",
boxSizing: "border-box" as const,
};
const filterTab = { const filterTab = {
padding: "6px 14px", padding: "6px 14px",
border: "none", border: "none",
@ -547,10 +526,3 @@ const tableStyle = {
borderCollapse: "collapse" as const, borderCollapse: "collapse" as const,
fontSize: 12, fontSize: 12,
}; };
const th = {
padding: "8px 10px",
textAlign: "left" as const,
fontWeight: 600 as const,
borderBottom: "1px solid #e6e8ec",
};
const td = { padding: "8px 10px" };

View file

@ -5,6 +5,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import ScrapeLogsPanel from "@/components/admin/ScrapeLogsPanel"; import ScrapeLogsPanel from "@/components/admin/ScrapeLogsPanel";
import { apiFetch } from "@/lib/api"; import { apiFetch } from "@/lib/api";
import { cardStyle, inputStyle, labelStyle, td, th } from "@/lib/adminStyles";
interface GeoJob { interface GeoJob {
job_id: number; job_id: number;
@ -551,33 +552,11 @@ export default function GeoScrapeAdminPage() {
); );
} }
const cardStyle = {
background: "#fff",
border: "1px solid #e6e8ec",
borderRadius: 12,
padding: 20,
};
const sectionTitle = { const sectionTitle = {
margin: "0 0 12px", margin: "0 0 12px",
fontSize: 16, fontSize: 16,
fontWeight: 600 as const, fontWeight: 600 as const,
}; };
const labelStyle = {
display: "block",
fontSize: 12,
color: "#5b6066",
marginBottom: 4,
textTransform: "uppercase" as const,
letterSpacing: 0.4,
};
const inputStyle = {
padding: "8px 10px",
border: "1px solid #d1d5db",
borderRadius: 6,
fontSize: 14,
width: "100%",
boxSizing: "border-box" as const,
};
const primaryBtn = { const primaryBtn = {
padding: "10px 18px", padding: "10px 18px",
background: "#1d4ed8", background: "#1d4ed8",
@ -602,10 +581,3 @@ const tableStyle = {
borderCollapse: "collapse" as const, borderCollapse: "collapse" as const,
fontSize: 12, fontSize: 12,
}; };
const th = {
padding: "8px 10px",
textAlign: "left" as const,
fontWeight: 600 as const,
borderBottom: "1px solid #e6e8ec",
};
const td = { padding: "8px 10px" };

View file

@ -5,6 +5,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import ScrapeLogsPanel from "@/components/admin/ScrapeLogsPanel"; import ScrapeLogsPanel from "@/components/admin/ScrapeLogsPanel";
import { apiFetch } from "@/lib/api"; import { apiFetch } from "@/lib/api";
import { cardStyle, inputStyle, labelStyle, td, th } from "@/lib/adminStyles";
interface ObjectiveRunRow { interface ObjectiveRunRow {
run_id: number; run_id: number;
@ -825,33 +826,11 @@ function Stat({ label, value }: { label: string; value: number }) {
// ── Styles ────────────────────────────────────────────────────────────────── // ── Styles ──────────────────────────────────────────────────────────────────
const cardStyle = {
background: "#fff",
border: "1px solid #e6e8ec",
borderRadius: 12,
padding: 20,
};
const sectionTitle = { const sectionTitle = {
margin: "0 0 12px", margin: "0 0 12px",
fontSize: 16, fontSize: 16,
fontWeight: 600 as const, fontWeight: 600 as const,
}; };
const labelStyle = {
display: "block",
fontSize: 12,
color: "#5b6066",
marginBottom: 4,
textTransform: "uppercase" as const,
letterSpacing: 0.4,
};
const inputStyle = {
padding: "8px 10px",
border: "1px solid #d1d5db",
borderRadius: 6,
fontSize: 14,
width: "100%",
boxSizing: "border-box" as const,
};
const primaryBtn = { const primaryBtn = {
padding: "10px 18px", padding: "10px 18px",
background: "#1d4ed8", background: "#1d4ed8",
@ -871,10 +850,3 @@ const secondaryBtn = {
fontSize: 13, fontSize: 13,
cursor: "pointer", cursor: "pointer",
}; };
const th = {
padding: "8px 10px",
textAlign: "left" as const,
fontWeight: 600 as const,
borderBottom: "1px solid #e6e8ec",
};
const td = { padding: "8px 10px" };

View file

@ -4,6 +4,7 @@ import { useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { apiFetch } from "@/lib/api"; import { apiFetch } from "@/lib/api";
import { cardStyle, inputStyle, labelStyle, td, th } from "@/lib/adminStyles";
interface RunRow { interface RunRow {
run_id: number; run_id: number;
@ -975,28 +976,6 @@ export default function ScrapeAdminPage() {
); );
} }
const cardStyle = {
background: "#fff",
border: "1px solid #e6e8ec",
borderRadius: 12,
padding: 20,
};
const labelStyle = {
display: "block",
fontSize: 12,
color: "#5b6066",
marginBottom: 4,
textTransform: "uppercase" as const,
letterSpacing: 0.4,
};
const inputStyle = {
padding: "8px 10px",
border: "1px solid #d1d5db",
borderRadius: 6,
fontSize: 14,
width: "100%",
boxSizing: "border-box" as const,
};
const primaryBtn = { const primaryBtn = {
padding: "10px 18px", padding: "10px 18px",
background: "#1d4ed8", background: "#1d4ed8",
@ -1031,10 +1010,3 @@ const queueTable = {
fontSize: 13, fontSize: 13,
marginBottom: 4, marginBottom: 4,
}; };
const th = {
padding: "8px 10px",
textAlign: "left" as const,
fontWeight: 600,
borderBottom: "1px solid #e6e8ec",
};
const td = { padding: "8px 10px" };

View file

@ -4,6 +4,7 @@ import { useState } from "react";
import { useMutation } from "@tanstack/react-query"; import { useMutation } from "@tanstack/react-query";
import { apiFetch } from "@/lib/api"; import { apiFetch } from "@/lib/api";
import { cardStyle } from "@/lib/adminStyles";
// ── Types ──────────────────────────────────────────────────────────────────── // ── Types ────────────────────────────────────────────────────────────────────
@ -261,13 +262,6 @@ export function BulkGeoPanel({ token }: { token: string }) {
// ── Styles ────────────────────────────────────────────────────────────────── // ── Styles ──────────────────────────────────────────────────────────────────
const cardStyle = {
background: "#fff",
border: "1px solid #e6e8ec",
borderRadius: 12,
padding: 20,
};
const sectionTitle = { const sectionTitle = {
margin: "0 0 8px", margin: "0 0 8px",
fontSize: 16, fontSize: 16,

View file

@ -7,6 +7,8 @@ import type {
ParcelAnalysisWind, ParcelAnalysisWind,
ParcelAnalysisWeather, ParcelAnalysisWeather,
} from "@/types/site-finder"; } from "@/types/site-finder";
import { SectionLabel } from "@/components/ui/SectionLabel";
import { EmptyState } from "@/components/ui/EmptyState";
import { SeasonalWeatherBlock } from "./SeasonalWeatherBlock"; import { SeasonalWeatherBlock } from "./SeasonalWeatherBlock";
import { HydrologyBlock } from "./HydrologyBlock"; import { HydrologyBlock } from "./HydrologyBlock";
@ -424,18 +426,9 @@ export function EnvironmentTab({ data }: Props) {
{/* Primary env grid */} {/* Primary env grid */}
{hasEnv && ( {hasEnv && (
<div> <div>
<div <SectionLabel style={{ marginBottom: 12 }}>
style={{
fontSize: 12,
fontWeight: 600,
color: "#6b7280",
textTransform: "uppercase",
letterSpacing: "0.05em",
marginBottom: 12,
}}
>
Внешние факторы Внешние факторы
</div> </SectionLabel>
<div <div
style={{ style={{
display: "grid", display: "grid",
@ -480,18 +473,9 @@ export function EnvironmentTab({ data }: Props) {
{/* Seasonal weather */} {/* Seasonal weather */}
{"seasonal_weather" in data && ( {"seasonal_weather" in data && (
<div> <div>
<div <SectionLabel style={{ marginBottom: 12 }}>
style={{
fontSize: 12,
fontWeight: 600,
color: "#6b7280",
textTransform: "uppercase",
letterSpacing: "0.05em",
marginBottom: 12,
}}
>
Климат (нормали 30 лет) Климат (нормали 30 лет)
</div> </SectionLabel>
<SeasonalWeatherBlock seasonal={data.seasonal_weather} /> <SeasonalWeatherBlock seasonal={data.seasonal_weather} />
</div> </div>
)} )}
@ -499,18 +483,7 @@ export function EnvironmentTab({ data }: Props) {
{/* Hydrology */} {/* Hydrology */}
{data.hydrology !== undefined && ( {data.hydrology !== undefined && (
<div> <div>
<div <SectionLabel style={{ marginBottom: 12 }}>Гидрология</SectionLabel>
style={{
fontSize: 12,
fontWeight: 600,
color: "#6b7280",
textTransform: "uppercase",
letterSpacing: "0.05em",
marginBottom: 12,
}}
>
Гидрология
</div>
<HydrologyBlock hydrology={data.hydrology} /> <HydrologyBlock hydrology={data.hydrology} />
</div> </div>
)} )}
@ -518,16 +491,7 @@ export function EnvironmentTab({ data }: Props) {
{!hasEnv && {!hasEnv &&
!("seasonal_weather" in data) && !("seasonal_weather" in data) &&
data.hydrology === undefined && ( data.hydrology === undefined && (
<div <EmptyState message="Данные об окружающей среде недоступны" />
style={{
fontSize: 13,
color: "#9ca3af",
padding: "24px 0",
textAlign: "center",
}}
>
Данные об окружающей среде недоступны
</div>
)} )}
</div> </div>
); );

View file

@ -1,6 +1,8 @@
"use client"; "use client";
import type { ParcelAnalysis } from "@/types/site-finder"; import type { ParcelAnalysis } from "@/types/site-finder";
import { SectionLabel } from "@/components/ui/SectionLabel";
import { EmptyState } from "@/components/ui/EmptyState";
import { GeologyBlock } from "./GeologyBlock"; import { GeologyBlock } from "./GeologyBlock";
import { GeometrySuitabilityBlock } from "./GeometrySuitabilityBlock"; import { GeometrySuitabilityBlock } from "./GeometrySuitabilityBlock";
import { GeotechRiskBlock } from "./GeotechRiskBlock"; import { GeotechRiskBlock } from "./GeotechRiskBlock";
@ -38,18 +40,9 @@ export function LandTab({ data }: Props) {
padding: "14px 18px", padding: "14px 18px",
}} }}
> >
<div <SectionLabel style={{ marginBottom: 8 }}>
style={{
fontSize: 12,
fontWeight: 600,
color: "#6b7280",
textTransform: "uppercase",
letterSpacing: "0.05em",
marginBottom: 8,
}}
>
Зонирование (ПЗЗ) Зонирование (ПЗЗ)
</div> </SectionLabel>
<div style={{ fontSize: 13, color: "#6b7280" }}> <div style={{ fontSize: 13, color: "#6b7280" }}>
Данные ПЗЗ доступны через{" "} Данные ПЗЗ доступны через{" "}
<a <a
@ -86,18 +79,9 @@ export function LandTab({ data }: Props) {
{/* Geotech risk */} {/* Geotech risk */}
{data.geotech_risk !== undefined && ( {data.geotech_risk !== undefined && (
<div> <div>
<div <SectionLabel style={{ marginBottom: 12 }}>
style={{
fontSize: 12,
fontWeight: 600,
color: "#6b7280",
textTransform: "uppercase",
letterSpacing: "0.05em",
marginBottom: 12,
}}
>
Геотехнический риск Геотехнический риск
</div> </SectionLabel>
<GeotechRiskBlock risk={data.geotech_risk} /> <GeotechRiskBlock risk={data.geotech_risk} />
</div> </div>
)} )}
@ -105,34 +89,12 @@ export function LandTab({ data }: Props) {
{/* Geology */} {/* Geology */}
{data.geology !== undefined && ( {data.geology !== undefined && (
<div> <div>
<div <SectionLabel style={{ marginBottom: 12 }}>Геология</SectionLabel>
style={{
fontSize: 12,
fontWeight: 600,
color: "#6b7280",
textTransform: "uppercase",
letterSpacing: "0.05em",
marginBottom: 12,
}}
>
Геология
</div>
<GeologyBlock geology={data.geology} /> <GeologyBlock geology={data.geology} />
</div> </div>
)} )}
{!hasAny && ( {!hasAny && <EmptyState message="Данные о земле и геологии недоступны" />}
<div
style={{
fontSize: 13,
color: "#9ca3af",
padding: "24px 0",
textAlign: "center",
}}
>
Данные о земле и геологии недоступны
</div>
)}
</div> </div>
); );
} }

View file

@ -1,6 +1,8 @@
"use client"; "use client";
import type { ParcelAnalysis } from "@/types/site-finder"; import type { ParcelAnalysis } from "@/types/site-finder";
import { SectionLabel } from "@/components/ui/SectionLabel";
import { EmptyState } from "@/components/ui/EmptyState";
import { MarketTrendBlock } from "./MarketTrendBlock"; import { MarketTrendBlock } from "./MarketTrendBlock";
import { CompetitorTable } from "./CompetitorTable"; import { CompetitorTable } from "./CompetitorTable";
import { Pipeline24moBlock } from "./Pipeline24moBlock"; import { Pipeline24moBlock } from "./Pipeline24moBlock";
@ -39,18 +41,7 @@ export function MarketTab({ data }: Props) {
{/* Competitors */} {/* Competitors */}
{data.competitors.length > 0 && ( {data.competitors.length > 0 && (
<div> <div>
<div <SectionLabel style={{ marginBottom: 12 }}>Конкуренты</SectionLabel>
style={{
fontSize: 12,
fontWeight: 600,
color: "#6b7280",
textTransform: "uppercase",
letterSpacing: "0.05em",
marginBottom: 12,
}}
>
Конкуренты
</div>
<CompetitorTable <CompetitorTable
competitors={data.competitors} competitors={data.competitors}
districtName={data.district?.district_name} districtName={data.district?.district_name}
@ -61,36 +52,16 @@ export function MarketTab({ data }: Props) {
{/* Success recommendation */} {/* Success recommendation */}
{hasRecommendation && ( {hasRecommendation && (
<div> <div>
<div <SectionLabel style={{ marginBottom: 12 }}>
style={{
fontSize: 12,
fontWeight: 600,
color: "#6b7280",
textTransform: "uppercase",
letterSpacing: "0.05em",
marginBottom: 12,
}}
>
Что хорошо продаётся Что хорошо продаётся
</div> </SectionLabel>
<SuccessRecommendationBlock <SuccessRecommendationBlock
recommendation={data.success_recommendation} recommendation={data.success_recommendation}
/> />
</div> </div>
)} )}
{!hasAny && ( {!hasAny && <EmptyState message="Рыночные данные недоступны" />}
<div
style={{
fontSize: 13,
color: "#9ca3af",
padding: "24px 0",
textAlign: "center",
}}
>
Рыночные данные недоступны
</div>
)}
</div> </div>
); );
} }

View file

@ -2,6 +2,7 @@
import type { FeatureCollection } from "geojson"; import type { FeatureCollection } from "geojson";
import type { ParcelAnalysis } from "@/types/site-finder"; import type { ParcelAnalysis } from "@/types/site-finder";
import { SectionLabel } from "@/components/ui/SectionLabel";
import { ConfidenceBadge } from "./ConfidenceBadge"; import { ConfidenceBadge } from "./ConfidenceBadge";
import { IsochronesPanel } from "./IsochronesPanel"; import { IsochronesPanel } from "./IsochronesPanel";
import { ScoreBreakdownPanel } from "./ScoreBreakdownPanel"; import { ScoreBreakdownPanel } from "./ScoreBreakdownPanel";
@ -62,17 +63,7 @@ export function OverviewTab({ data, onIsochronesResult }: Props) {
gap: 6, gap: 6,
}} }}
> >
<div <SectionLabel>Район</SectionLabel>
style={{
fontSize: 12,
fontWeight: 600,
color: "#6b7280",
textTransform: "uppercase",
letterSpacing: "0.05em",
}}
>
Район
</div>
<div style={{ fontSize: 16, fontWeight: 700, color: "#111827" }}> <div style={{ fontSize: 16, fontWeight: 700, color: "#111827" }}>
{data.district.district_name} {data.district.district_name}
</div> </div>
@ -138,18 +129,7 @@ export function OverviewTab({ data, onIsochronesResult }: Props) {
gap: 4, gap: 4,
}} }}
> >
<div <SectionLabel style={{ marginBottom: 6 }}>Балл</SectionLabel>
style={{
fontSize: 12,
fontWeight: 600,
color: "#6b7280",
textTransform: "uppercase",
letterSpacing: "0.05em",
marginBottom: 6,
}}
>
Балл
</div>
<div style={{ fontSize: 13, color: "#6b7280" }}> <div style={{ fontSize: 13, color: "#6b7280" }}>
{data.poi_count} POI ·{" "} {data.poi_count} POI ·{" "}
{data.source === "cad_quarter" ? "квартал" : "участок"} {data.source === "cad_quarter" ? "квартал" : "участок"}
@ -186,18 +166,9 @@ export function OverviewTab({ data, onIsochronesResult }: Props) {
background: "#fff", background: "#fff",
}} }}
> >
<div <SectionLabel style={{ marginBottom: 12 }}>
style={{
fontSize: 12,
fontWeight: 600,
color: "#6b7280",
textTransform: "uppercase",
letterSpacing: "0.05em",
marginBottom: 12,
}}
>
POI по категориям POI по категориям
</div> </SectionLabel>
{Object.entries(data.score_breakdown).length === 0 ? ( {Object.entries(data.score_breakdown).length === 0 ? (
<p style={{ fontSize: 13, color: "#9ca3af" }}>Нет данных</p> <p style={{ fontSize: 13, color: "#9ca3af" }}>Нет данных</p>
) : ( ) : (
@ -236,18 +207,9 @@ export function OverviewTab({ data, onIsochronesResult }: Props) {
background: "#f9fafb", background: "#f9fafb",
}} }}
> >
<div <SectionLabel style={{ marginBottom: 6 }}>
style={{
fontSize: 12,
fontWeight: 600,
color: "#6b7280",
textTransform: "uppercase",
letterSpacing: "0.05em",
marginBottom: 6,
}}
>
Изохроны доступности Изохроны доступности
</div> </SectionLabel>
<div style={{ fontSize: 12, color: "#9ca3af" }}> <div style={{ fontSize: 12, color: "#9ca3af" }}>
Недоступны нужен OpenRouteService API key (см.{" "} Недоступны нужен OpenRouteService API key (см.{" "}
<code style={{ fontFamily: "monospace" }}> <code style={{ fontFamily: "monospace" }}>

View file

@ -0,0 +1,22 @@
import type { CSSProperties } from "react";
interface EmptyStateProps {
message: string;
style?: CSSProperties;
}
export function EmptyState({ message, style }: EmptyStateProps) {
return (
<div
style={{
fontSize: 13,
color: "#9ca3af",
padding: "24px 0",
textAlign: "center",
...style,
}}
>
{message}
</div>
);
}

View file

@ -0,0 +1,23 @@
import type { CSSProperties, ReactNode } from "react";
interface SectionLabelProps {
children: ReactNode;
style?: CSSProperties;
}
export function SectionLabel({ children, style }: SectionLabelProps) {
return (
<div
style={{
fontSize: 12,
fontWeight: 600,
color: "#6b7280",
textTransform: "uppercase",
letterSpacing: "0.05em",
...style,
}}
>
{children}
</div>
);
}

View file

@ -0,0 +1,35 @@
import type { CSSProperties } from "react";
export const cardStyle: CSSProperties = {
background: "#fff",
border: "1px solid #e6e8ec",
borderRadius: 12,
padding: 20,
};
export const labelStyle: CSSProperties = {
display: "block",
fontSize: 12,
color: "#5b6066",
marginBottom: 4,
textTransform: "uppercase",
letterSpacing: 0.4,
};
export const inputStyle: CSSProperties = {
padding: "8px 10px",
border: "1px solid #d1d5db",
borderRadius: 6,
fontSize: 14,
width: "100%",
boxSizing: "border-box",
};
export const th: CSSProperties = {
padding: "8px 10px",
textAlign: "left",
fontWeight: 600,
borderBottom: "1px solid #e6e8ec",
};
export const td: CSSProperties = { padding: "8px 10px" };