feat: Select city in top of the page for next usage in create/edit pages
This commit is contained in:
62
CITY_SELECTOR_FEATURE.md
Normal file
62
CITY_SELECTOR_FEATURE.md
Normal 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. Пользователь может изменить город в форме если нужно
|
||||||
|
|
||||||
|
Функциональность полностью интегрирована и готова к использованию.
|
||||||
@@ -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);
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
1
src/shared/hooks/index.ts
Normal file
1
src/shared/hooks/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export * from "./useSelectedCity";
|
||||||
12
src/shared/hooks/useSelectedCity.ts
Normal file
12
src/shared/hooks/useSelectedCity.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import { selectedCityStore } from "@shared";
|
||||||
|
|
||||||
|
export const useSelectedCity = () => {
|
||||||
|
const { selectedCity, selectedCityId, selectedCityName } = selectedCityStore;
|
||||||
|
|
||||||
|
return {
|
||||||
|
selectedCity,
|
||||||
|
selectedCityId,
|
||||||
|
selectedCityName,
|
||||||
|
hasSelectedCity: !!selectedCity,
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -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";
|
||||||
|
|||||||
48
src/shared/store/SelectedCityStore/index.ts
Normal file
48
src/shared/store/SelectedCityStore/index.ts
Normal 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();
|
||||||
@@ -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";
|
||||||
|
|||||||
77
src/widgets/CitySelector/index.tsx
Normal file
77
src/widgets/CitySelector/index.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -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">
|
||||||
{(() => {
|
{(() => {
|
||||||
|
|||||||
@@ -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
Reference in New Issue
Block a user