Дополняет UI: 5 компонентов которые импортит page.tsx (Leaflet draw, форма ConceptInput, табы вариантов, placement-карта, экспорт). Не вошли в предыдущий commit (untracked-dir). ConceptResultMap: Leaflet stroke #D1D5DB (CSS var не резолвится в SVG).
232 lines
6.2 KiB
TypeScript
232 lines
6.2 KiB
TypeScript
"use client";
|
||
|
||
/**
|
||
* ConceptParamsForm — controlled form for the ConceptInput parameters
|
||
* (housing_class / target_floors / development_type / land_cost_rub). The
|
||
* parcel polygon is collected separately by the map; this form owns only the
|
||
* scalar fields and the submit CTA. Submit is disabled until the parent
|
||
* confirms a polygon is present (`hasPolygon`).
|
||
*/
|
||
|
||
import { Sparkles } from "lucide-react";
|
||
|
||
import {
|
||
DEVELOPMENT_TYPE_LABELS,
|
||
HOUSING_CLASS_LABELS,
|
||
type DevelopmentType,
|
||
type HousingClass,
|
||
} from "@/lib/concept-api";
|
||
|
||
export interface ConceptParams {
|
||
housing_class: HousingClass;
|
||
target_floors: number;
|
||
development_type: DevelopmentType;
|
||
land_cost_rub: number | null;
|
||
}
|
||
|
||
export const DEFAULT_PARAMS: ConceptParams = {
|
||
housing_class: "comfort",
|
||
target_floors: 9,
|
||
development_type: "mid_rise",
|
||
land_cost_rub: null,
|
||
};
|
||
|
||
const fieldLabelStyle: React.CSSProperties = {
|
||
display: "block",
|
||
fontSize: 12,
|
||
fontWeight: 500,
|
||
textTransform: "uppercase",
|
||
letterSpacing: "0.04em",
|
||
color: "var(--fg-secondary)",
|
||
marginBottom: 6,
|
||
};
|
||
|
||
const controlStyle: React.CSSProperties = {
|
||
width: "100%",
|
||
padding: "8px 12px",
|
||
fontSize: 14,
|
||
border: "1px solid var(--border-strong)",
|
||
borderRadius: 8,
|
||
outline: "none",
|
||
background: "var(--bg-card)",
|
||
color: "var(--fg-primary)",
|
||
};
|
||
|
||
interface Props {
|
||
params: ConceptParams;
|
||
onChange: (params: ConceptParams) => void;
|
||
onSubmit: () => void;
|
||
hasPolygon: boolean;
|
||
isPending: boolean;
|
||
}
|
||
|
||
export function ConceptParamsForm({
|
||
params,
|
||
onChange,
|
||
onSubmit,
|
||
hasPolygon,
|
||
isPending,
|
||
}: Props) {
|
||
function handleSubmit(e: React.FormEvent) {
|
||
e.preventDefault();
|
||
if (!hasPolygon || isPending) return;
|
||
onSubmit();
|
||
}
|
||
|
||
const disabled = !hasPolygon || isPending;
|
||
|
||
return (
|
||
<form onSubmit={handleSubmit} style={{ display: "grid", gap: 16 }}>
|
||
<div>
|
||
<label htmlFor="housing-class" style={fieldLabelStyle}>
|
||
Класс жилья
|
||
</label>
|
||
<select
|
||
id="housing-class"
|
||
value={params.housing_class}
|
||
onChange={(e) =>
|
||
onChange({
|
||
...params,
|
||
housing_class: e.target.value as HousingClass,
|
||
})
|
||
}
|
||
style={controlStyle}
|
||
>
|
||
{(Object.keys(HOUSING_CLASS_LABELS) as HousingClass[]).map((key) => (
|
||
<option key={key} value={key}>
|
||
{HOUSING_CLASS_LABELS[key]}
|
||
</option>
|
||
))}
|
||
</select>
|
||
</div>
|
||
|
||
<div>
|
||
<label htmlFor="dev-type" style={fieldLabelStyle}>
|
||
Тип застройки
|
||
</label>
|
||
<select
|
||
id="dev-type"
|
||
value={params.development_type}
|
||
onChange={(e) =>
|
||
onChange({
|
||
...params,
|
||
development_type: e.target.value as DevelopmentType,
|
||
})
|
||
}
|
||
style={controlStyle}
|
||
>
|
||
{(Object.keys(DEVELOPMENT_TYPE_LABELS) as DevelopmentType[]).map(
|
||
(key) => (
|
||
<option key={key} value={key}>
|
||
{DEVELOPMENT_TYPE_LABELS[key]}
|
||
</option>
|
||
),
|
||
)}
|
||
</select>
|
||
</div>
|
||
|
||
<div>
|
||
<label htmlFor="target-floors" style={fieldLabelStyle}>
|
||
Целевая этажность
|
||
</label>
|
||
<input
|
||
id="target-floors"
|
||
type="number"
|
||
min={1}
|
||
max={30}
|
||
value={params.target_floors}
|
||
onChange={(e) => {
|
||
const raw = Number(e.target.value);
|
||
const clamped = Number.isFinite(raw)
|
||
? Math.min(30, Math.max(1, Math.round(raw)))
|
||
: 1;
|
||
onChange({ ...params, target_floors: clamped });
|
||
}}
|
||
style={controlStyle}
|
||
/>
|
||
<p
|
||
style={{
|
||
margin: "4px 0 0",
|
||
fontSize: 11,
|
||
color: "var(--fg-tertiary)",
|
||
}}
|
||
>
|
||
От 1 до 30 этажей.
|
||
</p>
|
||
</div>
|
||
|
||
<div>
|
||
<label htmlFor="land-cost" style={fieldLabelStyle}>
|
||
Стоимость участка, ₽
|
||
</label>
|
||
<input
|
||
id="land-cost"
|
||
type="number"
|
||
min={0}
|
||
step={1_000_000}
|
||
value={params.land_cost_rub ?? ""}
|
||
placeholder="Необязательно"
|
||
onChange={(e) => {
|
||
const raw = e.target.value.trim();
|
||
const value = raw === "" ? null : Number(raw);
|
||
onChange({
|
||
...params,
|
||
land_cost_rub:
|
||
value != null && Number.isFinite(value) && value >= 0
|
||
? value
|
||
: null,
|
||
});
|
||
}}
|
||
style={controlStyle}
|
||
/>
|
||
<p
|
||
style={{
|
||
margin: "4px 0 0",
|
||
fontSize: 11,
|
||
color: "var(--fg-tertiary)",
|
||
}}
|
||
>
|
||
Учитывается в финансовой модели как часть затрат.
|
||
</p>
|
||
</div>
|
||
|
||
<button
|
||
type="submit"
|
||
disabled={disabled}
|
||
aria-label="Рассчитать концепции застройки"
|
||
style={{
|
||
display: "inline-flex",
|
||
alignItems: "center",
|
||
justifyContent: "center",
|
||
gap: 8,
|
||
padding: "10px 18px",
|
||
height: 44,
|
||
background: disabled ? "var(--bg-card-alt)" : "var(--accent)",
|
||
color: disabled ? "var(--fg-tertiary)" : "#fff",
|
||
border: disabled ? "1px solid var(--border-card)" : "none",
|
||
borderRadius: 8,
|
||
fontSize: 14,
|
||
fontWeight: 600,
|
||
cursor: disabled ? "not-allowed" : "pointer",
|
||
transition: "opacity 150ms",
|
||
}}
|
||
>
|
||
<Sparkles size={16} strokeWidth={1.5} />
|
||
{isPending ? "Расчёт концепций…" : "Рассчитать концепции"}
|
||
</button>
|
||
|
||
{!hasPolygon && (
|
||
<p
|
||
style={{
|
||
margin: 0,
|
||
fontSize: 12,
|
||
color: "var(--fg-tertiary)",
|
||
}}
|
||
>
|
||
Нарисуйте полигон участка или укажите кадастровый номер, чтобы начать
|
||
расчёт.
|
||
</p>
|
||
)}
|
||
</form>
|
||
);
|
||
}
|