feat: Select city in top of the page for next usage in create/edit pages

This commit is contained in:
2025-09-28 10:41:13 +03:00
parent 1abd6b30a4
commit db64beb3ee
12 changed files with 249 additions and 11 deletions

62
CITY_SELECTOR_FEATURE.md Normal file
View File

@@ -0,0 +1,62 @@
# Селектор городов
## Описание функциональности
Добавлена функциональность выбора города в админ-панели "Белые ночи":
### Основные возможности:
1. **Селектор городов в шапке приложения**
- Расположен рядом с именем пользователя в верхней части приложения
- Показывает список всех доступных городов
- Имеет иконку MapPin для лучшего UX
2. **Сохранение в localStorage**
- Выбранный город автоматически сохраняется в localStorage
- При перезагрузке страницы выбранный город восстанавливается
3. **Автоматическое использование в формах**
- При создании новой станции выбранный город автоматически подставляется
- При создании нового перевозчика выбранный город автоматически подставляется
- Пользователь может изменить город в форме при необходимости
### Технические детали:
#### Новые компоненты и сторы:
- `SelectedCityStore` - стор для управления выбранным городом
- `CitySelector` - компонент селектора городов
- `useSelectedCity` - хук для удобного доступа к выбранному городу
#### Интеграция:
- Селектор добавлен в `Layout` компонент
- Интегрирован в `StationCreatePage` и `CarrierCreatePage`
- Использует существующий `CityStore` для получения списка городов
#### Файлы, которые были изменены:
- `src/widgets/Layout/index.tsx` - добавлен CitySelector
- `src/pages/Station/StationCreatePage/index.tsx` - автоматическая подстановка города
- `src/pages/Carrier/CarrierCreatePage/index.tsx` - автоматическая подстановка города
- `src/shared/store/index.ts` - добавлен экспорт SelectedCityStore
- `src/widgets/index.ts` - добавлен экспорт CitySelector
- `src/shared/index.tsx` - добавлен экспорт hooks
#### Новые файлы:
- `src/shared/store/SelectedCityStore/index.ts`
- `src/widgets/CitySelector/index.tsx`
- `src/shared/hooks/useSelectedCity.ts`
- `src/shared/hooks/index.ts`
### Использование:
1. Пользователь выбирает город в селекторе в шапке приложения
2. Выбранный город сохраняется в localStorage
3. При создании новой станции или перевозчика выбранный город автоматически подставляется в форму
4. Пользователь может изменить город в форме если нужно
Функциональность полностью интегрирована и готова к использованию.

View File

