Compare commits

4 Commits

28 changed files with 357 additions and 173 deletions

View File

@@ -37,14 +37,14 @@ const Fullscreen3DModal = ({ isOpen, onClose, fileUrl }) => {
</div> </div>
</div> </div>
<div className="fullscreen-3d-actions"> <div className="fullscreen-3d-actions">
<button title="Увеличить масштаб" disabled={scale >= 1}> <button disabled={scale >= 1}>
<img src={scale_plus} alt="Увеличить" /> <img src={scale_plus} alt="Увеличить" />
</button> </button>
<button title="Уменьшить масштаб" disabled={scale <= 0.1}> <button disabled={scale <= 0.1}>
<img src={scale_minus} alt="Уменьшить" /> <img src={scale_minus} alt="Уменьшить" />
</button> </button>
<button onPointerUp={onClose} title="Закрыть"> <button onPointerUp={onClose}>
<img src={closeIcon} alt="Закрыть" /> <img src={closeIcon} alt="Закрыть" />
</button> </button>
</div> </div>

View File

@@ -36,7 +36,7 @@ const SightComponent = function SightComponent({
aria-label={`Выбрать достопримечательность ${title}`} aria-label={`Выбрать достопримечательность ${title}`}
> >
<div className="sight-image">{renderThumbnail()}</div> <div className="sight-image">{renderThumbnail()}</div>
<div className="sight-title" title={title}> <div className="sight-title">
{title} {title}
</div> </div>
</div> </div>

View File

@@ -361,7 +361,6 @@ const SightFrame = observer(({ media, sight_id, sight_name }) => {
<button <button
type="button" type="button"
className="three-d-control-btn" className="three-d-control-btn"
title="Уменьшить"
onPointerUp={() => threeViewControlRef.current?.zoomOut?.()} onPointerUp={() => threeViewControlRef.current?.zoomOut?.()}
> >
<MinusIcon /> <MinusIcon />
@@ -369,7 +368,6 @@ const SightFrame = observer(({ media, sight_id, sight_name }) => {
<button <button
type="button" type="button"
className="three-d-control-btn" className="three-d-control-btn"
title="Увеличить"
onPointerUp={() => threeViewControlRef.current?.zoomIn?.()} onPointerUp={() => threeViewControlRef.current?.zoomIn?.()}
> >
<PlusIcon /> <PlusIcon />
@@ -377,7 +375,6 @@ const SightFrame = observer(({ media, sight_id, sight_name }) => {
<button <button
type="button" type="button"
className="three-d-control-btn" className="three-d-control-btn"
title={isFullscreen3D ? "Свернуть" : "Развернуть"}
onPointerUp={() => { onPointerUp={() => {
if (isFullscreen3D) { if (isFullscreen3D) {
setIsFullscreen3D(false); setIsFullscreen3D(false);
@@ -392,11 +389,6 @@ const SightFrame = observer(({ media, sight_id, sight_name }) => {
</div> </div>
<button <button
className={"fullscreen-3d-button"} className={"fullscreen-3d-button"}
title={
isFullscreen3D
? "Закрыть полноэкранный режим"
: "Открыть в полноэкранном режиме"
}
aria-hidden="true" aria-hidden="true"
> >
<svg <svg

View File

@@ -2932,7 +2932,6 @@ export const WebGLMap = observer(() => {
whiteSpace: "nowrap", whiteSpace: "nowrap",
flex: 1, flex: 1,
}} }}
title={sightName}
> >
{sightName} {sightName}
</span> </span>

View File

@@ -123,6 +123,8 @@ const LeftWidget = observer(
? routeSightsEn.find((sight) => sight.id === selectedSightId) ? routeSightsEn.find((sight) => sight.id === selectedSightId)
: routeSightsZh.find((sight) => sight.id === selectedSightId); : routeSightsZh.find((sight) => sight.id === selectedSightId);
if (!sight) return;
const leftArticle = sight.left_article; const leftArticle = sight.left_article;
const leftArticleData = const leftArticleData =
@@ -132,6 +134,8 @@ const LeftWidget = observer(
? sightArticlesEn.get(leftArticle + "_" + selectedLanguage) ? sightArticlesEn.get(leftArticle + "_" + selectedLanguage)
: sightArticlesZh.get(leftArticle + "_" + selectedLanguage); : sightArticlesZh.get(leftArticle + "_" + selectedLanguage);
if (!leftArticleData?.media?.length) return;
const media = await ContentAPI.getMediaPreview( const media = await ContentAPI.getMediaPreview(
leftArticleData.media[0].id, leftArticleData.media[0].id,
selectedLanguage, selectedLanguage,

View File

@@ -505,7 +505,7 @@ const SideMenu = observer(({ onMenuToggle }) => {
: selectedLanguage == "zh" : selectedLanguage == "zh"
? "景点" ? "景点"
: "Attractions"} : "Attractions"}
</div> </div>
<div <div
onPointerUp={() => { onPointerUp={() => {
if (!isStationOpen) { if (!isStationOpen) {
@@ -553,7 +553,7 @@ const SideMenu = observer(({ onMenuToggle }) => {
: selectedLanguage == "zh" : selectedLanguage == "zh"
? "车站" ? "车站"
: "Stations"} : "Stations"}
</div> </div>
</div> </div>
<div className="side-menu-tag"> <div className="side-menu-tag">
{/* {selectedLanguage == "ru" {/* {selectedLanguage == "ru"

View File

@@ -64,7 +64,6 @@ const StationItem = ({
className="side-menu-sight" className="side-menu-sight"
onPointerDown={(e) => handlePointerDown(e, station.id)} onPointerDown={(e) => handlePointerDown(e, station.id)}
onPointerUp={(e) => handlePointerUp(e, station.id, handleStationClick)} onPointerUp={(e) => handlePointerUp(e, station.id, handleStationClick)}
title={station.name}
> >
<span ref={textRef} className={shouldAnimate ? "marquee-text" : ""}> <span ref={textRef} className={shouldAnimate ? "marquee-text" : ""}>
{station.name} {station.name}

View File

@@ -105,7 +105,6 @@ const SightItem = ({
className={`side-menu-sight pointer ${ className={`side-menu-sight pointer ${
localSelectedSightId === sight.id ? "selected" : "" localSelectedSightId === sight.id ? "selected" : ""
}`} }`}
title={sightName}
> >
<span ref={textRef} className={shouldAnimate ? "marquee-text" : ""}> <span ref={textRef} className={shouldAnimate ? "marquee-text" : ""}>
{sightName} {sightName}

View File

@@ -70,7 +70,6 @@ const SightItem = ({
className={`side-menu-sight pointer ${ className={`side-menu-sight pointer ${
localSelectedSightId === sight.id ? "selected" : "" localSelectedSightId === sight.id ? "selected" : ""
}`} }`}
title={sightName}
> >
<span ref={textRef} className={shouldAnimate ? "marquee-text" : ""}> <span ref={textRef} className={shouldAnimate ? "marquee-text" : ""}>
{sightName} {sightName}

View File

@@ -124,7 +124,6 @@ const StationItem = ({
); );
} }
}} }}
title={station.name}
> >
<span ref={textRef} className={shouldAnimate ? "marquee-text" : ""}> <span ref={textRef} className={shouldAnimate ? "marquee-text" : ""}>
{station.name} {station.name}

View File

@@ -1,31 +1,47 @@
import { useRef, useEffect } from 'react' import { useRef, useEffect } from "react";
import '../../styles/AppealWidget.css' import "../../styles/AppealWidget.css";
import { TouchableLayout } from '../TouchableLayout' import { TouchableLayout } from "../TouchableLayout";
function AppealWidget({widgetImgPath, widgetLabel, widgetText, style, isOpen}) { function AppealWidget({
const stopProp = (e) => { e.stopPropagation(); e.preventDefault(); }; widgetImgPath,
const layoutRef = useRef(null); widgetLabel,
widgetText,
style,
isOpen,
}) {
const stopProp = (e) => {
e.stopPropagation();
e.preventDefault();
};
const layoutRef = useRef(null);
useEffect(() => { useEffect(() => {
if (isOpen && layoutRef.current) { if (isOpen && layoutRef.current) {
const scrollable = layoutRef.current.querySelector('.scrollable'); const scrollable = layoutRef.current.querySelector(".scrollable");
if (scrollable) scrollable.scrollTop = 0; if (scrollable) scrollable.scrollTop = 0;
} }
}, [isOpen]); }, [isOpen]);
return ( return (
<div style={style} className='dynamic-widget' <div
onPointerDown={stopProp} style={style}
onPointerMove={stopProp} className="dynamic-widget"
onPointerUp={stopProp} onPointerDown={stopProp}
> onPointerMove={stopProp}
{widgetImgPath && <img className='dynamic-widget-image' src={widgetImgPath} />} onPointerUp={stopProp}
<div className='dynamic-widget-label'>{widgetLabel}</div> >
<TouchableLayout ref={layoutRef} className="dynamic-widget-text-scroll" maxHeight="calc(100vh - 150px - 100px - 300px)"> {widgetImgPath && (
<div className='dynamic-widget-text'>{widgetText}</div> <img className="dynamic-widget-image" src={widgetImgPath} />
</TouchableLayout> )}
</div> <div className="dynamic-widget-label">{widgetLabel}</div>
); <TouchableLayout
ref={layoutRef}
className="dynamic-widget-text-scroll"
>
<div className="dynamic-widget-text">{widgetText}</div>
</TouchableLayout>
</div>
);
} }
export default AppealWidget export default AppealWidget;

View File

@@ -92,16 +92,14 @@ const RouteWidget = observer(() => {
className={`route-widget-label ${ className={`route-widget-label ${
shouldAnimate(startStation?.name, 18) ? "marquee" : "" shouldAnimate(startStation?.name, 18) ? "marquee" : ""
} ${getLabelSizeClass(startStation?.name)}`} } ${getLabelSizeClass(startStation?.name)}`}
title={startStation?.name} >
>
{startStation?.name} {startStation?.name}
</div> </div>
<div <div
className={`route-widget-label ${ className={`route-widget-label ${
shouldAnimate(endStation?.name, 18) ? "marquee" : "" shouldAnimate(endStation?.name, 18) ? "marquee" : ""
} ${getLabelSizeClass(endStation?.name)}`} } ${getLabelSizeClass(endStation?.name)}`}
title={endStation?.name} >
>
{endStation?.name} {endStation?.name}
</div> </div>
{(selectedLanguage === "en" || selectedLanguage === "ru") && ( {(selectedLanguage === "en" || selectedLanguage === "ru") && (
@@ -109,8 +107,7 @@ const RouteWidget = observer(() => {
className={`route-widget-subtitle ${ className={`route-widget-subtitle ${
shouldAnimate(routeEnSubtitle, 50) ? "marquee" : "" shouldAnimate(routeEnSubtitle, 50) ? "marquee" : ""
}`} }`}
title={routeEnSubtitle} >
>
{routeEnSubtitle} {routeEnSubtitle}
</div> </div>
)} )}
@@ -119,8 +116,7 @@ const RouteWidget = observer(() => {
className={`route-widget-subtitle ${ className={`route-widget-subtitle ${
shouldAnimate(routeZhSubtitle, 50) ? "marquee" : "" shouldAnimate(routeZhSubtitle, 50) ? "marquee" : ""
}`} }`}
title={routeZhSubtitle} >
>
{routeZhSubtitle} {routeZhSubtitle}
</div> </div>
)} )}

View File

@@ -5,7 +5,7 @@
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
width: 420px; width: 420px;
max-height: calc(100vh - 150px - 100px); max-height: calc(100vh - 150px - 98px);
border-radius: 10px; border-radius: 10px;
background: background:
linear-gradient( linear-gradient(
@@ -13,49 +13,61 @@
rgba(255, 255, 255, 0) 8.71%, rgba(255, 255, 255, 0) 8.71%,
rgba(255, 255, 255, 0.16) 69.69% rgba(255, 255, 255, 0.16) 69.69%
), ),
var(--carrier-left, #006F3A); var(--carrier-left, #006f3a);
box-sizing: border-box; box-sizing: border-box;
overflow: hidden; overflow: hidden;
touch-action: none; touch-action: none;
} }
.dynamic-widget-image { .dynamic-widget-image {
padding-top: 4px; padding-top: 2px;
margin-left: 4px; margin-left: 2px;
margin-right: 4px; margin-right: 2px;
width: 412px; width: 416px;
border-radius: 6px 6px 0 0; border-radius: 10px 10px 0 0;
object-fit: cover; object-fit: cover;
} }
.dynamic-widget-label { .dynamic-widget-label {
width: 380px; width: 100%;
margin-top: 20px; padding: 10px 20px;
margin-bottom: 5px; box-sizing: border-box;
font-size: 20px; font-size: 20px;
font-weight: 500; font-weight: 500;
color: #fff;
border-bottom: 1px solid var(--Glass-stroke, rgba(255, 255, 255, 0.8));
background:
linear-gradient(
180deg,
rgba(255, 255, 255, 0.2) 0%,
rgba(255, 255, 255, 0) 100%
),
rgba(var(--carrier-right-menu-rgb, 179, 165, 152), 0.4);
} }
.dynamic-widget-text-scroll { .dynamic-widget-text-scroll.scrollable-container {
margin-top: 0; flex: 1;
margin-bottom: 25px; min-height: 0;
align-self: stretch;
margin: 15px 20px;
overflow: hidden;
}
.dynamic-widget-text-scroll .scrollable-viewport {
flex: 1;
min-height: 0;
overflow: hidden;
}
.dynamic-widget-text-scroll .scrollable {
flex: 1; flex: 1;
min-height: 0; min-height: 0;
} }
.dynamic-widget-text-scroll.scrollable-container {
margin: 0 20px;
}
.dynamic-widget-text-scroll .scrollable-viewport {
padding-bottom: 20px;
box-sizing: border-box;
}
.dynamic-widget-text { .dynamic-widget-text {
font-size: 14px; font-size: 14px;
font-weight: 400; font-weight: 400;
line-height: 190%; line-height: 190%;
padding-bottom: 10px;
padding-right: 5px; padding-right: 5px;
} }

View File

@@ -55,11 +55,12 @@ export const ArticleListPage = observer(() => {
sortable: false, sortable: false,
renderCell: (params: GridRenderCellParams) => ( renderCell: (params: GridRenderCellParams) => (
<div className="flex h-full gap-7 justify-center items-center"> <div className="flex h-full gap-7 justify-center items-center">
<button onClick={() => navigate(`/article/${params.row.id}`)}> <button title="Просмотр" onClick={() => navigate(`/article/${params.row.id}`)}>
<Eye size={20} className="text-green-500" /> <Eye size={20} className="text-green-500" />
</button> </button>
{canWriteArticles && ( {canWriteArticles && (
<button <button
title="Удалить"
onClick={() => { onClick={() => {
setIsDeleteModalOpen(true); setIsDeleteModalOpen(true);
setRowId(params.row.id); setRowId(params.row.id);
@@ -107,7 +108,9 @@ export const ArticleListPage = observer(() => {
</div> </div>
)} )}
<SearchInput value={searchQuery} onChange={setSearchQuery} /> {rows.length > 0 && (
<SearchInput value={searchQuery} onChange={setSearchQuery} />
)}
<div className="w-full"> <div className="w-full">
<DataGrid <DataGrid

View File

@@ -98,10 +98,11 @@ export const CarrierListPage = observer(() => {
sortable: false, sortable: false,
renderCell: (params: GridRenderCellParams) => ( renderCell: (params: GridRenderCellParams) => (
<div className="flex h-full gap-7 justify-center items-center"> <div className="flex h-full gap-7 justify-center items-center">
<button onClick={() => navigate(`/carrier/${params.row.id}/edit`)}> <button title="Редактировать" onClick={() => navigate(`/carrier/${params.row.id}/edit`)}>
<Pencil size={20} className="text-blue-500" /> <Pencil size={20} className="text-blue-500" />
</button> </button>
<button <button
title="Удалить"
onClick={() => { onClick={() => {
setIsDeleteModalOpen(true); setIsDeleteModalOpen(true);
setRowId(params.row.id); setRowId(params.row.id);
@@ -161,7 +162,9 @@ export const CarrierListPage = observer(() => {
</div> </div>
)} )}
<SearchInput value={searchQuery} onChange={setSearchQuery} /> {rows.length > 0 && (
<SearchInput value={searchQuery} onChange={setSearchQuery} />
)}
<DataGrid <DataGrid
rows={rows} rows={rows}

View File

@@ -119,10 +119,11 @@ export const CityListPage = observer(() => {
sortable: false, sortable: false,
renderCell: (params: GridRenderCellParams) => ( renderCell: (params: GridRenderCellParams) => (
<div className="flex h-full gap-7 justify-center items-center"> <div className="flex h-full gap-7 justify-center items-center">
<button onClick={() => navigate(`/city/${params.row.id}/edit`)}> <button title="Редактировать" onClick={() => navigate(`/city/${params.row.id}/edit`)}>
<Pencil size={20} className="text-blue-500" /> <Pencil size={20} className="text-blue-500" />
</button> </button>
<button <button
title="Удалить"
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
setIsDeleteModalOpen(true); setIsDeleteModalOpen(true);
@@ -164,7 +165,9 @@ export const CityListPage = observer(() => {
</div> </div>
)} )}
<SearchInput value={searchQuery} onChange={setSearchQuery} /> {rows.length > 0 && (
<SearchInput value={searchQuery} onChange={setSearchQuery} />
)}
<DataGrid <DataGrid
rows={filteredRows} rows={filteredRows}

View File

@@ -117,7 +117,9 @@ export const CountryListPage = observer(() => {
</div> </div>
)} )}
<SearchInput value={searchQuery} onChange={setSearchQuery} /> {rows.length > 0 && (
<SearchInput value={searchQuery} onChange={setSearchQuery} />
)}
<DataGrid <DataGrid
rows={rows} rows={rows}

View File

@@ -78,11 +78,12 @@ export const MediaListPage = observer(() => {
sortable: false, sortable: false,
renderCell: (params: GridRenderCellParams) => ( renderCell: (params: GridRenderCellParams) => (
<div className="flex h-full gap-7 justify-center items-center"> <div className="flex h-full gap-7 justify-center items-center">
<button onClick={() => navigate(`/media/${params.row.id}`)}> <button title="Просмотр" onClick={() => navigate(`/media/${params.row.id}`)}>
<Eye size={20} className="text-green-500" /> <Eye size={20} className="text-green-500" />
</button> </button>
{canWriteMedia && ( {canWriteMedia && (
<button <button
title="Удалить"
onClick={() => { onClick={() => {
setIsDeleteModalOpen(true); setIsDeleteModalOpen(true);
setRowId(params.row.id); setRowId(params.row.id);
@@ -112,7 +113,9 @@ export const MediaListPage = observer(() => {
return ( return (
<> <>
<div className="w-full"> <div className="w-full">
<SearchInput value={searchQuery} onChange={setSearchQuery} /> {rows.length > 0 && (
<SearchInput value={searchQuery} onChange={setSearchQuery} />
)}
{canWriteMedia && ids.length > 0 && ( {canWriteMedia && ids.length > 0 && (
<div className="flex justify-end mb-5 duration-300"> <div className="flex justify-end mb-5 duration-300">

View File

@@ -178,22 +178,23 @@ export const RouteListPage = observer(() => {
return ( return (
<div className="flex h-full gap-7 justify-center items-center"> <div className="flex h-full gap-7 justify-center items-center">
{canWriteRoutes && ( {canWriteRoutes && (
<button onClick={() => navigate(`/route/${params.row.id}/edit`)}> <button title="Редактировать" onClick={() => navigate(`/route/${params.row.id}/edit`)}>
<Pencil size={20} className="text-blue-500" /> <Pencil size={20} className="text-blue-500" />
</button> </button>
)} )}
{canShowRoutePreview && ( {canShowRoutePreview && (
<button onClick={() => navigate(`/route-preview/${params.row.id}`)}> <button title="Предпросмотр на карте" onClick={() => navigate(`/route-preview/${params.row.id}`)}>
<Map size={20} className="text-purple-500" /> <Map size={20} className="text-purple-500" />
</button> </button>
)} )}
{canShowRoutePreview && ( {canShowRoutePreview && (
<button onClick={() => window.open(`/demo/${params.row.id}`, "_blank")}> <button title="Демо" onClick={() => window.open(`/demo/${params.row.id}`, "_blank")}>
<Monitor size={20} className="text-green-500" /> <Monitor size={20} className="text-green-500" />
</button> </button>
)} )}
{canWriteRoutes && ( {canWriteRoutes && (
<button <button
title="Удалить"
onClick={() => { onClick={() => {
setIsDeleteModalOpen(true); setIsDeleteModalOpen(true);
setRowId(params.row.id); setRowId(params.row.id);
@@ -270,7 +271,9 @@ export const RouteListPage = observer(() => {
</div> </div>
)} )}
<SearchInput value={searchQuery} onChange={setSearchQuery} /> {rows.length > 0 && (
<SearchInput value={searchQuery} onChange={setSearchQuery} />
)}
<DataGrid <DataGrid
rows={rows} rows={rows}

View File

@@ -105,10 +105,11 @@ export const SightListPage = observer(() => {
sortable: false, sortable: false,
renderCell: (params: GridRenderCellParams) => ( renderCell: (params: GridRenderCellParams) => (
<div className="flex h-full gap-7 justify-center items-center"> <div className="flex h-full gap-7 justify-center items-center">
<button onClick={() => navigate(`/sight/${params.row.id}/edit`)}> <button title="Редактировать" onClick={() => navigate(`/sight/${params.row.id}/edit`)}>
<Pencil size={20} className="text-blue-500" /> <Pencil size={20} className="text-blue-500" />
</button> </button>
<button <button
title="Удалить"
onClick={() => { onClick={() => {
setIsDeleteModalOpen(true); setIsDeleteModalOpen(true);
setRowId(params.row.id); setRowId(params.row.id);
@@ -181,7 +182,9 @@ export const SightListPage = observer(() => {
</div> </div>
)} )}
<SearchInput value={searchQuery} onChange={setSearchQuery} /> {rows.length > 0 && (
<SearchInput value={searchQuery} onChange={setSearchQuery} />
)}
<DataGrid <DataGrid
rows={rows} rows={rows}

View File

@@ -1,5 +1,12 @@
import { Button, TextField } from "@mui/material"; import {
import { snapshotStore } from "@shared"; Button,
TextField,
Dialog,
DialogTitle,
DialogContent,
DialogActions,
} from "@mui/material";
import { snapshotStore, authStore, routeStore } from "@shared";
import { observer } from "mobx-react-lite"; import { observer } from "mobx-react-lite";
import { ArrowLeft, Loader2, Save } from "lucide-react"; import { ArrowLeft, Loader2, Save } from "lucide-react";
import { useState } from "react"; import { useState } from "react";
@@ -12,6 +19,76 @@ export const SnapshotCreatePage = observer(() => {
const navigate = useNavigate(); const navigate = useNavigate();
const [name, setName] = useState(""); const [name, setName] = useState("");
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [duplicateWarningOpen, setDuplicateWarningOpen] = useState(false);
const [duplicateRouteNumbers, setDuplicateRouteNumbers] = useState<string[]>([]);
const canReadRoutes = authStore.canRead("routes");
const startExport = async () => {
try {
setIsLoading(true);
const id = await createSnapshot(name);
await getSnapshotStatus(id);
while (snapshotStore.snapshotStatus?.Status != "done") {
await new Promise((resolve) => setTimeout(resolve, 1000));
await getSnapshotStatus(id);
}
if (snapshotStore.snapshotStatus?.Status === "done") {
toast.success("Экспорт медиа успешно создан");
runInAction(() => {
snapshotStore.snapshotStatus = null;
});
await getStorageInfo();
navigate(-1);
}
} catch (error) {
console.error(error);
toast.error("Ошибка при создании экспорта медиа");
} finally {
setIsLoading(false);
}
};
const handleSave = async () => {
if (!canReadRoutes) {
await startExport();
return;
}
try {
runInAction(() => {
routeStore.routes.loaded = false;
});
await routeStore.getRoutes();
const routes = routeStore.routes.data;
const numberCount = new Map<string, number>();
for (const route of routes) {
const num = (route.route_number ?? "").trim();
if (num) {
numberCount.set(num, (numberCount.get(num) ?? 0) + 1);
}
}
const duplicates = Array.from(numberCount.entries())
.filter(([, count]) => count > 1)
.map(([num]) => num);
if (duplicates.length > 0) {
setDuplicateRouteNumbers(duplicates);
setDuplicateWarningOpen(true);
} else {
await startExport();
}
} catch {
await startExport();
}
};
return ( return (
<div className="w-full h-[400px] flex justify-center items-center"> <div className="w-full h-[400px] flex justify-center items-center">
@@ -40,35 +117,7 @@ export const SnapshotCreatePage = observer(() => {
color="primary" color="primary"
className="w-min flex gap-2 items-center" className="w-min flex gap-2 items-center"
startIcon={<Save size={20} />} startIcon={<Save size={20} />}
onClick={async () => { onClick={handleSave}
try {
setIsLoading(true);
const id = await createSnapshot(name);
await getSnapshotStatus(id);
while (snapshotStore.snapshotStatus?.Status != "done") {
await new Promise((resolve) => setTimeout(resolve, 1000));
await getSnapshotStatus(id);
}
if (snapshotStore.snapshotStatus?.Status === "done") {
toast.success("Экспорт медиа успешно создан");
runInAction(() => {
snapshotStore.snapshotStatus = null;
});
await getStorageInfo();
navigate(-1);
}
} catch (error) {
console.error(error);
toast.error("Ошибка при создании экспорта медиа");
} finally {
setIsLoading(false);
}
}}
disabled={isLoading || !name.trim()} disabled={isLoading || !name.trim()}
> >
{isLoading ? ( {isLoading ? (
@@ -87,6 +136,47 @@ export const SnapshotCreatePage = observer(() => {
</Button> </Button>
</div> </div>
</div> </div>
<Dialog
open={duplicateWarningOpen}
onClose={() => !isLoading && setDuplicateWarningOpen(false)}
maxWidth="sm"
fullWidth
>
<DialogTitle>Найдены повторяющиеся маршруты</DialogTitle>
<DialogContent>
<p className="mb-3">
Обнаружены маршруты с одинаковыми номерами. Это может привести к
некорректным данным в экспорте.
</p>
<ul className="list-disc pl-5">
{duplicateRouteNumbers.map((num) => (
<li key={num}>
Найдены повторяющиеся маршруты под номером {num}
</li>
))}
</ul>
</DialogContent>
<DialogActions>
<Button
onClick={() => setDuplicateWarningOpen(false)}
disabled={isLoading}
>
Отмена
</Button>
<Button
variant="contained"
sx={{ backgroundColor: "#795548", "&:hover": { backgroundColor: "#5D4037" } }}
disabled={isLoading}
onClick={async () => {
setDuplicateWarningOpen(false);
await startExport();
}}
>
Продолжить экспорт
</Button>
</DialogActions>
</Dialog>
</div> </div>
); );
}); });

View File

@@ -137,6 +137,7 @@ export const SnapshotListPage = observer(() => {
renderCell: (params: GridRenderCellParams) => ( renderCell: (params: GridRenderCellParams) => (
<div className="flex h-full gap-7 justify-center items-center"> <div className="flex h-full gap-7 justify-center items-center">
<button <button
title="Восстановить"
onClick={() => { onClick={() => {
setIsRestoreModalOpen(true); setIsRestoreModalOpen(true);
setRowId(params.row.id); setRowId(params.row.id);
@@ -145,6 +146,7 @@ export const SnapshotListPage = observer(() => {
<DatabaseBackup size={20} className="text-blue-500" /> <DatabaseBackup size={20} className="text-blue-500" />
</button> </button>
<button <button
title="Удалить"
onClick={() => { onClick={() => {
setIsDeleteModalOpen(true); setIsDeleteModalOpen(true);
setRowId(params.row.id); setRowId(params.row.id);
@@ -297,7 +299,9 @@ export const SnapshotListPage = observer(() => {
</Alert> </Alert>
)} )}
<SearchInput value={searchQuery} onChange={setSearchQuery} /> {rows.length > 0 && (
<SearchInput value={searchQuery} onChange={setSearchQuery} />
)}
<DataGrid <DataGrid
rows={rows} rows={rows}

View File

@@ -141,7 +141,7 @@ export const StationListPage = observer(() => {
return ( return (
<div className="flex h-full gap-7 justify-center items-center"> <div className="flex h-full gap-7 justify-center items-center">
{canWriteStations && ( {canWriteStations && (
<button onClick={() => navigate(`/station/${params.row.id}/edit`)}> <button title="Редактировать" onClick={() => navigate(`/station/${params.row.id}/edit`)}>
<Pencil size={20} className="text-blue-500" /> <Pencil size={20} className="text-blue-500" />
</button> </button>
)} )}
@@ -151,13 +151,14 @@ export const StationListPage = observer(() => {
setSelectedStationId(params.row.id); setSelectedStationId(params.row.id);
setIsTransfersModalOpen(true); setIsTransfersModalOpen(true);
}} }}
title="Редактировать пересадки" title="Управление пересадками"
> >
<Route size={20} className="text-purple-500" /> <Route size={20} className="text-purple-500" />
</button> </button>
)} )}
{canWriteStations && ( {canWriteStations && (
<button <button
title="Удалить"
onClick={() => { onClick={() => {
setIsDeleteModalOpen(true); setIsDeleteModalOpen(true);
setRowId(params.row.id); setRowId(params.row.id);
@@ -225,7 +226,9 @@ export const StationListPage = observer(() => {
</div> </div>
)} )}
<SearchInput value={searchQuery} onChange={setSearchQuery} /> {rows.length > 0 && (
<SearchInput value={searchQuery} onChange={setSearchQuery} />
)}
<DataGrid <DataGrid
rows={rows} rows={rows}

View File

@@ -92,10 +92,11 @@ export const UserListPage = observer(() => {
sortable: false, sortable: false,
renderCell: (params: GridRenderCellParams) => ( renderCell: (params: GridRenderCellParams) => (
<div className="flex h-full gap-7 justify-center items-center"> <div className="flex h-full gap-7 justify-center items-center">
<button onClick={() => navigate(`/user/${params.row.id}/edit`)}> <button title="Редактировать" onClick={() => navigate(`/user/${params.row.id}/edit`)}>
<Pencil size={20} className="text-blue-500" /> <Pencil size={20} className="text-blue-500" />
</button> </button>
<button <button
title="Удалить"
onClick={() => { onClick={() => {
setIsDeleteModalOpen(true); setIsDeleteModalOpen(true);
setRowId(params.row.id); setRowId(params.row.id);
@@ -146,7 +147,9 @@ export const UserListPage = observer(() => {
</div> </div>
)} )}
<SearchInput value={searchQuery} onChange={setSearchQuery} /> {rows.length > 0 && (
<SearchInput value={searchQuery} onChange={setSearchQuery} />
)}
<DataGrid <DataGrid
rows={rows} rows={rows}
@@ -154,6 +157,11 @@ export const UserListPage = observer(() => {
checkboxSelection={canWriteUsers} checkboxSelection={canWriteUsers}
disableRowSelectionExcludeModel disableRowSelectionExcludeModel
disableRowSelectionOnClick disableRowSelectionOnClick
onRowDoubleClick={(params) => {
if (canWriteUsers) {
navigate(`/user/${params.row.id}/edit`);
}
}}
loading={isLoading} loading={isLoading}
paginationModel={paginationModel} paginationModel={paginationModel}
onPaginationModelChange={setPaginationModel} onPaginationModelChange={setPaginationModel}

View File

@@ -115,15 +115,16 @@ export const VehicleListPage = observer(() => {
return ( return (
<div className="flex h-full gap-7 justify-center items-center"> <div className="flex h-full gap-7 justify-center items-center">
{canWrite && ( {canWrite && (
<button onClick={() => navigate(`/vehicle/${params.row.id}/edit`)}> <button title="Редактировать" onClick={() => navigate(`/vehicle/${params.row.id}/edit`)}>
<Pencil size={20} className="text-blue-500" /> <Pencil size={20} className="text-blue-500" />
</button> </button>
)} )}
<button onClick={() => navigate(`/vehicle/${params.row.id}`)}> <button title="Просмотр" onClick={() => navigate(`/vehicle/${params.row.id}`)}>
<Eye size={20} className="text-green-500" /> <Eye size={20} className="text-green-500" />
</button> </button>
{canWrite && ( {canWrite && (
<button <button
title="Удалить"
onClick={() => { onClick={() => {
setIsDeleteModalOpen(true); setIsDeleteModalOpen(true);
setRowId(params.row.id); setRowId(params.row.id);
@@ -173,7 +174,9 @@ export const VehicleListPage = observer(() => {
/> />
</div> </div>
<SearchInput value={searchQuery} onChange={setSearchQuery} /> {rows.length > 0 && (
<SearchInput value={searchQuery} onChange={setSearchQuery} />
)}
{canWriteVehicles && ids.length > 0 && ( {canWriteVehicles && ids.length > 0 && (
<div className="flex justify-end mb-5 duration-300"> <div className="flex justify-end mb-5 duration-300">

View File

@@ -6,11 +6,21 @@ import {
SelectChangeEvent, SelectChangeEvent,
Typography, Typography,
Box, Box,
keyframes,
} from "@mui/material"; } from "@mui/material";
import { observer } from "mobx-react-lite"; import { observer } from "mobx-react-lite";
import { authStore, cityStore, selectedCityStore, snapshotStore, type City } from "@shared"; import { authStore, cityStore, selectedCityStore, snapshotStore, type City } from "@shared";
import { MapPin } from "lucide-react"; import { MapPin } from "lucide-react";
const borderSpin = keyframes`
0% {
background-position: 0% 50%;
}
100% {
background-position: 200% 50%;
}
`;
export const CitySelector: React.FC = observer(() => { export const CitySelector: React.FC = observer(() => {
const { selectedCity, setSelectedCity, isLocked } = selectedCityStore; const { selectedCity, setSelectedCity, isLocked } = selectedCityStore;
const canLoadAllCities = authStore.isAdmin && authStore.canRead("cities"); const canLoadAllCities = authStore.isAdmin && authStore.canRead("cities");
@@ -43,6 +53,8 @@ export const CitySelector: React.FC = observer(() => {
})() })()
: baseCities; : baseCities;
const noCitySelected = !selectedCity?.id;
const handleCityChange = (event: SelectChangeEvent<string>) => { const handleCityChange = (event: SelectChangeEvent<string>) => {
const cityId = event.target.value; const cityId = event.target.value;
if (cityId === "") { if (cityId === "") {
@@ -58,49 +70,76 @@ export const CitySelector: React.FC = observer(() => {
} }
}; };
const selectElement = (
<Select
value={selectedCity?.id?.toString() || ""}
onChange={handleCityChange}
displayEmpty
disabled={isLocked}
sx={{
height: "40px",
color: "white",
borderRadius: "4px",
...(noCitySelected && !isLocked
? {
backgroundColor: "#48989f",
"& .MuiOutlinedInput-notchedOutline": { border: "none" },
}
: {
"& .MuiOutlinedInput-notchedOutline": {
borderColor: isLocked
? "rgba(255, 255, 255, 0.1)"
: "rgba(255, 255, 255, 0.3)",
},
}),
"&.Mui-disabled": {
color: "rgba(255, 255, 255, 0.5)",
WebkitTextFillColor: "rgba(255, 255, 255, 0.5)",
},
"&:hover .MuiOutlinedInput-notchedOutline": {
borderColor: isLocked
? "rgba(255, 255, 255, 0.1)"
: "rgba(255, 255, 255, 0.5)",
},
"&.Mui-focused .MuiOutlinedInput-notchedOutline": {
borderColor: "white",
},
"& .MuiSvgIcon-root": {
color: isLocked ? "rgba(255, 255, 255, 0.3)" : "white",
},
}}
>
<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>
);
return ( return (
<Box className="flex items-center gap-2"> <Box className="flex items-center gap-2">
<MapPin size={16} className={isLocked ? "text-gray-400" : "text-white"} /> <MapPin size={16} className={isLocked ? "text-gray-400" : "text-white"} />
<FormControl size="medium" sx={{ minWidth: 120 }}> <FormControl size="medium" sx={{ minWidth: 120 }}>
<Select {noCitySelected && !isLocked ? (
value={selectedCity?.id?.toString() || ""} <Box
onChange={handleCityChange} sx={{
displayEmpty position: "relative",
disabled={isLocked} borderRadius: "4px",
sx={{ padding: "2px",
height: "40px", background: "linear-gradient(90deg, rgba(255,255,255,0.1), rgba(255,255,255,0.7), rgba(255,255,255,0.1), rgba(255,255,255,0.7), rgba(255,255,255,0.1))",
color: "white", backgroundSize: "200% 100%",
"&.Mui-disabled": { animation: `${borderSpin} 2.5s linear infinite`,
color: "rgba(255, 255, 255, 0.5)", }}
WebkitTextFillColor: "rgba(255, 255, 255, 0.5)", >
}, {selectElement}
"& .MuiOutlinedInput-notchedOutline": { </Box>
borderColor: isLocked ) : (
? "rgba(255, 255, 255, 0.1)" selectElement
: "rgba(255, 255, 255, 0.3)", )}
},
"&:hover .MuiOutlinedInput-notchedOutline": {
borderColor: isLocked
? "rgba(255, 255, 255, 0.1)"
: "rgba(255, 255, 255, 0.5)",
},
"&.Mui-focused .MuiOutlinedInput-notchedOutline": {
borderColor: "white",
},
"& .MuiSvgIcon-root": {
color: isLocked ? "rgba(255, 255, 255, 0.3)" : "white",
},
}}
>
<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> </FormControl>
</Box> </Box>
); );

View File

@@ -618,7 +618,7 @@ export const DevicesTable = observer(() => {
e.stopPropagation(); e.stopPropagation();
navigate(`/vehicle/${row.vehicle_id}/edit`); navigate(`/vehicle/${row.vehicle_id}/edit`);
}} }}
title="Редактировать транспорт" title="Редактировать"
> >
<Pencil size={16} /> <Pencil size={16} />
</button> </button>
@@ -628,7 +628,7 @@ export const DevicesTable = observer(() => {
e.stopPropagation(); e.stopPropagation();
handleReloadStatus(); handleReloadStatus();
}} }}
title="Перезапросить статус" title="Обновить статус"
disabled={ disabled={
!row.device_uuid || !devices.includes(row.device_uuid) !row.device_uuid || !devices.includes(row.device_uuid)
} }
@@ -655,7 +655,7 @@ export const DevicesTable = observer(() => {
setLogsModalOpen(true); setLogsModalOpen(true);
} }
}} }}
title="Логи устройства" title="Логи"
> >
<ScrollText size={16} /> <ScrollText size={16} />
</button> </button>
@@ -668,7 +668,7 @@ export const DevicesTable = observer(() => {
setSessionsModalVehicleTailNumber(row.tail_number); setSessionsModalVehicleTailNumber(row.tail_number);
setSessionsModalOpen(true); setSessionsModalOpen(true);
}} }}
title="Сессии ТО" title="Сессии обслуживания"
> >
<Wrench size={16} /> <Wrench size={16} />
</button> </button>

View File

@@ -87,12 +87,14 @@ export const SightsTable = observer(() => {
<TableCell align="center" className="py-3"> <TableCell align="center" className="py-3">
<div className="flex justify-center items-center gap-3"> <div className="flex justify-center items-center gap-3">
<button <button
title="Редактировать"
className="rounded-md px-3 py-1.5 transition-transform transform hover:scale-105" className="rounded-md px-3 py-1.5 transition-transform transform hover:scale-105"
onClick={() => navigate(`/sight/${row?.id}`)} onClick={() => navigate(`/sight/${row?.id}`)}
> >
<Pencil size={18} className="text-blue-500" /> <Pencil size={18} className="text-blue-500" />
</button> </button>
<button <button
title="Удалить"
className="rounded-md px-3 py-1.5 transition-transform transform hover:scale-105" className="rounded-md px-3 py-1.5 transition-transform transform hover:scale-105"
onClick={() => { onClick={() => {
setIsDeleteModalOpen(true); setIsDeleteModalOpen(true);