151 lines
6.6 KiB
TypeScript
151 lines
6.6 KiB
TypeScript
"use client";
|
||
|
||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||
|
||
import { apiFetch } from "@/lib/api";
|
||
|
||
// ── Types ─────────────────────────────────────────────────────────────────────
|
||
// Manually typed to match backend Pydantic schemas (sub-PR 3).
|
||
// After PR #138 merges run `npm run codegen` to sync with generated openapi types.
|
||
|
||
export interface WeightProfile {
|
||
id: number;
|
||
user_id: string;
|
||
profile_name: string;
|
||
weights: Record<string, number>;
|
||
is_default: boolean;
|
||
description: string | null;
|
||
created_at: string;
|
||
updated_at: string;
|
||
}
|
||
|
||
export interface WeightProfileCreate {
|
||
user_id: string;
|
||
profile_name: string;
|
||
weights: Record<string, number>;
|
||
is_default?: boolean;
|
||
description?: string | null;
|
||
}
|
||
|
||
// ── Constants ─────────────────────────────────────────────────────────────────
|
||
|
||
/**
|
||
* Владелец системных пресетов (Эконом / Комфорт / Бизнес) — mirrors
|
||
* `SYSTEM_USER_ID` в backend/app/services/site_finder/weight_profiles.py.
|
||
* Профили с этим user_id общие для всех и НЕ адресуемы через `profile_id`:
|
||
* `resolve_weights()` ищет профиль в области владельца, у чужого пользователя
|
||
* его не найдёт и молча вернёт системные веса с ответом `source="profile"`
|
||
* (#2782). Их веса уходят в analyze inline — см. WeightProfilePanel.
|
||
*/
|
||
export const SYSTEM_PROFILE_USER_ID = "__system__";
|
||
|
||
// ALLOWED_CATEGORIES — mirrors backend weight_profiles.py ALLOWED_CATEGORIES.
|
||
// Keep in sync with backend; source of truth is `_POI_WEIGHTS` in parcels.py.
|
||
|
||
export const POI_CATEGORIES = [
|
||
"school",
|
||
"kindergarten",
|
||
"pharmacy",
|
||
"hospital",
|
||
"shop_mall",
|
||
"shop_supermarket",
|
||
"shop_small",
|
||
"park",
|
||
"bus_stop",
|
||
"metro_stop",
|
||
"tram_stop",
|
||
] as const;
|
||
|
||
export type PoiCategoryKey = (typeof POI_CATEGORIES)[number];
|
||
|
||
// System defaults — mirrors _POI_WEIGHTS in backend/app/api/v1/parcels.py.
|
||
export const POI_DEFAULT_WEIGHTS: Record<PoiCategoryKey, number> = {
|
||
school: 1.5,
|
||
kindergarten: 1.5,
|
||
pharmacy: 0.8,
|
||
hospital: 0.6,
|
||
shop_mall: 1.2,
|
||
shop_supermarket: 1.0,
|
||
shop_small: 0.5,
|
||
park: 1.8,
|
||
bus_stop: 0.3,
|
||
metro_stop: 1.5,
|
||
tram_stop: -0.5,
|
||
};
|
||
|
||
export const POI_LABELS: Record<PoiCategoryKey, string> = {
|
||
school: "Школы",
|
||
kindergarten: "Детсады",
|
||
pharmacy: "Аптеки",
|
||
hospital: "Больницы",
|
||
shop_mall: "ТРЦ / Молы",
|
||
shop_supermarket: "Супермаркеты",
|
||
shop_small: "Магазины у дома",
|
||
park: "Парки",
|
||
bus_stop: "Автобусные ост.",
|
||
metro_stop: "Метро",
|
||
tram_stop: "Трамвайные ост. (−)",
|
||
};
|
||
|
||
// Weight range bounds — mirrors MIN_WEIGHT / MAX_WEIGHT in backend.
|
||
export const POI_WEIGHT_MIN = -2;
|
||
export const POI_WEIGHT_MAX = 3;
|
||
|
||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||
|
||
const BASE_PATH = "/api/v1/admin/site-finder/weight-profiles";
|
||
|
||
// Никакого X-Admin-Token: сервер перестал его читать в #437, а последний
|
||
// `verify_admin_token` удалён в #2775. Заголовок отправлялся ещё год после этого и
|
||
// ничего не решал — проверено живым запросом на проде (#2782): один и тот же 200
|
||
// с корректным токеном, с мусорным и без заголовка вовсе.
|
||
//
|
||
// Реальная защита `/api/v1/admin/*` — два живых слоя, оба прод-проверены:
|
||
// 1) Caddy basic_auth на gendsgn.ru → без валидных кред 401 ещё на периметре
|
||
// (подставленный клиентом X-Authenticated-User туда же не проходит);
|
||
// 2) app/main.py rbac_guard → `role != "admin"` даёт 403 "admin only".
|
||
|
||
// ── Hooks ─────────────────────────────────────────────────────────────────────
|
||
|
||
/**
|
||
* Профили пользователя + системные пресеты (#2790).
|
||
*
|
||
* `include_system=true` домешивает в конец списка три общих пресета (Эконом /
|
||
* Комфорт / Бизнес, засеяны `data/sql/100_user_weight_profiles_default_seed.sql`).
|
||
* Без него у пользователя без своих профилей дропдаун пустой — пресеты лежали в
|
||
* проде с 16.05.2026 и не были видны никому.
|
||
*/
|
||
export function useWeightProfiles(userId: string) {
|
||
return useQuery<WeightProfile[]>({
|
||
queryKey: ["weight-profiles", userId],
|
||
queryFn: () =>
|
||
apiFetch<WeightProfile[]>(
|
||
`${BASE_PATH}?user_id=${encodeURIComponent(userId)}&include_system=true`,
|
||
),
|
||
enabled: !!userId,
|
||
});
|
||
}
|
||
|
||
/** Create a new weight profile. Invalidates the list query on success. */
|
||
export function useCreateProfile() {
|
||
const qc = useQueryClient();
|
||
return useMutation<WeightProfile, Error, WeightProfileCreate>({
|
||
mutationFn: (payload) =>
|
||
apiFetch<WeightProfile>(BASE_PATH, {
|
||
method: "POST",
|
||
body: JSON.stringify(payload),
|
||
}),
|
||
onSuccess: (_, variables) => {
|
||
void qc.invalidateQueries({
|
||
queryKey: ["weight-profiles", variables.user_id],
|
||
});
|
||
},
|
||
});
|
||
}
|
||
|
||
// useUpdateProfile / useDeleteProfile здесь больше нет (#2790 п.3). Их не звали
|
||
// ниоткуда: в UI есть список и создание, кнопок «переименовать» / «удалить» нет.
|
||
// Спрос за 3 месяца по проду: 1 профиль на всю базу (`admin`, создан 15.05.2026,
|
||
// updated_at = created_at) + 3 системных пресета — ни одного изменения и ни
|
||
// одной попытки удаления. PUT/DELETE-эндпоинты живы и покрыты тестами бэкенда;
|
||
// понадобится UI — хуки вернутся из истории (мертвее они там не станут).
|