@@ -12,7 +12,13 @@ import { ArrowLeft, Save } from "lucide-react";
import { Loader2 } from "lucide-react"; import { Loader2 } from "lucide-react";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { toast } from "react-toastify"; import { toast } from "react-toastify";
import { carrierStore, cityStore, mediaStore, languageStore } from "@shared"; import {
carrierStore,
cityStore,
mediaStore,
languageStore,
useSelectedCity,
} from "@shared";
import { useState, useEffect } from "react"; import { useState, useEffect } from "react";
import { ImageUploadCard, LanguageSwitcher } from "@widgets"; import { ImageUploadCard, LanguageSwitcher } from "@widgets";
import { import {
@@ -25,6 +31,7 @@ export const CarrierCreatePage = observer(() => {
const navigate = useNavigate(); const navigate = useNavigate();
const { createCarrierData, setCreateCarrierData } = carrierStore; const { createCarrierData, setCreateCarrierData } = carrierStore;
const { language } = languageStore; const { language } = languageStore;
const { selectedCityId } = useSelectedCity();
const [selectedMediaId, setSelectedMediaId] = useState<string | null>(null); const [selectedMediaId, setSelectedMediaId] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [isSelectMediaOpen, setIsSelectMediaOpen] = useState(false); const [isSelectMediaOpen, setIsSelectMediaOpen] = useState(false);
@@ -41,6 +48,20 @@ export const CarrierCreatePage = observer(() => {
languageStore.setLanguage("ru"); languageStore.setLanguage("ru");
}, []); }, []);
// Автоматически устанавливаем выбранный город при загрузке страницы
useEffect(() => {
if (selectedCityId && !createCarrierData.city_id) {
setCreateCarrierData(
createCarrierData[language].full_name,
createCarrierData[language].short_name,
selectedCityId,
createCarrierData[language].slogan,
selectedMediaId || "",
language
);
}
}, [selectedCityId, createCarrierData.city_id]);
const handleCreate = async () => { const handleCreate = async () => {
try { try {
setIsLoading(true); setIsLoading(true);

View File

@@ -6,14 +6,18 @@ import {
MenuItem, MenuItem,
FormControl, FormControl,
InputLabel, InputLabel,
} } from "@mui/material";
from "@mui/material";
import { observer } from "mobx-react-lite"; import { observer } from "mobx-react-lite";
import { ArrowLeft, Save } from "lucide-react"; import { ArrowLeft, Save } from "lucide-react";
import { Loader2 } from "lucide-react"; import { Loader2 } from "lucide-react";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { toast } from "react-toastify"; import { toast } from "react-toastify";
import { stationsStore, languageStore, cityStore } from "@shared"; import {
stationsStore,
languageStore,
cityStore,
useSelectedCity,
} from "@shared";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { LanguageSwitcher } from "@widgets"; import { LanguageSwitcher } from "@widgets";
import { SaveWithoutCityAgree } from "@widgets"; import { SaveWithoutCityAgree } from "@widgets";
@@ -29,6 +33,7 @@ export const StationCreatePage = observer(() => {
setLanguageCreateStationData, setLanguageCreateStationData,
} = stationsStore; } = stationsStore;
const { cities, getCities } = cityStore; const { cities, getCities } = cityStore;
const { selectedCityId, selectedCity } = useSelectedCity();
const [coordinates, setCoordinates] = useState<string>(""); const [coordinates, setCoordinates] = useState<string>("");
// НОВОЕ СОСТОЯНИЕ ДЛЯ ПРЕДУПРЕЖДАЮЩЕГО ОКНА // НОВОЕ СОСТОЯНИЕ ДЛЯ ПРЕДУПРЕЖДАЮЩЕГО ОКНА
const [isSaveWarningOpen, setIsSaveWarningOpen] = useState(false); const [isSaveWarningOpen, setIsSaveWarningOpen] = useState(false);
@@ -93,6 +98,16 @@ export const StationCreatePage = observer(() => {
fetchCities(); fetchCities();
}, []); }, []);
// Автоматически устанавливаем выбранный город при загрузке страницы
useEffect(() => {
if (selectedCityId && selectedCity && !createStationData.common.city_id) {
setCreateCommonData({
city_id: selectedCityId,
city: selectedCity.name,
});
}
}, [selectedCityId, selectedCity, createStationData.common.city_id]);
return ( return (
<Paper className="w-full h-full p-3 flex flex-col gap-10"> <Paper className="w-full h-full p-3 flex flex-col gap-10">
<LanguageSwitcher /> <LanguageSwitcher />
@@ -242,4 +257,4 @@ export const StationCreatePage = observer(() => {
)} )}
</Paper> </Paper>
); );
}); });

View File

@@ -0,0 +1 @@
export * from "./useSelectedCity";

View File

@@ -0,0 +1,12 @@
import { selectedCityStore } from "@shared";
export const useSelectedCity = () => {
const { selectedCity, selectedCityId, selectedCityName } = selectedCityStore;
return {
selectedCity,
selectedCityId,
selectedCityName,
hasSelectedCity: !!selectedCity,
};
};

View File

@@ -5,3 +5,4 @@ export * from "./store";
export * from "./const"; export * from "./const";
export * from "./api"; export * from "./api";
export * from "./modals"; export * from "./modals";
export * from "./hooks";

View File

