hotfix admin panel

This commit is contained in:
Микаэл Оганесян
2025-09-27 22:29:13 -07:00
parent 34ba3c1db0
commit b25df42960
14 changed files with 461 additions and 582 deletions

View File

@@ -73,7 +73,8 @@ if (typeof document !== "undefined") {
import { languageInstance } from "@shared";
import { makeAutoObservable } from "mobx";
import { stationsStore, routeStore, sightsStore } from "@shared";
import { stationsStore, routeStore, sightsStore, menuStore } from "@shared";
import { Token } from "@mui/icons-material";
// Функция для сброса кешей карты
export const clearMapCaches = () => {
@@ -101,6 +102,7 @@ interface ApiStation {
name: string;
latitude: number;
longitude: number;
created_at?: string;
}
interface ApiSight {
@@ -109,8 +111,12 @@ interface ApiSight {
description: string;
latitude: number;
longitude: number;
created_at?: string;
}
export type SortType = "name_asc" | "name_desc" | "date_asc" | "date_desc";
class MapStore {
constructor() {
makeAutoObservable(this);
@@ -120,6 +126,61 @@ class MapStore {
stations: ApiStation[] = [];
sights: ApiSight[] = [];
// НОВЫЕ ПОЛЯ ДЛЯ СОРТИРОВКИ
stationSort: SortType = "name_asc";
sightSort: SortType = "name_asc";
// НОВЫЕ МЕТОДЫ-СЕТТЕРЫ
setStationSort = (sortType: SortType) => {
this.stationSort = sortType;
};
setSightSort = (sortType: SortType) => {
this.sightSort = sortType;
};
// ПРИВАТНЫЙ МЕТОД ДЛЯ ОБЩЕЙ ЛОГИКИ СОРТИРОВКИ
private sortFeatures<T extends ApiStation | ApiSight>(
features: T[],
sortType: SortType
): T[] {
const sorted = [...features];
switch (sortType) {
case "name_asc":
return sorted.sort((a, b) => a.name.localeCompare(b.name));
case "name_desc":
return sorted.sort((a, b) => b.name.localeCompare(a.name));
case "date_asc":
return sorted.sort((a, b) => {
if ('created_at' in a && 'created_at' in b && a.created_at && b.created_at) {
return new Date(a.created_at).getTime() - new Date(b.created_at).getTime();
}
// Фоллбэк: сортировка по ID, если дата недоступна
return a.id - b.id;
});
case "date_desc":
return sorted.sort((a, b) => {
if ('created_at' in a && 'created_at' in b && a.created_at && b.created_at) {
return new Date(b.created_at).getTime() - new Date(a.created_at).getTime();
}
// Фоллбэк: сортировка по ID, если дата недоступна
return b.id - a.id;
});
default:
return sorted;
}
}
// НОВЫЕ ГЕТТЕРЫ, ВОЗВРАЩАЮЩИЕ ОТСОРТИРОВАННЫЕ СПИСКИ
get sortedStations(): ApiStation[] {
return this.sortFeatures(this.stations, this.stationSort);
}
get sortedSights(): ApiSight[] {
return this.sortFeatures(this.sights, this.sightSort);
}
getRoutes = async () => {
const response = await languageInstance("ru").get("/route");
const routesIds = response.data.map((route: any) => route.id);
@@ -2116,6 +2177,8 @@ const MapControls: React.FC<MapControlsProps> = ({
);
};
import {observer} from "mobx-react-lite";
// --- MAP SIGHTBAR COMPONENT ---
interface MapSightbarProps {
mapService: MapService | null;
@@ -2126,8 +2189,7 @@ interface MapSightbarProps {
activeSection: string | null;
setActiveSection: (section: string | null) => void;
}
const MapSightbar: React.FC<MapSightbarProps> = ({
const MapSightbar: React.FC<MapSightbarProps> = observer(({
mapService,
mapFeatures,
selectedFeature,
@@ -2138,50 +2200,50 @@ const MapSightbar: React.FC<MapSightbarProps> = ({
}) => {
const navigate = useNavigate();
const [searchQuery, setSearchQuery] = useState("");
const [stationSort, setStationSort] = useState<SortType>("name_asc");
const [sightSort, setSightSort] = useState<SortType>("name_asc");
const actualFeatures = useMemo(() => {
return mapFeatures.filter((feature) => !feature.get("isProxy"));
}, [mapFeatures]);
const { isOpen } = menuStore;
const actualFeatures = useMemo(
() => mapFeatures.filter((f) => !f.get("isProxy")),
[mapFeatures]
);
const filteredFeatures = useMemo(() => {
if (!searchQuery.trim()) return actualFeatures;
return actualFeatures.filter((feature) =>
((feature.get("name") as string) || "")
return actualFeatures.filter((f) =>
((f.get("name") as string) || "")
.toLowerCase()
.includes(searchQuery.toLowerCase())
);
}, [actualFeatures, searchQuery]);
const handleFeatureClick = useCallback(
(id: string | number | undefined) => {
if (!id || !mapService) return;
(id: string | number) => {
if (!mapService) return;
mapService.selectFeature(id);
},
[mapService]
);
const handleDeleteFeature = useCallback(
// @ts-ignore
(id, recourse) => {
if (
mapService &&
window.confirm("Вы действительно хотите удалить этот объект?")
) {
mapService.deleteFeature(id, recourse);
(id: string | number, resource: string) => {
if (!mapService) return;
if (window.confirm("Вы действительно хотите удалить этот объект?")) {
mapService.deleteFeature(id, resource);
}
},
[mapService]
);
const handleCheckboxChange = useCallback(
(id: string | number | undefined) => {
if (!id || !mapService) return;
(id: string | number) => {
if (!mapService) return;
const newSet = new Set(selectedIds);
if (newSet.has(id)) {
newSet.delete(id);
} else {
newSet.add(id);
}
if (newSet.has(id)) newSet.delete(id);
else newSet.add(id);
setSelectedIds(newSet);
mapService.setSelectedIds(newSet);
},
@@ -2200,69 +2262,68 @@ const MapSightbar: React.FC<MapSightbarProps> = ({
}
}, [mapService, selectedIds, setSelectedIds]);
// @ts-ignore
const handleEditFeature = useCallback(
// @ts-ignore
(featureType, fullId) => {
if (!featureType || !fullId) return;
(featureType: string, fullId: string | number) => {
const numericId = String(fullId).split("-")[1];
if (numericId) navigate(`/${featureType}/${numericId}/edit`);
if (!featureType || !numericId) return;
navigate(`/${featureType}/${numericId}/edit`);
},
[navigate]
);
const sortFeatures = (
features: Feature<Geometry>[],
currentSelectedIds: Set<string | number>,
currentSelectedFeature: Feature<Geometry> | null
) => {
const selectedId = currentSelectedFeature?.getId();
return [...features].sort((a, b) => {
const aId = a.getId();
const bId = b.getId();
if (selectedId) {
if (aId === selectedId) return -1;
if (bId === selectedId) return 1;
}
const aIsChecked = aId !== undefined && currentSelectedIds.has(aId);
const bIsChecked = bId !== undefined && currentSelectedIds.has(bId);
if (aIsChecked && !bIsChecked) return -1;
if (!aIsChecked && bIsChecked) return 1;
const aNumericId = aId ? parseInt(String(aId).split("-")[1], 10) : 0;
const bNumericId = bId ? parseInt(String(bId).split("-")[1], 10) : 0;
if (
!isNaN(aNumericId) &&
!isNaN(bNumericId) &&
aNumericId !== bNumericId
) {
return aNumericId - bNumericId;
}
const aName = (a.get("name") as string) || "";
const bName = (b.get("name") as string) || "";
return aName.localeCompare(bName, "ru");
});
const sortFeaturesByType = <T extends Feature<Geometry>>(
features: T[],
sortType: SortType
): T[] => {
const sorted = [...features];
switch (sortType) {
case "name_asc":
return sorted.sort((a, b) =>
((a.get("name") as string) || "").localeCompare(
((b.get("name") as string) || "")
)
);
case "name_desc":
return sorted.sort((a, b) =>
((b.get("name") as string) || "").localeCompare(
((a.get("name") as string) || "")
)
);
case "date_asc":
return sorted.sort((a, b) => {
const aDate = a.get("created_at")
? new Date(a.get("created_at"))
: new Date(0);
const bDate = b.get("created_at")
? new Date(b.get("created_at"))
: new Date(0);
return aDate.getTime() - bDate.getTime();
});
case "date_desc":
return sorted.sort((a, b) => {
const aDate = a.get("created_at")
? new Date(a.get("created_at"))
: new Date(0);
const bDate = b.get("created_at")
? new Date(b.get("created_at"))
: new Date(0);
return bDate.getTime() - aDate.getTime();
});
default:
return sorted;
}
};
const toggleSection = (id: string) =>
setActiveSection(activeSection === id ? null : id);
const stations = filteredFeatures.filter(
(f) => f.get("featureType") === "station"
);
const lines = filteredFeatures.filter(
(f) => f.get("featureType") === "route"
);
const lines = filteredFeatures.filter((f) => f.get("featureType") === "route");
const sights = filteredFeatures.filter(
(f) => f.get("featureType") === "sight"
);
const sortedStations = sortFeatures(stations, selectedIds, selectedFeature);
const sortedLines = sortFeatures(lines, selectedIds, selectedFeature);
const sortedSights = sortFeatures(sights, selectedIds, selectedFeature);
const sortedStations = sortFeaturesByType(stations, stationSort);
const sortedSights = sortFeaturesByType(sights, sightSort);
const renderFeatureList = (
features: Feature<Geometry>[],
@@ -2273,9 +2334,11 @@ const MapSightbar: React.FC<MapSightbarProps> = ({
{features.length > 0 ? (
features.map((feature) => {
const fId = feature.getId();
if (fId === undefined) return null; // TypeScript-safe
const fName = (feature.get("name") as string) || "Без названия";
const isSelected = selectedFeature?.getId() === fId;
const isChecked = fId !== undefined && selectedIds.has(fId);
const isChecked = selectedIds.has(fId);
return (
<div
key={String(fId)}
@@ -2316,11 +2379,8 @@ const MapSightbar: React.FC<MapSightbarProps> = ({
size={16}
/>
<span
className={`font-medium truncate ${
isSelected
? "text-orange-600"
: "group-hover:text-blue-600"
}`}
className={`font-medium whitespace-nowrap overflow-x-auto block
scrollbar-thin scrollbar-thumb-gray-200 scrollbar-track-transparent`}
title={fName}
>
{fName}
@@ -2331,7 +2391,8 @@ const MapSightbar: React.FC<MapSightbarProps> = ({
<button
onClick={(e) => {
e.stopPropagation();
handleEditFeature(feature.get("featureType"), fId);
const featureTypeVal = feature.get("featureType");
if (featureTypeVal) handleEditFeature(featureTypeVal, fId);
}}
className="p-1 rounded-full text-gray-400 hover:text-blue-600 hover:bg-blue-100 transition-colors"
title="Редактировать детали"
@@ -2358,32 +2419,69 @@ const MapSightbar: React.FC<MapSightbarProps> = ({
</div>
);
const toggleSection = (id: string) =>
setActiveSection(activeSection === id ? null : id);
const sections = [
{
id: "layers",
title: `Остановки (${sortedStations.length})`,
icon: <Bus size={20} />,
count: sortedStations.length,
content: renderFeatureList(sortedStations, "station", MapPin),
content: (
<>
<div className="flex items-center space-x-2 mb-2">
<label>Сортировка:</label>
<select
value={stationSort}
onChange={(e) => setStationSort(e.target.value as SortType)}
className="border rounded px-2 py-1 text-sm"
>
<option value="name_asc">Имя </option>
<option value="name_desc">Имя </option>
</select>
</div>
{renderFeatureList(sortedStations, "station", MapPin)}
</>
),
},
{
id: "lines",
title: `Маршруты (${sortedLines.length})`,
title: `Маршруты (${lines.length})`,
icon: <RouteIcon size={20} />,
count: sortedLines.length,
content: renderFeatureList(sortedLines, "route", ArrowRightLeft),
count: lines.length,
content: renderFeatureList(lines, "route", ArrowRightLeft),
},
{
id: "sights",
title: `Достопримечательности (${sortedSights.length})`,
icon: <Landmark size={20} />,
count: sortedSights.length,
content: renderFeatureList(sortedSights, "sight", Landmark),
content: (
<>
<div className="flex items-center space-x-2 mb-2">
<label>Сортировка:</label>
<select
value={sightSort}
onChange={(e) => setSightSort(e.target.value as SortType)}
className="border rounded px-2 py-1 text-sm"
>
<option value="name_asc">Имя </option>
<option value="name_desc">Имя </option>
</select>
</div>
{renderFeatureList(sortedSights, "sight", Landmark)}
</>
),
},
];
React.useEffect(() => {
console.log("isOpen changed:", isOpen);
}, [isOpen]);
return (
<div className="w-[360px] relative bg-gray-50 shadow-lg flex flex-col border-l border-gray-200 h-[90vh]">
<div className={`${isOpen ? "w-[360px]" : "w-[590px]"} transition-all duration-300 ease-in-out relative bg-gray-50 shadow-lg flex flex-col border-l border-gray-200 h-[90vh]`}>
<div className="p-4 bg-gray-700 text-white">
<h2 className="text-lg font-semibold">Панель управления</h2>
</div>
@@ -2396,8 +2494,7 @@ const MapSightbar: React.FC<MapSightbarProps> = ({
className="w-full px-3 py-2 text-sm border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
<div className="flex-1 flex flex-col min-h-0">
<div className="flex-1 flex flex-col min-h-0 overflow-y-auto">
{filteredFeatures.length === 0 && searchQuery ? (
<div className="p-4 text-center text-gray-500">
Ничего не найдено.
@@ -2452,7 +2549,6 @@ const MapSightbar: React.FC<MapSightbarProps> = ({
)
)}
</div>
{selectedIds.size > 0 && (
<div className="p-3 border-t border-gray-200 bg-white flex-shrink-0">
<button
@@ -2466,7 +2562,7 @@ const MapSightbar: React.FC<MapSightbarProps> = ({
)}
</div>
);
};
});
// --- MAP PAGE COMPONENT ---
export const MapPage: React.FC = () => {
const mapRef = useRef<HTMLDivElement | null>(null);

View File

@@ -301,10 +301,10 @@ export const RouteCreatePage = observer(() => {
</Box>
</Box>
{/* Селектор видео превью */}
{/* Селектор видеозаставки */}
<Box className="flex flex-col gap-2">
<label className="text-sm font-medium text-gray-700">
Видео превью
Видеозаставка
</label>
<Box className="flex gap-2">
<Box

View File

@@ -307,10 +307,10 @@ export const RouteEditPage = observer(() => {
</Box>
</Box>
{/* Селектор видео превью */}
{/* Селектор видеозаставки */}
<Box className="flex flex-col gap-2">
<label className="text-sm font-medium text-gray-700">
Видео превью
Видеозаставка
</label>
<Box className="flex gap-2">
<Box

View File

@@ -6,7 +6,8 @@ import {
MenuItem,
FormControl,
InputLabel,
} from "@mui/material";
}
from "@mui/material";
import { observer } from "mobx-react-lite";
import { ArrowLeft, Save } from "lucide-react";
import { Loader2 } from "lucide-react";
@@ -15,6 +16,7 @@ import { toast } from "react-toastify";
import { stationsStore, languageStore, cityStore } from "@shared";
import { useEffect, useState } from "react";
import { LanguageSwitcher } from "@widgets";
import { SaveWithoutCityAgree } from "@widgets";
export const StationCreatePage = observer(() => {
const navigate = useNavigate();
@@ -28,6 +30,8 @@ export const StationCreatePage = observer(() => {
} = stationsStore;
const { cities, getCities } = cityStore;
const [coordinates, setCoordinates] = useState<string>("");
// НОВОЕ СОСТОЯНИЕ ДЛЯ ПРЕДУПРЕЖДАЮЩЕГО ОКНА
const [isSaveWarningOpen, setIsSaveWarningOpen] = useState(false);
useEffect(() => {
if (
@@ -40,7 +44,8 @@ export const StationCreatePage = observer(() => {
}
}, [createStationData.common.latitude, createStationData.common.longitude]);
const handleCreate = async () => {
// НОВАЯ ФУНКЦИЯ: Фактическое создание (вызывается после подтверждения)
const executeCreate = async () => {
try {
setIsLoading(true);
await createStation();
@@ -54,6 +59,30 @@ export const StationCreatePage = observer(() => {
}
};
// ОБНОВЛЕННАЯ ФУНКЦИЯ: Проверка и вызов окна или создания
const handleCreate = async () => {
const isCityMissing = !createStationData.common.city_id;
const isNameMissing = !createStationData[language].name;
if (isCityMissing || isNameMissing) {
setIsSaveWarningOpen(true);
return;
}
await executeCreate();
};
// Обработчик "Да" в предупреждающем окне
const handleConfirmCreate = async () => {
setIsSaveWarningOpen(false);
await executeCreate();
};
// Обработчик "Нет" в предупреждающем окне
const handleCancelCreate = () => {
setIsSaveWarningOpen(false);
};
useEffect(() => {
const fetchCities = async () => {
await getCities("ru");
@@ -192,7 +221,7 @@ export const StationCreatePage = observer(() => {
className="w-min flex gap-2 items-center"
startIcon={<Save size={20} />}
onClick={handleCreate}
disabled={isLoading || !createStationData[language]?.name}
disabled={isLoading} // Убрал проверку name/city_id отсюда, чтобы ее обрабатывал handleCreate
>
{isLoading ? (
<Loader2 size={20} className="animate-spin" />
@@ -201,6 +230,16 @@ export const StationCreatePage = observer(() => {
)}
</Button>
</div>
{/* ИНТЕГРИРОВАННОЕ ПРЕДУПРЕЖДАЮЩЕЕ ОКНО */}
{isSaveWarningOpen && (
<SaveWithoutCityAgree
blocker={{
proceed: handleConfirmCreate,
reset: handleCancelCreate,
}}
/>
)}
</Paper>
);
});
});

View File

@@ -16,6 +16,7 @@ import { stationsStore, languageStore, cityStore } from "@shared";
import { useEffect, useState } from "react";
import { LanguageSwitcher } from "@widgets";
import { LinkedSights } from "../LinkedSights";
import { SaveWithoutCityAgree } from "@widgets";
export const StationEditPage = observer(() => {
const navigate = useNavigate();
@@ -31,9 +32,10 @@ export const StationEditPage = observer(() => {
} = stationsStore;
const { cities, getCities } = cityStore;
const [coordinates, setCoordinates] = useState<string>("");
// НОВОЕ СОСТОЯНИЕ ДЛЯ ПРЕДУПРЕЖДАЮЩЕГО ОКНА
const [isSaveWarningOpen, setIsSaveWarningOpen] = useState(false);
useEffect(() => {
// Устанавливаем русский язык при загрузке страницы
languageStore.setLanguage("ru");
}, []);
@@ -48,7 +50,8 @@ export const StationEditPage = observer(() => {
}
}, [editStationData.common.latitude, editStationData.common.longitude]);
const handleEdit = async () => {
// НОВАЯ ФУНКЦИЯ: Фактическое редактирование (вызывается после подтверждения)
const executeEdit = async () => {
try {
setIsLoading(true);
await editStation(Number(id));
@@ -61,6 +64,30 @@ export const StationEditPage = observer(() => {
}
};
// ОБНОВЛЕННАЯ ФУНКЦИЯ: Проверка и вызов окна или редактирования
const handleEdit = async () => {
const isCityMissing = !editStationData.common.city_id;
const isNameMissing = !editStationData[language].name;
if (isCityMissing || isNameMissing) {
setIsSaveWarningOpen(true);
return;
}
await executeEdit();
};
// Обработчик "Да" в предупреждающем окне
const handleConfirmEdit = async () => {
setIsSaveWarningOpen(false);
await executeEdit();
};
// Обработчик "Нет" в предупреждающем окне
const handleCancelEdit = () => {
setIsSaveWarningOpen(false);
};
useEffect(() => {
const fetchAndSetStationData = async () => {
if (!id) return;
@@ -211,7 +238,7 @@ export const StationEditPage = observer(() => {
className="w-min flex gap-2 items-center"
startIcon={<Save size={20} />}
onClick={handleEdit}
disabled={isLoading || !editStationData[language]?.name}
disabled={isLoading} // Убрал проверку name/city_id отсюда, чтобы ее обрабатывал handleEdit
>
{isLoading ? (
<Loader2 size={20} className="animate-spin" />
@@ -220,6 +247,16 @@ export const StationEditPage = observer(() => {
)}
</Button>
</div>
{/* ИНТЕГРИРОВАННОЕ ПРЕДУПРЕЖДАЮЩЕЕ ОКНО */}
{isSaveWarningOpen && (
<SaveWithoutCityAgree
blocker={{
proceed: handleConfirmEdit,
reset: handleCancelEdit,
}}
/>
)}
</Paper>
);
});
});

View File

@@ -0,0 +1,15 @@
import { makeAutoObservable } from "mobx";
class MenuStore {
isOpen: boolean = true;
constructor() {
makeAutoObservable(this);
}
setIsMenuOpen = (isOpen: boolean) => {
this.isOpen = isOpen;
};
}
export const menuStore = new MenuStore();

View File

@@ -14,3 +14,4 @@ export * from "./RouteStore";
export * from "./UserStore";
export * from "./CarrierStore";
export * from "./StationsStore";
export * from "./MenuStore"

View File

@@ -8,7 +8,7 @@ import { AppBar } from "./ui/AppBar";
import { Drawer } from "./ui/Drawer";
import { DrawerHeader } from "./ui/DrawerHeader";
import { NavigationList } from "@features";
import { authStore, userStore } from "@shared";
import { authStore, userStore, menuStore } from "@shared";
import { observer } from "mobx-react-lite";
import { useEffect } from "react";
import { Typography } from "@mui/material";
@@ -20,6 +20,14 @@ interface LayoutProps {
export const Layout: React.FC<LayoutProps> = observer(({ children }) => {
const theme = useTheme();
const [open, setOpen] = React.useState(true);
const { setIsMenuOpen } = menuStore;
React.useEffect(() => {
setIsMenuOpen(open);
}, [open]);
const { getUsers, users } = userStore;
useEffect(() => {

View File

@@ -0,0 +1,23 @@
import { Button } from "@mui/material";
export const SaveWithoutCityAgree = ({ blocker }: { blocker: any }) => {
return (
<div className="fixed top-0 left-0 w-screen h-screen flex justify-center items-center z-10000 bg-black/30">
<div className="bg-white p-4 w-140 rounded-lg flex flex-col gap-4 items-center">
<p className="text-black w-140 text-center">
Вы не указали город и/или не заполнили названия на всех языках.
<br />
Сохранить достопримечательность без этой информации?
</p>
<div className="flex gap-4 justify-center">
<Button variant="contained" onClick={() => blocker.proceed()}>
Да
</Button>
<Button variant="outlined" onClick={() => blocker.reset()}>
Нет
</Button>
</div>
</div>
</div>
);
};

View File

@@ -35,8 +35,7 @@ import { Save } from "lucide-react";
import { observer } from "mobx-react-lite";
import { useEffect, useState } from "react";
import { toast } from "react-toastify";
// Мокап для всплывающей подсказки
import { SaveWithoutCityAgree } from "@widgets";
export const CreateInformationTab = observer(
({ value, index }: { value: number; index: number }) => {
@@ -51,7 +50,6 @@ export const CreateInformationTab = observer(
const [, setCity] = useState<number>(sight.city_id ?? 0);
const [coordinates, setCoordinates] = useState<string>(`0, 0`);
// Menu state for each media button
const [menuAnchorEl, setMenuAnchorEl] = useState<null | HTMLElement>(null);
const [activeMenuType, setActiveMenuType] = useState<
"thumbnail" | "watermark_lu" | "watermark_rd" | "video_preview" | null
@@ -62,21 +60,15 @@ export const CreateInformationTab = observer(
"thumbnail" | "watermark_lu" | "watermark_rd" | "video_preview" | null
>(null);
// НОВОЕ СОСТОЯНИЕ ДЛЯ ПРЕДУПРЕЖДАЮЩЕГО ОКНА
const [isSaveWarningOpen, setIsSaveWarningOpen] = useState(false);
useEffect(() => {}, [hardcodeType]);
// const handleMenuOpen = (
// event: React.MouseEvent<HTMLElement>,
// type: "thumbnail" | "watermark_lu" | "watermark_rd"
// ) => {
// setMenuAnchorEl(event.currentTarget);
// setActiveMenuType(type);
// };
useEffect(() => {
// Показывать только при инициализации (не менять при ошибках пользователя)
if (sight.latitude !== 0 || sight.longitude !== 0) {
setCoordinates(`${sight.latitude}, ${sight.longitude}`);
}
// если координаты обнулились — оставить поле как есть
}, [sight.latitude, sight.longitude]);
const handleMenuClose = () => {
@@ -125,6 +117,29 @@ export const CreateInformationTab = observer(
}
};
const handleSave = async () => {
const isCityMissing = !sight.city_id;
const isNameMissing = !sight[language].name;
if (isCityMissing || isNameMissing) {
setIsSaveWarningOpen(true);
return;
}
await createSight(language);
toast.success("Достопримечательность создана");
};
const handleConfirmSave = async () => {
setIsSaveWarningOpen(false);
await createSight(language);
toast.success("Достопримечательность создана");
};
const handleCancelSave = () => {
setIsSaveWarningOpen(false);
};
return (
<>
<TabPanel value={value} index={index}>
@@ -134,7 +149,7 @@ export const CreateInformationTab = observer(
flexDirection: "column",
gap: 3,
position: "relative",
paddingBottom: "70px" /* Space for save button */,
paddingBottom: "70px",
}}
>
<div className="flex gap-10 items-center mb-5 max-w-[80%]">
@@ -146,12 +161,11 @@ export const CreateInformationTab = observer(
sx={{
display: "flex",
gap: 4, // Added gap between the two main columns
gap: 4,
width: "100%",
flexDirection: "column",
}}
>
{/* Left column with main fields */}
<Box
sx={{
flexGrow: 1,
@@ -215,14 +229,13 @@ export const CreateInformationTab = observer(
value={coordinates}
onChange={(e) => {
const input = e.target.value;
setCoordinates(input); // показываем как есть
setCoordinates(input);
const [latStr, lonStr] = input.split(/\s+/); // учитываем любые пробелы
const [latStr, lonStr] = input.split(/\s+/);
const lat = parseFloat(latStr);
const lon = parseFloat(lonStr);
// Проверка, что обе координаты валидные числа
const isValidLat = !isNaN(lat);
const isValidLon = !isNaN(lon);
@@ -260,7 +273,7 @@ export const CreateInformationTab = observer(
justifyContent: "space-around",
width: "80%",
gap: 2,
flexDirection: { xs: "column", sm: "row" }, // Stack on extra small, side-by-side on small and up
flexDirection: { xs: "column", sm: "row" },
}}
>
<ImageUploadCard
@@ -348,7 +361,7 @@ export const CreateInformationTab = observer(
/>
<VideoPreviewCard
title="Видео превью"
title="Видеозаставка"
videoId={sight.video_preview}
onVideoClick={handleVideoPreviewClick}
onDeleteVideoClick={() => {
@@ -358,12 +371,10 @@ export const CreateInformationTab = observer(
}}
onSelectVideoClick={(file) => {
if (file) {
// Если передан файл, открываем диалог загрузки медиа
createSightStore.setFileToUpload(file);
setActiveMenuType("video_preview");
setIsUploadMediaOpen(true);
} else {
// Если файл не передан, открываем диалог выбора существующих медиа
setActiveMenuType("video_preview");
setIsAddMediaOpen(true);
}
@@ -373,31 +384,25 @@ export const CreateInformationTab = observer(
</Box>
</Box>
{/* LanguageSwitcher positioned at the top right */}
<LanguageSwitcher />
{/* Save Button fixed at the bottom right */}
<Box
sx={{
position: "absolute",
bottom: 0,
right: 0,
padding: 2,
backgroundColor: "background.paper", // To ensure it stands out over content
width: "100%", // Take full width to cover content below it
backgroundColor: "background.paper",
width: "100%",
display: "flex",
justifyContent: "flex-end", // Align to the right
justifyContent: "flex-end",
}}
>
<Button
variant="contained"
color="success"
startIcon={<Save color="white" size={18} />}
onClick={async () => {
await createSight(language);
toast.success("Достопримечательность создана");
}}
onClick={handleSave}
>
Сохранить
</Button>
@@ -405,7 +410,6 @@ export const CreateInformationTab = observer(
</Box>
</TabPanel>
{/* Media Menu */}
<MuiMenu
anchorEl={menuAnchorEl}
open={Boolean(menuAnchorEl)}
@@ -471,7 +475,6 @@ export const CreateInformationTab = observer(
initialFile={createSightStore.fileToUpload || undefined}
/>
{/* Модальное окно предпросмотра видео */}
{sight.video_preview && sight.video_preview !== "" && (
<Dialog
open={isVideoPreviewOpen}
@@ -498,7 +501,17 @@ export const CreateInformationTab = observer(
</DialogActions>
</Dialog>
)}
{/* ИНТЕГРИРОВАННОЕ ПРЕДУПРЕЖДАЮЩЕЕ ОКНО */}
{isSaveWarningOpen && (
<SaveWithoutCityAgree
blocker={{
proceed: handleConfirmSave,
reset: handleCancelSave,
}}
/>
)}
</>
);
}
);
);

View File

@@ -37,7 +37,8 @@ import { useEffect, useState } from "react";
import { toast } from "react-toastify";
// Мокап для всплывающей подсказки
// Компонент предупреждающего окна (перенесен сюда)
import { SaveWithoutCityAgree } from "@widgets";
export const InformationTab = observer(
({ value, index }: { value: number; index: number }) => {
@@ -51,7 +52,6 @@ export const InformationTab = observer(
const [, setCity] = useState<number>(sight.common.city_id ?? 0);
const [coordinates, setCoordinates] = useState<string>(`0, 0`);
// Menu state for each media button
const [menuAnchorEl, setMenuAnchorEl] = useState<null | HTMLElement>(null);
const [activeMenuType, setActiveMenuType] = useState<
"thumbnail" | "watermark_lu" | "watermark_rd" | "video_preview" | null
@@ -62,15 +62,15 @@ export const InformationTab = observer(
"thumbnail" | "watermark_lu" | "watermark_rd" | "video_preview" | null
>(null);
const { cities } = cityStore;
// НОВОЕ СОСТОЯНИЕ ДЛЯ ПРЕДУПРЕЖДАЮЩЕГО ОКНА
const [isSaveWarningOpen, setIsSaveWarningOpen] = useState(false);
useEffect(() => {}, [hardcodeType]);
useEffect(() => {
// Показывать только при инициализации (не менять при ошибках пользователя)
if (sight.common.latitude !== 0 || sight.common.longitude !== 0) {
setCoordinates(`${sight.common.latitude}, ${sight.common.longitude}`);
}
// если координаты обнулились — оставить поле как есть
}, [sight.common.latitude, sight.common.longitude]);
const handleMenuClose = () => {
@@ -119,6 +119,36 @@ export const InformationTab = observer(
updateSightInfo(language, content, common);
};
// НОВАЯ ФУНКЦИЯ: Фактическое сохранение (вызывается после подтверждения)
const executeSave = async () => {
await updateSight();
toast.success("Достопримечательность сохранена");
};
// ОБНОВЛЕННАЯ ФУНКЦИЯ: Проверка и вызов окна или сохранения
const handleSave = async () => {
const isCityMissing = !sight.common.city_id;
const isNameMissing = !sight[language].name;
if (isCityMissing || isNameMissing) {
setIsSaveWarningOpen(true);
return;
}
await executeSave();
};
// Обработчик "Да" в предупреждающем окне
const handleConfirmSave = async () => {
setIsSaveWarningOpen(false);
await executeSave();
};
// Обработчик "Нет" в предупреждающем окне
const handleCancelSave = () => {
setIsSaveWarningOpen(false);
};
return (
<>
<TabPanel value={value} index={index}>
@@ -128,7 +158,7 @@ export const InformationTab = observer(
flexDirection: "column",
gap: 3,
position: "relative",
paddingBottom: "70px" /* Space for save button */,
paddingBottom: "70px",
}}
>
<div className="flex gap-10 items-center mb-5 max-w-[80%]">
@@ -141,12 +171,11 @@ export const InformationTab = observer(
sx={{
display: "flex",
gap: 4, // Added gap between the two main columns
gap: 4,
width: "100%",
flexDirection: "column",
}}
>
{/* Left column with main fields */}
<Box
sx={{
flexGrow: 1,
@@ -208,16 +237,14 @@ export const InformationTab = observer(
value={coordinates}
onChange={(e) => {
const newValue = e.target.value;
setCoordinates(newValue); // сохраняем ввод пользователя как есть
setCoordinates(newValue);
// Обрабатываем значение для сохранения
const input = newValue.replace(/,/g, " ").trim();
const [latStr, lonStr] = input.split(/\s+/);
const lat = parseFloat(latStr);
const lon = parseFloat(lonStr);
// Проверка, что обе координаты валидные числа
const isValidLat = !isNaN(lat);
const isValidLon = !isNaN(lon);
@@ -260,7 +287,7 @@ export const InformationTab = observer(
justifyContent: "space-around",
width: "80%",
gap: 2,
flexDirection: { xs: "column", sm: "row" }, // Stack on extra small, side-by-side on small and up
flexDirection: { xs: "column", sm: "row" },
}}
>
<ImageUploadCard
@@ -358,7 +385,7 @@ export const InformationTab = observer(
/>
<VideoPreviewCard
title="Видео превью"
title="Видеозаставка"
videoId={sight.common.video_preview}
onVideoClick={handleVideoPreviewClick}
onDeleteVideoClick={() => {
@@ -372,12 +399,10 @@ export const InformationTab = observer(
}}
onSelectVideoClick={(file) => {
if (file) {
// Если передан файл, открываем диалог загрузки медиа
editSightStore.setFileToUpload(file);
setActiveMenuType("video_preview");
setIsUploadMediaOpen(true);
} else {
// Если файл не передан, открываем диалог выбора существующих медиа
setActiveMenuType("video_preview");
setIsAddMediaOpen(true);
}
@@ -387,31 +412,25 @@ export const InformationTab = observer(
</Box>
</Box>
{/* LanguageSwitcher positioned at the top right */}
<LanguageSwitcher />
{/* Save Button fixed at the bottom right */}
<Box
sx={{
position: "absolute",
bottom: 0,
right: 0,
padding: 2,
backgroundColor: "background.paper", // To ensure it stands out over content
width: "100%", // Take full width to cover content below it
backgroundColor: "background.paper",
width: "100%",
display: "flex",
justifyContent: "flex-end", // Align to the right
justifyContent: "flex-end",
}}
>
<Button
variant="contained"
color="success"
startIcon={<Save color="white" size={18} />}
onClick={async () => {
await updateSight();
toast.success("Достопримечательность сохранена");
}}
onClick={handleSave} // Используем новую функцию-обработчик
>
Сохранить
</Button>
@@ -419,7 +438,6 @@ export const InformationTab = observer(
</Box>
</TabPanel>
{/* Media Menu */}
<MuiMenu
anchorEl={menuAnchorEl}
open={Boolean(menuAnchorEl)}
@@ -492,7 +510,6 @@ export const InformationTab = observer(
mediaId={mediaId}
/>
{/* Модальное окно предпросмотра видео */}
{sight.common.video_preview && sight.common.video_preview !== "" && (
<Dialog
open={isVideoPreviewOpen}
@@ -519,7 +536,17 @@ export const InformationTab = observer(
</DialogActions>
</Dialog>
)}
{/* ИНТЕГРИРОВАННОЕ ПРЕДУПРЕЖДАЮЩЕЕ ОКНО */}
{isSaveWarningOpen && (
<SaveWithoutCityAgree
blocker={{
proceed: handleConfirmSave,
reset: handleCancelSave,
}}
/>
)}
</>
);
}
);
);

View File

@@ -17,4 +17,5 @@ export * from "./LeaveAgree";
export * from "./DeleteModal";
export * from "./SnapshotRestore";
export * from "./CreateButton";
export * from "./SaveWithoutCityAgree"
export * from "./modals";