From c55eb4f4d4ead39bee5990c9337bf164c1e297a1 Mon Sep 17 00:00:00 2001 From: Light1YT Date: Sun, 28 Jun 2026 02:49:44 +0500 Subject: [PATCH] =?UTF-8?q?feat(concept):=20=D1=84=D1=80=D0=BE=D0=BD=D1=82?= =?UTF-8?q?-=D0=BF=D0=B8=D0=BA=D0=B5=D1=80=20=D1=82=D0=B8=D0=BF=D0=BE?= =?UTF-8?q?=D0=B2=D1=8B=D1=85=20=D0=B4=D0=BE=D0=BC=D0=BE=D0=B2=20(#1965=20?= =?UTF-8?q?Stage=203b)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Финальная часть эпика #1953: пользователь выбирает типовые дома (тип × этажность × число секций) вместо авто max-FAR раскладки, формируя building_program из Stage 3a. Бэкенд: - GET /api/v1/concepts/house-types — read-only каталог HOUSE_TYPES (section_type, label_ru, footprint w×d + sqm, default_floors, housing_class) как single source of truth; фронт ничего не хардкодит. - Схема HouseTypeCatalog / HouseTypeCatalogItem в schemas/concept.py. - Тесты эндпоинта: полнота каталога + совпадение ключей с available_section_types. Кодген: api-types.ts перегенерён (dump OpenAPI → openapi-typescript → project-local prettier 3.9.0); 2-й прогон без диффа. Фронтенд: - useHouseTypes() (TanStack useQuery, staleTime Infinity) в concept-api.ts; building_program в ConceptInput, placed_count/requested_count в ConceptVariant. - HouseProgramPicker: toggle «Авто (max-FAR)» (default, program omit → greedy) vs «Выбрать дома» (список каталожных типов, count 1-50 / floors 1-40, дефолт из каталога; габариты/этажность/класс как подсказка). Смонтирован в Section7Concept и на странице /concept. - Partial-fit заметка в ConceptVariantsResult: при placed tuple[float | None, str return None, "class_norm" +@router.get("/house-types", response_model=HouseTypeCatalog) +async def list_house_types() -> HouseTypeCatalog: + """Stage 3b (#1965, эпик #1953) — каталог типовых домов для фронт-пикера. + + Read-only проекция ``catalog.HOUSE_TYPES`` (single source of truth по типам + секций): фронт Stage 3b показывает эти типы в режиме «Выбрать дома» и кладёт + выбранные ``section_type`` в ``ConceptInput.building_program``. Без БД, без + side-effects — справочник захардкожен в коде (см. модуль ``catalog``). Так + фронт НЕ хардкодит габариты/этажности — они приходят отсюда. + """ + return HouseTypeCatalog( + house_types=[ + HouseTypeCatalogItem( + section_type=ht.section_type, + label_ru=ht.label_ru, + footprint_w_m=ht.footprint_w_m, + footprint_d_m=ht.footprint_d_m, + footprint_sqm=ht.footprint_sqm, + default_floors=ht.default_floors, + housing_class=ht.housing_class, + ) + for ht in HOUSE_TYPES + ] + ) + + @router.post("", response_model=ConceptOutput) async def create_concept( payload: ConceptInput, diff --git a/backend/app/schemas/concept.py b/backend/app/schemas/concept.py index 38ef9a42..c0c4cde8 100644 --- a/backend/app/schemas/concept.py +++ b/backend/app/schemas/concept.py @@ -22,6 +22,39 @@ class BuildingProgramItem(BaseModel): count: int = Field(..., ge=1, le=50, description="Number of sections to place") +class HouseTypeCatalogItem(BaseModel): + """Stage 3b (#1965, эпик #1953) — один тип дома каталога, ОТДАВАЕМЫЙ фронту. + + Read-only проекция ``app.services.generative.catalog.HouseType`` для пикера + Stage 3b: фронт показывает ``label_ru`` + габариты пятна + дефолтную этажность + + класс, а в ``BuildingProgramItem.section_type`` кладёт ``section_type``. Это + делает каталог ЕДИНЫМ источником истины — фронт ничего не хардкодит. Поля + зеркалят dataclass на бэке (см. ``GET /api/v1/concepts/house-types``). + """ + + # Стабильный машинный ключ типа — значение для BuildingProgramItem.section_type. + section_type: str = Field(..., description="Catalog house-type key (HOUSE_TYPES)") + # Человекочитаемый русский лейбл для UI. + label_ru: str = Field(..., description="Human-readable Russian label") + # Габариты пятна секции, метры (ширина × глубина). + footprint_w_m: float = Field(..., description="Section footprint width, m") + footprint_d_m: float = Field(..., description="Section footprint depth, m") + # Площадь пятна секции, кв.м (helper для UI: ширина × глубина). + footprint_sqm: float = Field(..., description="Section footprint area, m² (w × d)") + # Дефолтная этажность типа — UI подставляет, пользователь меняет в [1, 40]. + default_floors: int = Field(..., description="Default floors for this type") + # Подходящий класс жилья (econom / comfort / business). + housing_class: Literal["econom", "comfort", "business"] = Field( + ..., description="Suitable housing class" + ) + + +class HouseTypeCatalog(BaseModel): + """Stage 3b (#1965) — каталог типовых домов, отдаваемый ``GET /concepts/house-types``.""" + + house_types: list[HouseTypeCatalogItem] + + class ConceptInput(BaseModel): """Stage 1a — input contract. Frozen interface for frontend codegen.""" diff --git a/backend/tests/services/generative/test_api_concepts.py b/backend/tests/services/generative/test_api_concepts.py index 8cd42603..950ebe44 100644 --- a/backend/tests/services/generative/test_api_concepts.py +++ b/backend/tests/services/generative/test_api_concepts.py @@ -246,3 +246,46 @@ def test_concepts_program_unknown_type_returns_422() -> None: ) assert response.status_code == 422 assert "unknown house type" in response.json()["detail"].lower() + + +# ── Stage 3b (#1965): GET /api/v1/concepts/house-types catalog ────────────────────── + + +def test_house_types_catalog_returns_full_catalog() -> None: + # Каталог отдаётся read-only (GET, без БД) и зеркалит catalog.HOUSE_TYPES — + # single source of truth для пикера Stage 3b (фронт ничего не хардкодит). + from app.services.generative.catalog import HOUSE_TYPES + + client = TestClient(app) + resp = client.get("/api/v1/concepts/house-types") + assert resp.status_code == 200, resp.text + items = resp.json()["house_types"] + # Полный каталог: один пункт на каждый HouseType, ключи совпадают с каталогом. + assert len(items) == len(HOUSE_TYPES) + assert {it["section_type"] for it in items} == {ht.section_type for ht in HOUSE_TYPES} + + # Каждый пункт несёт всё, что нужно пикеру: лейбл, габариты, дефолт-этажность, класс. + by_key = {it["section_type"]: it for it in items} + for ht in HOUSE_TYPES: + item = by_key[ht.section_type] + assert item["label_ru"] == ht.label_ru + assert item["footprint_w_m"] == ht.footprint_w_m + assert item["footprint_d_m"] == ht.footprint_d_m + # footprint_sqm — производный helper для UI (ширина × глубина). + assert item["footprint_sqm"] == ht.footprint_w_m * ht.footprint_d_m + assert item["default_floors"] == ht.default_floors + assert item["housing_class"] == ht.housing_class + # default_floors в пределах контракта BuildingProgramItem [1, 40]. + assert 1 <= item["default_floors"] <= 40 + + +def test_house_types_catalog_keys_match_available_section_types() -> None: + # Ключи каталога == множество, валидируемое в create_concept (program-режим). + # Гарантирует: всё, что фронт может выбрать, бэк примет (нет рассинхрона 422). + from app.services.generative.catalog import available_section_types + + client = TestClient(app) + resp = client.get("/api/v1/concepts/house-types") + assert resp.status_code == 200, resp.text + catalog_keys = {it["section_type"] for it in resp.json()["house_types"]} + assert catalog_keys == set(available_section_types()) diff --git a/frontend/src/app/concept/page.tsx b/frontend/src/app/concept/page.tsx index a24e8901..e0d1cb39 100644 --- a/frontend/src/app/concept/page.tsx +++ b/frontend/src/app/concept/page.tsx @@ -26,10 +26,15 @@ import { type ConceptParams, } from "@/components/concept/ConceptParamsForm"; import { ConceptVariantsResult } from "@/components/concept/ConceptVariantsResult"; +import { + HouseProgramPicker, + type ProgramMode, +} from "@/components/concept/HouseProgramPicker"; import { polygonAreaSqm, useCadastreGeom, useCreateConcept, + type BuildingProgramItem, } from "@/lib/concept-api"; // ConceptDrawMap uses Leaflet + leaflet-draw — must load without SSR. @@ -70,6 +75,10 @@ export default function ConceptPage() { const [polygon, setPolygon] = useState(null); const [params, setParams] = useState(DEFAULT_PARAMS); + // Stage 3b (#1965): program mode — «Авто (max-FAR)» vs «Выбрать дома». + const [programMode, setProgramMode] = useState("auto"); + const [houseProgram, setHouseProgram] = useState([]); + const cadastreGeom = useCadastreGeom(); const concept = useCreateConcept(); @@ -93,12 +102,19 @@ export default function ConceptPage() { function handleGenerate() { if (!polygon) return; + // «Выбрать дома» с непустой программой → building_program (бэк кладёт ровно + // её). Иначе omit → жадная max-FAR раскладка (поведение по умолчанию). + const program = + programMode === "pick" && houseProgram.length > 0 + ? houseProgram + : undefined; concept.mutate({ parcel_geojson: polygon, housing_class: params.housing_class, target_floors: params.target_floors, development_type: params.development_type, land_cost_rub: params.land_cost_rub, + building_program: program, }); } @@ -228,15 +244,24 @@ export default function ConceptPage() { {/* Parameters */}
- +
+ + +
diff --git a/frontend/src/components/concept/ConceptVariantsResult.tsx b/frontend/src/components/concept/ConceptVariantsResult.tsx index fcc1e769..9ec5cfcd 100644 --- a/frontend/src/components/concept/ConceptVariantsResult.tsx +++ b/frontend/src/components/concept/ConceptVariantsResult.tsx @@ -100,6 +100,16 @@ function VariantPanel({ parcel, variant }: PanelProps) { ? false : null; + // Stage 3b (#1965) — partial-fit honest note: program mode (requested_count + // != null) where fewer sections fit than were requested. Neutral, not an + // error: the user asked for M, the parcel takes N < M. Greedy / full-fit → null. + const partialFit = + variant.requested_count != null && + variant.placed_count != null && + variant.placed_count < variant.requested_count + ? { placed: variant.placed_count, requested: variant.requested_count } + : null; + return (
{/* Headline-bar — one-sentence JTBD verdict for the variant. */} @@ -140,6 +150,28 @@ function VariantPanel({ parcel, variant }: PanelProps) {
+ {/* Stage 3b — partial-fit note: program asked for more sections than fit. */} + {partialFit && ( +
+ Разместилось {formatInt(partialFit.placed)} из{" "} + {formatInt(partialFit.requested)} секций — участок вмещает меньше, чем + в заданной программе. ТЭП и финмодель рассчитаны по фактически + размещённым секциям. +
+ )} + {/* Placement map */}
void; + /** Selected program rows (pick mode). Owned by the parent; lifted via onChange. */ + program: BuildingProgramItem[]; + onChange: (program: BuildingProgramItem[]) => void; + /** Disables controls while a calculation is in flight. */ + isPending: boolean; +} + +function clampInt( + raw: number, + min: number, + max: number, + fallback: number, +): number { + if (!Number.isFinite(raw)) return fallback; + return Math.min(max, Math.max(min, Math.round(raw))); +} + +export function HouseProgramPicker({ + mode, + onModeChange, + program, + onChange, + isPending, +}: Props) { + const houseTypes = useHouseTypes(); + const catalog = houseTypes.data ?? []; + + function byKey(sectionType: string): HouseTypeCatalogItem | undefined { + return catalog.find((ht) => ht.section_type === sectionType); + } + + function addRow() { + const first = catalog[0]; + if (!first) return; + onChange([ + ...program, + { + section_type: first.section_type, + floors: first.default_floors, + count: 1, + }, + ]); + } + + function updateRow(index: number, patch: Partial) { + onChange( + program.map((row, i) => (i === index ? { ...row, ...patch } : row)), + ); + } + + function removeRow(index: number) { + onChange(program.filter((_, i) => i !== index)); + } + + function handleTypeChange(index: number, sectionType: string) { + // Switching the type resets floors to that type's catalog default (the + // previous floors were tuned for a different house) but keeps the count. + const ht = byKey(sectionType); + updateRow(index, { + section_type: sectionType, + floors: ht ? ht.default_floors : program[index].floors, + }); + } + + return ( +
+ {/* Mode toggle */} +
+ Что строить +
+ onModeChange("auto")} + label="Авто (max-FAR)" + /> + onModeChange("pick")} + label="Выбрать дома" + /> +
+

+ {mode === "auto" + ? "Движок сам подберёт максимально плотную застройку в пределах нормативов (три стратегии)." + : "Соберите программу из типовых домов — движок разместит ровно её и покажет, сколько секций поместилось."} +

+
+ + {/* Pick mode: catalog program builder */} + {mode === "pick" && ( +
+ {houseTypes.isPending && ( +

+ Загрузка каталога типовых домов… +

+ )} + + {houseTypes.isError && ( +

+ Не удалось загрузить каталог типовых домов:{" "} + {houseTypes.error.message} +

+ )} + + {houseTypes.isSuccess && ( + <> + {program.length === 0 && ( +

+ Добавьте хотя бы один тип дома, чтобы задать программу. Пока + список пуст — будет использован авторежим. +

+ )} + + {program.map((row, i) => { + const ht = byKey(row.section_type); + return ( +
+
+ {/* House type */} +
+ + +
+ + {/* Floors */} +
+ + + updateRow(i, { + floors: clampInt( + Number(e.target.value), + FLOORS_MIN, + FLOORS_MAX, + ht?.default_floors ?? FLOORS_MIN, + ), + }) + } + style={controlStyle} + /> +
+ + {/* Count */} +
+ + + updateRow(i, { + count: clampInt( + Number(e.target.value), + COUNT_MIN, + COUNT_MAX, + COUNT_MIN, + ), + }) + } + style={controlStyle} + /> +
+ + {/* Remove */} + +
+ + {/* Catalog helper text — footprint / default floors / class */} + {ht && ( +

+ Пятно секции {nf.format(ht.footprint_w_m)} ×{" "} + {nf.format(ht.footprint_d_m)} м ≈{" "} + {nf.format(ht.footprint_sqm)} м² · типовая этажность{" "} + {ht.default_floors} · класс{" "} + {HOUSING_CLASS_LABELS[ht.housing_class]}. +

+ )} +
+ ); + })} + + + + )} +
+ )} +
+ ); +} + +// ── Mode tab ──────────────────────────────────────────────────────────────── + +interface ModeTabProps { + active: boolean; + onClick: () => void; + label: string; +} + +function ModeTab({ active, onClick, label }: ModeTabProps) { + return ( + + ); +} diff --git a/frontend/src/components/site-finder/analysis/Section7Concept.tsx b/frontend/src/components/site-finder/analysis/Section7Concept.tsx index 19223f24..2417ce38 100644 --- a/frontend/src/components/site-finder/analysis/Section7Concept.tsx +++ b/frontend/src/components/site-finder/analysis/Section7Concept.tsx @@ -41,11 +41,16 @@ import { DEFAULT_PARAMS, type ConceptParams, } from "@/components/concept/ConceptParamsForm"; +import { + HouseProgramPicker, + type ProgramMode, +} from "@/components/concept/HouseProgramPicker"; import { extractPolygon, polygonAreaSqm, polygonCentroidWkt, useCreateConcept, + type BuildingProgramItem, type DevelopmentType, type HousingClass, type MassingProgram, @@ -183,6 +188,12 @@ export function Section7Concept({ analysis }: Props) { }, [fin, analysis.egrn?.cadastral_value_rub]); const [params, setParams] = useState(seededParams); + + // Stage 3b (#1965): program mode — «Авто (max-FAR)» (default, building_program + // omitted → greedy unchanged) vs «Выбрать дома» (typed house program). + const [programMode, setProgramMode] = useState("auto"); + const [houseProgram, setHouseProgram] = useState([]); + const concept = useCreateConcept(); const financeUnavailable = fin == null; @@ -248,12 +259,20 @@ export function Section7Concept({ analysis }: Props) { function handleGenerate() { if (!polygon) return; + // «Выбрать дома» с непустой программой → передаём building_program (бэк кладёт + // ровно её, один вариант). Иначе (авто / пустой список) — omit → жадная + // max-FAR раскладка (3 стратегии), поведение по умолчанию не меняется. + const program = + programMode === "pick" && houseProgram.length > 0 + ? houseProgram + : undefined; concept.mutate({ parcel_geojson: polygon, housing_class: params.housing_class, target_floors: params.target_floors, development_type: params.development_type, land_cost_rub: params.land_cost_rub, + building_program: program, }); } @@ -396,15 +415,24 @@ export function Section7Concept({ analysis }: Props) {
- +
+ + +
diff --git a/frontend/src/lib/api-types.ts b/frontend/src/lib/api-types.ts index a0180cb4..ea7dae0e 100644 --- a/frontend/src/lib/api-types.ts +++ b/frontend/src/lib/api-types.ts @@ -4,6 +4,32 @@ */ export interface paths { + "/api/v1/concepts/house-types": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * List House Types + * @description Stage 3b (#1965, эпик #1953) — каталог типовых домов для фронт-пикера. + * + * Read-only проекция ``catalog.HOUSE_TYPES`` (single source of truth по типам + * секций): фронт Stage 3b показывает эти типы в режиме «Выбрать дома» и кладёт + * выбранные ``section_type`` в ``ConceptInput.building_program``. Без БД, без + * side-effects — справочник захардкожен в коде (см. модуль ``catalog``). Так + * фронт НЕ хардкодит габариты/этажности — они приходят отсюда. + */ + get: operations["list_house_types_api_v1_concepts_house_types_get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/v1/concepts": { parameters: { query?: never; @@ -3819,6 +3845,62 @@ export interface components { */ include_risks: boolean; }; + /** + * HouseTypeCatalog + * @description Stage 3b (#1965) — каталог типовых домов, отдаваемый ``GET /concepts/house-types``. + */ + HouseTypeCatalog: { + /** House Types */ + house_types: components["schemas"]["HouseTypeCatalogItem"][]; + }; + /** + * HouseTypeCatalogItem + * @description Stage 3b (#1965, эпик #1953) — один тип дома каталога, ОТДАВАЕМЫЙ фронту. + * + * Read-only проекция ``app.services.generative.catalog.HouseType`` для пикера + * Stage 3b: фронт показывает ``label_ru`` + габариты пятна + дефолтную этажность + * + класс, а в ``BuildingProgramItem.section_type`` кладёт ``section_type``. Это + * делает каталог ЕДИНЫМ источником истины — фронт ничего не хардкодит. Поля + * зеркалят dataclass на бэке (см. ``GET /api/v1/concepts/house-types``). + */ + HouseTypeCatalogItem: { + /** + * Section Type + * @description Catalog house-type key (HOUSE_TYPES) + */ + section_type: string; + /** + * Label Ru + * @description Human-readable Russian label + */ + label_ru: string; + /** + * Footprint W M + * @description Section footprint width, m + */ + footprint_w_m: number; + /** + * Footprint D M + * @description Section footprint depth, m + */ + footprint_d_m: number; + /** + * Footprint Sqm + * @description Section footprint area, m² (w × d) + */ + footprint_sqm: number; + /** + * Default Floors + * @description Default floors for this type + */ + default_floors: number; + /** + * Housing Class + * @description Suitable housing class + * @enum {string} + */ + housing_class: "econom" | "comfort" | "business"; + }; /** InsightCreate */ InsightCreate: { /** @@ -5039,6 +5121,26 @@ export interface components { } export type $defs = Record; export interface operations { + list_house_types_api_v1_concepts_house_types_get: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HouseTypeCatalog"]; + }; + }; + }; + }; create_concept_api_v1_concepts_post: { parameters: { query?: never; diff --git a/frontend/src/lib/concept-api.ts b/frontend/src/lib/concept-api.ts index f0d1cb1c..7d3aa24d 100644 --- a/frontend/src/lib/concept-api.ts +++ b/frontend/src/lib/concept-api.ts @@ -14,7 +14,7 @@ * ConceptExportButtons). */ -import { useMutation } from "@tanstack/react-query"; +import { useMutation, useQuery } from "@tanstack/react-query"; import type { Feature, FeatureCollection, Polygon } from "geojson"; import { apiFetch, apiFetchWithStatus } from "@/lib/api"; @@ -25,6 +25,22 @@ export type HousingClass = "econom" | "comfort" | "business"; export type DevelopmentType = "spot" | "mid_rise" | "high_rise"; export type ConceptStrategy = "max_area" | "max_insolation" | "balanced"; +/** + * Один пункт программы застройки (Stage 3a/3b, #1965): «поставить `count` + * секций каталожного типа `section_type` этажностью `floors`». Габариты пятна + * берёт бэк из каталога по ключу. None-программа → жадная max-FAR раскладка. + * Зеркалит `BuildingProgramItem` в backend/app/schemas/concept.py + * (`components["schemas"]["BuildingProgramItem"]` в api-types.ts). + */ +export interface BuildingProgramItem { + /** Ключ типа дома из каталога house-types (см. useHouseTypes). */ + section_type: string; + /** Этажность группы секций, 1–40. */ + floors: number; + /** Сколько секций этого типа разместить, 1–50. */ + count: number; +} + export interface ConceptInput { /** GeoJSON Polygon of the parcel (WGS84 / EPSG:4326). */ parcel_geojson: Polygon; @@ -34,6 +50,12 @@ export interface ConceptInput { development_type: DevelopmentType; /** Стоимость участка (₽) — опционально, для финмодели. */ land_cost_rub?: number | null; + /** + * Stage 3b (#1965): опциональная программа типовых домов. Не задана / пусто → + * жадная max-FAR раскладка (3 стратегии, без изменений). Задана → бэк кладёт + * РОВНО эту программу (один вариант) и возвращает partial-fit сигнал. + */ + building_program?: BuildingProgramItem[] | null; } // ── Output contract (ConceptOutput) ───────────────────────────────────────── @@ -130,6 +152,14 @@ export interface ConceptVariant { buildings_geojson: FeatureCollection; teap: Teap; financial: FinancialModel; + /** + * Stage 3b (#1965) — partial-fit сигнал program-режима: `requested_count` — + * сколько секций просили (Σ count), `placed_count` — сколько реально влезло. + * placed < requested → участок вмещает меньше (честная заметка, не ошибка). + * Оба null в greedy-режиме (программа не задана) — backward-compat. + */ + placed_count?: number | null; + requested_count?: number | null; } export interface ConceptOutput { @@ -196,6 +226,54 @@ export function useCreateConcept() { }); } +// ── House-type catalog (Stage 3b, #1965) ───────────────────────────────────── + +/** + * Один тип дома каталога, отдаваемый `GET /concepts/house-types`. Зеркалит + * `HouseTypeCatalogItem` в backend/app/schemas/concept.py + * (`components["schemas"]["HouseTypeCatalogItem"]` в api-types.ts). Каталог — + * единый источник истины: фронт НЕ хардкодит габариты/этажности. + */ +export interface HouseTypeCatalogItem { + /** Ключ типа — значение для BuildingProgramItem.section_type. */ + section_type: string; + /** Человекочитаемый русский лейбл. */ + label_ru: string; + /** Ширина пятна секции, м. */ + footprint_w_m: number; + /** Глубина пятна секции, м. */ + footprint_d_m: number; + /** Площадь пятна секции, м² (ширина × глубина). */ + footprint_sqm: number; + /** Дефолтная этажность типа (UI подставляет; пользователь меняет в 1–40). */ + default_floors: number; + /** Подходящий класс жилья. */ + housing_class: HousingClass; +} + +interface HouseTypeCatalog { + house_types: HouseTypeCatalogItem[]; +} + +/** + * GET /api/v1/concepts/house-types — каталог типовых домов для пикера Stage 3b. + * Read-only справочник (статичен на бэке) → `useQuery` per data-layer rule; + * `staleTime: Infinity`, т.к. каталог не меняется в рамках сессии. Возвращает + * массив пунктов каталога (разворачиваем `{house_types}` в `select`). + */ +export function useHouseTypes() { + return useQuery({ + queryKey: ["concept", "house-types"], + queryFn: async () => { + const data = await apiFetch( + "/api/v1/concepts/house-types", + ); + return data.house_types; + }, + staleTime: Infinity, + }); +} + // ── Live recompute contract (Stage 2b, #1965) ─────────────────────────────── /**