@@ -0,0 +1,48 @@
import { makeAutoObservable, runInAction } from "mobx";
import { City } from "../CityStore";
class SelectedCityStore {
selectedCity: City | null = null;
constructor() {
makeAutoObservable(this);
this.initialize();
}
private initialize() {
const storedCity = localStorage.getItem("selectedCity");
if (storedCity) {
try {
this.selectedCity = JSON.parse(storedCity);
} catch (error) {
console.error("Error parsing stored city:", error);
localStorage.removeItem("selectedCity");
}
}
}
setSelectedCity = (city: City | null) => {
runInAction(() => {
this.selectedCity = city;
if (city) {
localStorage.setItem("selectedCity", JSON.stringify(city));
} else {
localStorage.removeItem("selectedCity");
}
});
};
clearSelectedCity = () => {
this.setSelectedCity(null);
};
get selectedCityId() {
return this.selectedCity?.id || null;
}
get selectedCityName() {
return this.selectedCity?.name || null;
}
}
export const selectedCityStore = new SelectedCityStore();

View File

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

View File

@@ -0,0 +1,77 @@
import React, { useEffect } from "react";
import {
FormControl,
Select,
MenuItem,
SelectChangeEvent,
Typography,
Box,
} from "@mui/material";
import { observer } from "mobx-react-lite";
import { cityStore, selectedCityStore } from "@shared";
import { MapPin } from "lucide-react";
export const CitySelector: React.FC = observer(() => {
const { getCities, cities } = cityStore;
const { selectedCity, setSelectedCity } = selectedCityStore;
useEffect(() => {
getCities("ru");
}, []);
const handleCityChange = (event: SelectChangeEvent<string>) => {
const cityId = event.target.value;
if (cityId === "") {
setSelectedCity(null);
return;
}
const city = cities["ru"].data.find((c) => c.id === Number(cityId));
if (city) {
setSelectedCity(city);
}
};
const currentCities = cities["ru"].data;
return (
<Box className="flex items-center gap-2">
<MapPin size={16} className="text-white" />
<FormControl size="medium" sx={{ minWidth: 120 }}>
<Select
value={selectedCity?.id?.toString() || ""}
onChange={handleCityChange}
displayEmpty
sx={{
height: "40px",
color: "white",
"& .MuiOutlinedInput-notchedOutline": {
borderColor: "rgba(255, 255, 255, 0.3)",
},
"&:hover .MuiOutlinedInput-notchedOutline": {
borderColor: "rgba(255, 255, 255, 0.5)",
},
"&.Mui-focused .MuiOutlinedInput-notchedOutline": {
borderColor: "white",
},
"& .MuiSvgIcon-root": {
color: "white",
},
}}
MenuProps={{
PaperProps: {},
}}
>
<MenuItem value="">
<Typography variant="body2">Выберите город</Typography>
</MenuItem>
{currentCities.map((city) => (
<MenuItem key={city.id} value={city.id?.toString()}>
<Typography variant="body2">{city.name}</Typography>
</MenuItem>
))}
</Select>
</FormControl>
</Box>
);
});

View File

@@ -12,6 +12,7 @@ import { authStore, userStore, menuStore } from "@shared";
import { observer } from "mobx-react-lite"; import { observer } from "mobx-react-lite";
import { useEffect } from "react"; import { useEffect } from "react";
import { Typography } from "@mui/material"; import { Typography } from "@mui/material";
import { CitySelector } from "@widgets";
interface LayoutProps { interface LayoutProps {
children: React.ReactNode; children: React.ReactNode;
@@ -26,8 +27,6 @@ export const Layout: React.FC<LayoutProps> = observer(({ children }) => {
setIsMenuOpen(open); setIsMenuOpen(open);
}, [open]); }, [open]);
const { getUsers, users } = userStore; const { getUsers, users } = userStore;
useEffect(() => { useEffect(() => {
@@ -63,7 +62,7 @@ export const Layout: React.FC<LayoutProps> = observer(({ children }) => {
> >
<Menu /> <Menu />
</IconButton> </IconButton>
<div></div> <CitySelector />
<div className="flex gap-2 items-center"> <div className="flex gap-2 items-center">
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
{(() => { {(() => {

View File

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

File diff suppressed because one or more lines are too long