feat: add AppLayout, BrandLogo, ProfileMenu, and RouteBadge components
- Implemented AppLayout component for application shell with navigation and sidebar. - Created BrandLogo component to display the application logo. - Developed ProfileMenu component for user profile management with dropdown functionality. - Added RouteBadge component to visually represent route numbers with color coding. style: add CSS styles for Dashboard and Routes pages - Introduced Dashboard.css for styling the dashboard layout and components. - Created RoutesPage.css for styling the routes page, including filters and table layout. - Added SchedulesPage.css for styling the schedules layout and route options.
This commit is contained in:
@@ -1,9 +1,8 @@
|
||||
import { useState } from 'react';
|
||||
import { BrowserRouter as Router, Routes, Route, Link, useLocation, Navigate } from 'react-router-dom';
|
||||
import { Bus, MapPin, Calendar, Settings, Gift, Menu, X, AlertCircle, Users, UserCircle, LogOut } from 'lucide-react';
|
||||
import { BrowserRouter as Router, Routes, Route, useLocation, Navigate } from 'react-router-dom';
|
||||
import { AuthProvider, useAuth } from './contexts/AuthContext';
|
||||
import { ProtectedRoute } from './components/ProtectedRoute';
|
||||
import { Avatar } from './components/AvatarUpload';
|
||||
import AppLayout from './components/AppLayout';
|
||||
import { PageLoader } from './components/AnimatedComponents';
|
||||
|
||||
// Pages
|
||||
import LoginPage from './pages/LoginPage';
|
||||
@@ -28,7 +27,9 @@ function App() {
|
||||
}
|
||||
|
||||
function AppContent() {
|
||||
const { isAuthenticated } = useAuth();
|
||||
const { isAuthenticated, loading } = useAuth();
|
||||
|
||||
if (loading) return <PageLoader />;
|
||||
|
||||
if (!isAuthenticated) {
|
||||
return (
|
||||
@@ -40,15 +41,9 @@ function AppContent() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
<Navigation />
|
||||
|
||||
<main className="max-w-7xl mx-auto px-3 sm:px-6 lg:px-8 py-4 sm:py-8 pb-20 lg:pb-8">
|
||||
<AnimatedRoutes />
|
||||
</main>
|
||||
|
||||
<MobileBottomNav />
|
||||
</div>
|
||||
<AppLayout>
|
||||
<AnimatedRoutes />
|
||||
</AppLayout>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -76,206 +71,4 @@ function AnimatedRoutes() {
|
||||
);
|
||||
}
|
||||
|
||||
function Navigation() {
|
||||
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
|
||||
const [showUserMenu, setShowUserMenu] = useState(false);
|
||||
const { user, logout, isAdmin } = useAuth();
|
||||
|
||||
return (
|
||||
<nav className="bg-white shadow-sm border-b border-gray-200 sticky top-0 z-40">
|
||||
<div className="max-w-7xl mx-auto px-3 sm:px-6 lg:px-8">
|
||||
<div className="flex justify-between h-14 sm:h-16">
|
||||
<div className="flex items-center">
|
||||
<Link to="/" className="flex-shrink-0 flex items-center group">
|
||||
<Bus className="h-7 w-7 sm:h-8 sm:w-8 text-primary-600 transition-transform duration-300 group-hover:scale-110" />
|
||||
<span className="ml-2 text-lg sm:text-xl font-bold text-gray-900 hidden xs:inline">
|
||||
Polotsk Transit
|
||||
</span>
|
||||
</Link>
|
||||
|
||||
{/* Desktop Navigation */}
|
||||
<div className="hidden lg:ml-6 lg:flex lg:space-x-6">
|
||||
<NavLink to="/" icon={<Settings className="h-4 w-4" />}>Dashboard</NavLink>
|
||||
<NavLink to="/routes" icon={<Bus className="h-4 w-4" />}>Маршруты</NavLink>
|
||||
<NavLink to="/stops" icon={<MapPin className="h-4 w-4" />}>Остановки</NavLink>
|
||||
<NavLink to="/schedules" icon={<Calendar className="h-4 w-4" />}>Расписания</NavLink>
|
||||
<NavLink to="/alerts" icon={<AlertCircle className="h-4 w-4" />}>Уведомления</NavLink>
|
||||
{isAdmin && (
|
||||
<NavLink to="/users" icon={<Users className="h-4 w-4" />}>Пользователи</NavLink>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Desktop User Menu */}
|
||||
<div className="hidden lg:flex items-center">
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={() => setShowUserMenu(!showUserMenu)}
|
||||
className="flex items-center gap-2 p-2 rounded-lg hover:bg-gray-100 transition-colors"
|
||||
>
|
||||
<Avatar src={user?.avatar_url} alt={user?.username} size="sm" />
|
||||
<span className="text-sm font-medium text-gray-700">{user?.username}</span>
|
||||
</button>
|
||||
|
||||
{showUserMenu && (
|
||||
<div className="absolute right-0 mt-2 w-48 bg-white rounded-lg shadow-lg border border-gray-200 py-1">
|
||||
<Link
|
||||
to="/profile"
|
||||
onClick={() => setShowUserMenu(false)}
|
||||
className="flex items-center gap-2 px-4 py-2 text-sm text-gray-700 hover:bg-gray-100"
|
||||
>
|
||||
<UserCircle className="h-4 w-4" />
|
||||
Профиль
|
||||
</Link>
|
||||
<button
|
||||
onClick={() => { logout(); setShowUserMenu(false); }}
|
||||
className="w-full flex items-center gap-2 px-4 py-2 text-sm text-red-600 hover:bg-red-50"
|
||||
>
|
||||
<LogOut className="h-4 w-4" />
|
||||
Выйти
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mobile menu button */}
|
||||
<div className="flex items-center lg:hidden">
|
||||
<button
|
||||
onClick={() => setMobileMenuOpen(!mobileMenuOpen)}
|
||||
className="p-2 rounded-lg text-gray-500 hover:text-gray-700 hover:bg-gray-100 transition-colors duration-200"
|
||||
>
|
||||
<div className="relative w-6 h-6">
|
||||
<X className={`h-6 w-6 absolute transition-all duration-300 ${
|
||||
mobileMenuOpen ? 'opacity-100 rotate-0' : 'opacity-0 rotate-90'
|
||||
}`} />
|
||||
<Menu className={`h-6 w-6 absolute transition-all duration-300 ${
|
||||
mobileMenuOpen ? 'opacity-0 -rotate-90' : 'opacity-100 rotate-0'
|
||||
}`} />
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mobile menu dropdown */}
|
||||
<div className={`lg:hidden border-t border-gray-200 bg-white overflow-hidden transition-all duration-300 ease-out ${
|
||||
mobileMenuOpen ? 'max-h-96 opacity-100' : 'max-h-0 opacity-0'
|
||||
}`}>
|
||||
<div className="px-2 pt-2 pb-3 space-y-1">
|
||||
<MobileNavLink to="/" icon={<Settings className="h-5 w-5" />} onClick={() => setMobileMenuOpen(false)} delay={0}>
|
||||
Dashboard
|
||||
</MobileNavLink>
|
||||
<MobileNavLink to="/routes" icon={<Bus className="h-5 w-5" />} onClick={() => setMobileMenuOpen(false)} delay={1}>
|
||||
Маршруты
|
||||
</MobileNavLink>
|
||||
<MobileNavLink to="/stops" icon={<MapPin className="h-5 w-5" />} onClick={() => setMobileMenuOpen(false)} delay={2}>
|
||||
Остановки
|
||||
</MobileNavLink>
|
||||
<MobileNavLink to="/schedules" icon={<Calendar className="h-5 w-5" />} onClick={() => setMobileMenuOpen(false)} delay={3}>
|
||||
Расписания
|
||||
</MobileNavLink>
|
||||
<MobileNavLink to="/alerts" icon={<AlertCircle className="h-5 w-5" />} onClick={() => setMobileMenuOpen(false)} delay={4}>
|
||||
Уведомления
|
||||
</MobileNavLink>
|
||||
{isAdmin && (
|
||||
<MobileNavLink to="/users" icon={<Users className="h-5 w-5" />} onClick={() => setMobileMenuOpen(false)} delay={5}>
|
||||
Пользователи
|
||||
</MobileNavLink>
|
||||
)}
|
||||
<MobileNavLink to="/profile" icon={<UserCircle className="h-5 w-5" />} onClick={() => setMobileMenuOpen(false)} delay={6}>
|
||||
Профиль
|
||||
</MobileNavLink>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
|
||||
function MobileBottomNav() {
|
||||
const location = useLocation();
|
||||
const { isAdmin } = useAuth();
|
||||
|
||||
const navItems = [
|
||||
{ to: '/', icon: Settings, label: 'Главная' },
|
||||
{ to: '/routes', icon: Bus, label: 'Маршруты' },
|
||||
{ to: '/stops', icon: MapPin, label: 'Остановки' },
|
||||
{ to: '/alerts', icon: AlertCircle, label: 'Уведомл.' },
|
||||
isAdmin
|
||||
? { to: '/users', icon: Users, label: 'Пользов.' }
|
||||
: { to: '/profile', icon: UserCircle, label: 'Профиль' },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="fixed bottom-0 left-0 right-0 bg-white border-t border-gray-200 lg:hidden z-50 safe-bottom">
|
||||
<div className="flex justify-around items-center h-16">
|
||||
{navItems.map(({ to, icon: Icon, label }) => {
|
||||
const isActive = location.pathname === to ||
|
||||
(to !== '/' && location.pathname.startsWith(to));
|
||||
|
||||
return (
|
||||
<Link
|
||||
key={to}
|
||||
to={to}
|
||||
className={`flex flex-col items-center justify-center flex-1 h-full px-1 transition-all duration-200 ${
|
||||
isActive
|
||||
? 'text-primary-600'
|
||||
: 'text-gray-500 hover:text-gray-700'
|
||||
}`}
|
||||
>
|
||||
<Icon className={`h-5 w-5 transition-transform ${isActive ? 'scale-110 -translate-y-0.5' : ''}`} />
|
||||
<span className="text-xs mt-1 truncate">{label}</span>
|
||||
{isActive && (
|
||||
<div className="absolute top-0 left-1/2 -translate-x-1/2 w-8 h-0.5 bg-primary-600 rounded-b-full" />
|
||||
)}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function NavLink({ to, icon, children }) {
|
||||
const location = useLocation();
|
||||
const isActive = location.pathname === to ||
|
||||
(to !== '/' && location.pathname.startsWith(to));
|
||||
|
||||
return (
|
||||
<Link
|
||||
to={to}
|
||||
className={`inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium transition-colors ${
|
||||
isActive
|
||||
? 'border-primary-500 text-gray-900'
|
||||
: 'border-transparent text-gray-500 hover:border-primary-300 hover:text-gray-700'
|
||||
}`}
|
||||
>
|
||||
<span className="mr-1.5">{icon}</span>
|
||||
{children}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
function MobileNavLink({ to, icon, children, onClick, delay = 0 }) {
|
||||
const location = useLocation();
|
||||
const isActive = location.pathname === to ||
|
||||
(to !== '/' && location.pathname.startsWith(to));
|
||||
|
||||
return (
|
||||
<Link
|
||||
to={to}
|
||||
onClick={onClick}
|
||||
className={`flex items-center px-3 py-3 rounded-lg text-base font-medium transition-all ${
|
||||
isActive
|
||||
? 'bg-primary-50 text-primary-700'
|
||||
: 'text-gray-600 hover:bg-gray-50 hover:text-gray-900'
|
||||
}`}
|
||||
style={{ animationDelay: `${delay * 50}ms` }}
|
||||
>
|
||||
<span className="mr-3">{icon}</span>
|
||||
{children}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
export default App;
|
||||
|
||||
BIN
frontend/src/assets/dashboard-background.png
Normal file
BIN
frontend/src/assets/dashboard-background.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 122 KiB |
26
frontend/src/assets/logo-with-text.svg
Normal file
26
frontend/src/assets/logo-with-text.svg
Normal file
@@ -0,0 +1,26 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!-- Generated by Pixelmator Pro 4.3 -->
|
||||
<svg viewBox="0 0 168 34" xmlns="http://www.w3.org/2000/svg">
|
||||
<g id="g1">
|
||||
<g id="g2">
|
||||
<path id="path1" fill="#262626" stroke="none" d="M 37.050182 25.721401 L 37.050182 13.416975 L 39.763466 13.416975 L 39.763466 16.445757 L 39.447968 16.06716 C 39.637268 15.625462 39.826565 15.246862 40.078964 14.931366 C 40.331364 14.552767 40.646858 14.300369 41.025459 14.04797 C 41.340958 13.858671 41.656456 13.732471 41.971954 13.606272 C 42.350552 13.480074 42.66605 13.416975 43.044647 13.416975 C 43.423244 13.416975 43.801842 13.416975 44.180439 13.416975 L 44.180439 16.319557 C 43.864944 16.193357 43.423244 16.193357 42.981548 16.193357 C 42.539848 16.256458 42.098152 16.319557 41.782654 16.508856 C 41.404057 16.698156 41.088558 16.887453 40.836159 17.202951 C 40.583763 17.455351 40.394463 17.833948 40.268265 18.149446 C 40.142063 18.528046 40.078964 18.969742 40.078964 19.411438 L 40.078964 25.658302 L 37.050182 25.658302 Z"/>
|
||||
<path id="path2" fill="#262626" stroke="none" d="M 46.641327 11.713284 L 46.641327 9 L 49.733208 9 L 49.733208 11.713284 L 46.641327 11.713284 Z M 46.641327 25.721401 L 46.641327 13.416975 L 49.733208 13.416975 L 49.733208 25.658302 L 46.641327 25.658302 Z"/>
|
||||
<path id="path3" fill="#262626" stroke="none" d="M 58.062359 26.0369 C 56.926563 26.0369 55.916973 25.721401 55.096676 25.216606 C 54.276382 24.648708 53.582283 23.891514 53.140587 22.88192 C 52.635792 21.935425 52.446491 20.799631 52.446491 19.600739 C 52.446491 18.338745 52.698891 17.266052 53.140587 16.256458 C 53.582283 15.246862 54.276382 14.489668 55.159779 13.984871 C 56.043167 13.480074 57.052765 13.164576 58.251656 13.164576 C 59.387451 13.164576 60.397045 13.480074 61.217342 14.04797 C 62.037632 14.615868 62.60553 15.373062 63.047234 16.382658 C 63.425827 17.329151 63.678226 18.464945 63.678226 19.663837 C 63.678226 20.86273 63.488926 21.998524 63.047234 22.945019 C 62.60553 23.891514 62.037632 24.711807 61.217342 25.279705 C 60.333946 25.784502 59.324352 26.0369 58.062359 26.0369 Z M 58.567154 23.323616 C 59.261253 23.323616 59.829147 23.197418 60.207748 22.88192 C 60.649445 22.566422 60.901844 22.124722 61.091141 21.556828 C 61.280441 20.98893 61.34354 20.357933 61.34354 19.600739 C 61.34354 18.843544 61.280441 18.212547 61.091141 17.64465 C 60.901844 17.076752 60.649445 16.635056 60.207748 16.319557 C 59.829147 16.004059 59.261253 15.877859 58.630257 15.877859 C 57.936157 15.877859 57.368263 16.06716 56.926563 16.382658 C 56.484871 16.698156 56.169373 17.139853 55.916973 17.707748 C 55.727669 18.275646 55.601475 18.906643 55.601475 19.600739 C 55.601475 20.294834 55.727669 20.925831 55.916973 21.493727 C 56.106274 22.061625 56.421772 22.503321 56.863464 22.818819 C 57.368263 23.134317 57.936157 23.323616 58.567154 23.323616 Z M 61.406639 25.721401 L 61.406639 17.329151 L 61.028042 17.329151 L 61.028042 9.378597 L 64.119926 9.378597 L 64.119926 25.721401 L 61.406639 25.721401 Z"/>
|
||||
<path id="path4" fill="#262626" stroke="none" d="M 73.016968 26.0369 C 71.754974 26.0369 70.682289 25.784502 69.672691 25.216606 C 68.726196 24.648708 67.969002 23.954613 67.4011 23.008118 C 66.833206 22.061625 66.580811 20.98893 66.580811 19.726936 C 66.580811 18.401844 66.833206 17.202951 67.4011 16.256458 C 67.905899 15.246862 68.663094 14.489668 69.609589 13.92177 C 70.556084 13.353874 71.628784 13.101477 72.827675 13.101477 C 74.089668 13.101477 75.225456 13.416975 76.108856 13.984871 C 76.992249 14.615868 77.686348 15.436163 78.128036 16.508856 C 78.56974 17.581551 78.695938 18.906643 78.56974 20.357933 L 75.540955 20.357933 L 75.540955 19.222141 C 75.540955 17.960148 75.351662 17.076752 74.973061 16.508856 C 74.59446 15.94096 73.900368 15.68856 73.016968 15.68856 C 71.944283 15.68856 71.18708 16.004059 70.682289 16.635056 C 70.17749 17.266052 69.925087 18.212547 69.925087 19.474539 C 69.925087 20.610332 70.17749 21.493727 70.682289 22.124722 C 71.18708 22.755718 71.88118 23.071217 72.89077 23.071217 C 73.521767 23.071217 74.026566 22.945019 74.468262 22.692619 C 74.909958 22.44022 75.225456 22.061625 75.477859 21.556828 L 78.56974 22.44022 C 78.128036 23.576015 77.37085 24.45941 76.361252 25.027306 C 75.288559 25.721401 74.215866 26.0369 73.016968 26.0369 Z M 68.915497 20.421034 L 68.915497 18.149446 L 77.055351 18.149446 L 77.055351 20.421034 L 68.915497 20.421034 Z"/>
|
||||
<path id="path5" fill="#262626" stroke="none" d="M 81.093727 25.721401 L 81.093727 9.378597 L 84.185608 9.378597 L 84.185608 17.329151 L 83.807007 17.329151 L 83.807007 25.721401 L 81.093727 25.721401 Z M 87.151291 26.0369 C 85.952393 26.0369 84.942802 25.721401 84.122505 25.216606 C 83.302216 24.648708 82.671219 23.891514 82.292618 22.88192 C 81.914017 21.872326 81.661621 20.799631 81.661621 19.600739 C 81.661621 18.401844 81.850922 17.266052 82.292618 16.319557 C 82.671219 15.373062 83.302216 14.552767 84.122505 13.984871 C 84.942802 13.416975 85.889297 13.101477 87.088188 13.101477 C 88.287086 13.101477 89.296677 13.353874 90.180077 13.92177 C 91.063461 14.489668 91.757561 15.246862 92.199257 16.193357 C 92.640953 17.139853 92.893356 18.275646 92.893356 19.53764 C 92.893356 20.73653 92.640953 21.872326 92.199257 22.818819 C 91.694458 23.765312 91.063461 24.585609 90.243172 25.153507 C 89.233582 25.784502 88.287086 26.0369 87.151291 26.0369 Z M 86.646492 23.323616 C 87.340591 23.323616 87.845383 23.134317 88.287086 22.818819 C 88.728783 22.503321 89.044281 22.061625 89.233582 21.493727 C 89.422874 20.925831 89.54908 20.294834 89.54908 19.600739 C 89.54908 18.906643 89.422874 18.275646 89.233582 17.707748 C 89.044281 17.139853 88.66568 16.698156 88.223984 16.382658 C 87.782288 16.06716 87.214386 15.877859 86.520294 15.877859 C 85.889297 15.877859 85.384499 16.004059 84.942802 16.319557 C 84.564201 16.635056 84.248711 17.076752 84.059402 17.64465 C 83.87011 18.212547 83.807007 18.843544 83.807007 19.600739 C 83.807007 20.357933 83.87011 20.98893 84.059402 21.556828 C 84.248711 22.124722 84.564201 22.566422 84.942802 22.88192 C 85.384499 23.134317 85.952393 23.323616 86.646492 23.323616 Z"/>
|
||||
<path id="path6" fill="#262626" stroke="none" d="M 99.83432 26.1 C 98.887825 26.1 98.067528 25.910702 97.499626 25.595203 C 96.868629 25.279705 96.426933 24.838009 96.111435 24.33321 C 95.795937 23.828413 95.543541 23.323616 95.417343 22.755718 C 95.291138 22.187822 95.16494 21.746126 95.16494 21.304428 C 95.16494 20.86273 95.101845 20.547232 95.101845 20.357933 L 95.101845 13.416975 L 98.256828 13.416975 L 98.256828 19.222141 C 98.256828 19.474539 98.256828 19.853138 98.319923 20.294834 C 98.383026 20.73653 98.446121 21.178228 98.635422 21.619926 C 98.824722 22.061625 99.077118 22.44022 99.392616 22.692619 C 99.771217 23.008118 100.276016 23.134317 100.907013 23.134317 C 101.159409 23.134317 101.474907 23.071217 101.790405 23.008118 C 102.105896 22.945019 102.358307 22.755718 102.610703 22.503321 C 102.863091 22.250923 103.115501 21.935425 103.241692 21.430628 C 103.430992 20.98893 103.494087 20.357933 103.494087 19.600739 L 105.26088 20.421034 C 105.26088 21.367527 105.071579 22.314022 104.692986 23.134317 C 104.314384 23.954613 103.683388 24.648708 102.926193 25.216606 C 102.169006 25.784502 101.033211 26.1 99.83432 26.1 Z M 103.809586 25.721401 L 103.809586 21.619926 L 103.430992 21.619926 L 103.430992 13.416975 L 106.522873 13.416975 L 106.522873 25.658302 L 103.809586 25.658302 Z"/>
|
||||
<path id="path7" fill="#262626" stroke="none" d="M 114.536522 26.0369 C 113.022141 26.0369 111.760147 25.658302 110.813652 24.964207 C 109.867157 24.270111 109.299263 23.260517 109.109962 21.998524 L 112.264946 21.493727 C 112.391144 22.061625 112.706642 22.503321 113.148338 22.818819 C 113.590034 23.134317 114.157936 23.323616 114.85202 23.323616 C 115.41993 23.323616 115.861618 23.197418 116.177116 23.008118 C 116.492615 22.818819 116.618813 22.503321 116.618813 22.124722 C 116.618813 21.872326 116.555717 21.683025 116.429512 21.556828 C 116.303314 21.430628 116.050919 21.241329 115.60923 21.115129 C 115.167519 20.98893 114.536522 20.799631 113.653137 20.547232 C 112.643539 20.294834 111.82325 19.979336 111.255348 19.663837 C 110.687454 19.348339 110.245758 18.969742 109.93026 18.528046 C 109.614761 18.086346 109.551659 17.51845 109.551659 16.950554 C 109.551659 16.193357 109.740959 15.499262 110.182655 14.931366 C 110.624352 14.363468 111.12915 13.92177 111.886345 13.606272 C 112.643539 13.290775 113.463837 13.164576 114.473434 13.164576 C 115.41993 13.164576 116.303314 13.290775 116.997414 13.606272 C 117.754608 13.92177 118.322502 14.300369 118.827301 14.868265 C 119.268997 15.436163 119.584496 16.06716 119.647598 16.824354 L 116.492615 17.39225 C 116.429512 16.950554 116.240227 16.571957 115.924728 16.319557 C 115.60923 16.06716 115.104431 15.877859 114.536522 15.877859 C 113.968636 15.81476 113.526939 15.94096 113.148338 16.130259 C 112.83284 16.319557 112.643539 16.571957 112.643539 16.950554 C 112.643539 17.139853 112.706642 17.329151 112.895943 17.455351 C 113.022141 17.581551 113.337639 17.770849 113.842438 17.897049 C 114.284134 18.023247 115.041321 18.275646 115.987816 18.464945 C 116.934311 18.717342 117.691505 18.969742 118.259407 19.28524 C 118.827301 19.600739 119.205902 19.979336 119.5214 20.421034 C 119.773796 20.86273 119.899994 21.430628 119.899994 22.061625 C 119.899994 23.323616 119.458298 24.33321 118.511803 25.027306 C 117.376007 25.658302 116.114014 26.0369 114.536522 26.0369 Z"/>
|
||||
</g>
|
||||
</g>
|
||||
<g id="g3">
|
||||
<g id="g4">
|
||||
<clipPath id="clipPath1">
|
||||
<path d="M -0.1 5 L 28.9 5 L 28.9 34 L -0.1 34 Z"/>
|
||||
</clipPath>
|
||||
<g id="g5" clip-path="url(#clipPath1)">
|
||||
<path id="path8" fill="#2082c4" stroke="none" d="M 10.6 5.700001 L 20.5 6.5 C 25.200001 6.799999 28.700001 10.9 28.299999 15.6 L 27.5 25.5 C 27.1 30.200001 23.1 33.700001 18.4 33.299999 L 8.5 32.5 C 3.9 32.200001 0.4 28 0.7 23.299999 L 1.5 13.4 C 1.9 8.799999 6 5.299999 10.6 5.700001 Z"/>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
<path id="path9" fill="#ffffff" stroke="none" d="M 9.8 27.200001 C 9 27.200001 8.7 26.9 8.7 26.299999 C 8.7 25.9 8.7 25.5 8.7 25.1 C 8.7 25 8.6 25 8.6 24.799999 C 8.1 24.200001 7.8 23.4 7.9 22.700001 C 8 20.200001 7.9 17.700001 7.9 15.1 C 7.9 14.700001 8 14.299999 8 14 C 8.3 13.1 9 12.5 9.9 12.200001 C 11 11.9 12 11.700001 13.2 11.799999 C 14.6 11.799999 16 11.799999 17.299999 11.9 C 18.299999 12 19.4 12.200001 20.200001 13 C 20.299999 13.1 20.5 13.299999 20.5 13.4 C 20.9 14 21.1 14.9 21 15.700001 C 21 16.9 21 18 21 19.1 L 21 19.1 C 21 20.4 21 21.6 21 23 C 21 23.6 20.700001 24.4 20.299999 24.9 C 20 25 20 25.200001 20 25.4 C 20 25.799999 20 26.200001 20 26.4 C 20 26.700001 19.9 27 19.6 27.200001 C 19.5 27.299999 19.299999 27.299999 19.200001 27.299999 C 18.9 27.299999 18.6 27.299999 18.200001 27.299999 C 17.700001 27.299999 17.4 26.9 17.299999 26.5 C 17.299999 26.299999 17.299999 26.1 17.299999 25.9 C 17.299999 25.799999 17.200001 25.799999 17.200001 25.700001 L 17.200001 25.700001 L 11.3 25.700001 C 11.2 25.700001 11.1 25.700001 11.2 25.9 C 11.2 26 11.2 26.299999 11.2 26.4 C 11.2 26.9 10.8 27.299999 10.4 27.299999 M 14.4 15.1 L 14.4 15.1 C 12.8 15.1 11.3 15.1 9.8 15.1 C 9.7 15.1 9.5 15.200001 9.5 15.4 C 9.5 16.5 9.5 17.700001 9.5 18.9 C 9.5 19 9.6 19.200001 9.8 19.200001 C 12.9 19.200001 15.9 19.200001 19 19.200001 C 19.200001 19.200001 19.299999 19.1 19.299999 18.9 C 19.299999 17.799999 19.299999 16.6 19.299999 15.5 C 19.299999 15.4 19.200001 15.200001 19 15.200001 L 19 15.200001 C 17.4 15.1 15.9 15.1 14.4 15.1 Z M 18.200001 24 C 18.799999 23.9 19.299999 23.4 19.200001 22.700001 C 19.1 22 18.6 21.6 17.9 21.700001 C 17.799999 21.700001 17.799999 21.700001 17.6 21.700001 C 17 21.9 16.6 22.6 16.799999 23.200001 C 17.1 23.6 17.6 24 18.200001 24 L 18.200001 24 Z M 10.5 24 C 11.1 24.1 11.8 23.700001 11.9 23 C 12 22.4 11.6 21.700001 11 21.6 C 10.4 21.5 9.7 21.9 9.6 22.6 C 9.5 22.700001 9.5 22.700001 9.5 22.9 C 9.6 23.4 10 23.9 10.5 24 L 10.5 24 Z M 13 23.1 C 12.9 23.6 13.3 24.1 13.7 24.299999 C 14.1 24.6 14.8 24.4 15.2 24.200001 C 15.6 23.9 15.8 23.6 15.7 23.1 L 15.1 23.1 C 15 23.6 14.7 23.9 14.3 23.9 C 13.9 23.9 13.7 23.700001 13.5 23.1 C 13.6 23.1 13 23.1 13 23.1 Z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 12 KiB |
82
frontend/src/components/AppLayout.jsx
Normal file
82
frontend/src/components/AppLayout.jsx
Normal file
@@ -0,0 +1,82 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { Link, NavLink, useLocation } from 'react-router-dom';
|
||||
import { Bus, Menu, X, LayoutDashboard, MapPin, CalendarDays, Gift, Bell, Users, User } from 'lucide-react';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import BrandLogo from './BrandLogo';
|
||||
import ProfileMenu from './ProfileMenu';
|
||||
|
||||
const navigationGroups = [
|
||||
{ label: 'Обзор', items: [{ to: '/', label: 'Главная', icon: LayoutDashboard }] },
|
||||
{ label: 'Транспорт', items: [
|
||||
{ to: '/routes', label: 'Маршруты', icon: Bus },
|
||||
{ to: '/stops', label: 'Остановки', icon: MapPin },
|
||||
{ to: '/schedules', label: 'Расписания', icon: CalendarDays },
|
||||
{ to: '/holidays', label: 'Праздники', icon: Gift },
|
||||
] },
|
||||
{ label: 'Управление', items: [
|
||||
{ to: '/alerts', label: 'Уведомления', icon: Bell },
|
||||
{ to: '/users', label: 'Пользователи', icon: Users, adminOnly: true },
|
||||
{ to: '/profile', label: 'Профиль', icon: User },
|
||||
] },
|
||||
];
|
||||
|
||||
export default function AppLayout({ children }) {
|
||||
const { isAdmin } = useAuth();
|
||||
const { pathname } = useLocation();
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
const menuButton = useRef(null);
|
||||
|
||||
useEffect(() => { setMenuOpen(false); }, [pathname]);
|
||||
|
||||
function closeMenu() {
|
||||
setMenuOpen(false);
|
||||
menuButton.current?.focus();
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="app-shell">
|
||||
<a href="#main-content" className="skip-link">Перейти к содержимому</a>
|
||||
<aside className="app-sidebar" onKeyDown={(event) => {
|
||||
if (event.key === 'Escape' && menuOpen) closeMenu();
|
||||
}}>
|
||||
<div className="sidebar-brand-row">
|
||||
<Link to="/" className="sidebar-brand" aria-label="На главную">
|
||||
<BrandLogo />
|
||||
</Link>
|
||||
<button ref={menuButton} type="button" className="sidebar-menu-button"
|
||||
aria-label={menuOpen ? 'Закрыть меню' : 'Открыть меню'}
|
||||
aria-expanded={menuOpen} aria-controls="sidebar-content"
|
||||
onClick={() => setMenuOpen(!menuOpen)}>
|
||||
{menuOpen ? <X size={22} /> : <Menu size={22} />}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div id="sidebar-content" className={`sidebar-content ${menuOpen ? 'is-open' : ''}`}>
|
||||
<ProfileMenu />
|
||||
<nav aria-label="Основная навигация" className="sidebar-navigation">
|
||||
{navigationGroups.map(group => (
|
||||
<section key={group.label}>
|
||||
<h2 className="sidebar-group-title">{group.label}</h2>
|
||||
<div className="space-y-1">
|
||||
{group.items.filter(item => !item.adminOnly || isAdmin).map(item => (
|
||||
<NavLink key={item.to} to={item.to} end={item.to === '/'} onClick={closeMenu}
|
||||
className={({ isActive }) => `sidebar-link ${isActive ? 'is-active' : ''}`}>
|
||||
<item.icon size={18} strokeWidth={1.8} aria-hidden="true" className="shrink-0" />
|
||||
{item.label}
|
||||
</NavLink>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
))}
|
||||
</nav>
|
||||
<div className="sidebar-footer">
|
||||
<p className="px-3 pt-4 text-xs text-gray-500">Общественный транспорт Полоцка</p>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
<main id="main-content" tabIndex={-1} className={`app-main ${pathname === '/' ? 'app-main-dashboard' : ['/routes', '/schedules'].includes(pathname) ? 'app-main-wide' : ''}`}>
|
||||
<div className="app-content">{children}</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
5
frontend/src/components/BrandLogo.jsx
Normal file
5
frontend/src/components/BrandLogo.jsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import logo from '../assets/logo-with-text.svg';
|
||||
|
||||
export default function BrandLogo({ className = '' }) {
|
||||
return <img src={logo} alt="RideBus" width="168" height="34" className={`brand-logo ${className}`} />;
|
||||
}
|
||||
117
frontend/src/components/ProfileMenu.jsx
Normal file
117
frontend/src/components/ProfileMenu.jsx
Normal file
@@ -0,0 +1,117 @@
|
||||
import { useEffect, useLayoutEffect, useRef, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { Link, useLocation } from 'react-router-dom';
|
||||
import { ChevronDown, LogOut, User } from 'lucide-react';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import { Avatar } from './AvatarUpload';
|
||||
|
||||
export default function ProfileMenu() {
|
||||
const { user, isAdmin, logout } = useAuth();
|
||||
const { pathname } = useLocation();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [position, setPosition] = useState({});
|
||||
const trigger = useRef(null);
|
||||
const menu = useRef(null);
|
||||
const focusLast = useRef(false);
|
||||
|
||||
function close(restoreFocus = false) {
|
||||
setOpen(false);
|
||||
if (restoreFocus) trigger.current?.focus();
|
||||
}
|
||||
|
||||
useEffect(() => { setOpen(false); }, [pathname]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!open) return;
|
||||
function placeMenu() {
|
||||
const rect = trigger.current.getBoundingClientRect();
|
||||
if (!rect.width) { setOpen(false); return; }
|
||||
const width = Math.min(Math.max(rect.width, 248), window.innerWidth - 32);
|
||||
const height = menu.current?.offsetHeight || 164;
|
||||
setPosition({
|
||||
width,
|
||||
left: Math.max(16, Math.min(rect.left, window.innerWidth - width - 16)),
|
||||
top: rect.bottom + height + 8 < window.innerHeight
|
||||
? rect.bottom + 8 : Math.max(8, rect.top - height - 8),
|
||||
});
|
||||
}
|
||||
placeMenu();
|
||||
window.addEventListener('resize', placeMenu);
|
||||
window.addEventListener('scroll', placeMenu, true);
|
||||
return () => {
|
||||
window.removeEventListener('resize', placeMenu);
|
||||
window.removeEventListener('scroll', placeMenu, true);
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const items = menu.current.querySelectorAll('[role="menuitem"]');
|
||||
items[focusLast.current ? items.length - 1 : 0]?.focus();
|
||||
function dismiss(event) {
|
||||
if (!menu.current?.contains(event.target) && !trigger.current?.contains(event.target)) close();
|
||||
}
|
||||
document.addEventListener('pointerdown', dismiss);
|
||||
document.addEventListener('focusin', dismiss);
|
||||
return () => {
|
||||
document.removeEventListener('pointerdown', dismiss);
|
||||
document.removeEventListener('focusin', dismiss);
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
function onMenuKeyDown(event) {
|
||||
const items = [...menu.current.querySelectorAll('[role="menuitem"]')];
|
||||
const current = items.indexOf(document.activeElement);
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
close(true);
|
||||
} else if (['ArrowDown', 'ArrowUp', 'Home', 'End'].includes(event.key)) {
|
||||
event.preventDefault();
|
||||
const next = event.key === 'Home' ? 0 : event.key === 'End' ? items.length - 1
|
||||
: (current + (event.key === 'ArrowDown' ? 1 : -1) + items.length) % items.length;
|
||||
items[next]?.focus();
|
||||
} else if (event.key === 'Tab') {
|
||||
// Return to the trigger's position in the document tab order before moving on.
|
||||
close(true);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<button ref={trigger} type="button" className="sidebar-account w-full text-left"
|
||||
aria-label="Меню профиля" aria-haspopup="menu" aria-expanded={open} aria-controls={open ? 'profile-menu' : undefined}
|
||||
onClick={() => { focusLast.current = false; setOpen(!open); }}
|
||||
onKeyDown={event => {
|
||||
if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
|
||||
event.preventDefault();
|
||||
focusLast.current = event.key === 'ArrowUp';
|
||||
setOpen(true);
|
||||
}
|
||||
}}>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate font-medium">{user?.username}</span>
|
||||
<span className="block text-xs text-gray-500 mt-0.5">{isAdmin ? 'Администратор' : 'Пользователь'}</span>
|
||||
</span>
|
||||
<Avatar src={user?.avatar_url} alt="" size="sm" />
|
||||
<ChevronDown size={14} aria-hidden="true" className={`shrink-0 transition-transform ${open ? 'rotate-180' : ''}`} />
|
||||
</button>
|
||||
{open && createPortal(
|
||||
<div ref={menu} id="profile-menu" role="menu" aria-label="Меню профиля" className="profile-menu"
|
||||
style={position} onKeyDown={onMenuKeyDown}>
|
||||
<Link to="/profile" role="menuitem" tabIndex={-1} className="profile-menu-item" onClick={() => close(true)}>
|
||||
<User size={24} aria-hidden="true" />
|
||||
<span className="min-w-0">
|
||||
<span className="block truncate font-medium">{user?.username}</span>
|
||||
<span className="block text-xs text-gray-500 mt-1">Мой профиль</span>
|
||||
</span>
|
||||
</Link>
|
||||
<div className="profile-menu-divider" role="separator" />
|
||||
<button type="button" role="menuitem" tabIndex={-1} className="profile-menu-item" onClick={() => { close(); logout(); }}>
|
||||
<LogOut size={24} aria-hidden="true" />Выйти
|
||||
</button>
|
||||
</div>, document.body
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
7
frontend/src/components/RouteBadge.jsx
Normal file
7
frontend/src/components/RouteBadge.jsx
Normal file
@@ -0,0 +1,7 @@
|
||||
export default function RouteBadge({ route }) {
|
||||
return (
|
||||
<span className="route-badge" style={{ backgroundColor: route.color || '#1677ff' }}>
|
||||
{route.route_number}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -2,9 +2,80 @@
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
:root {
|
||||
--control-radius: 8px;
|
||||
--page-title-size: 38px;
|
||||
--page-title-line-height: 48px;
|
||||
}
|
||||
@media (max-width: 1199px) {
|
||||
:root { --page-title-size: 30px; --page-title-line-height: 40px; }
|
||||
}
|
||||
@media (max-width: 767px) {
|
||||
:root { --page-title-size: 28px; --page-title-line-height: 36px; }
|
||||
}
|
||||
|
||||
/* Dodo IS reference: 264px sidebar, neutral surfaces, 8–12px corners. */
|
||||
@layer components {
|
||||
.app-shell { min-height: 100vh; }
|
||||
.app-sidebar {
|
||||
@apply sticky top-0 z-40 flex flex-col;
|
||||
background: #f0f0f0;
|
||||
}
|
||||
.sidebar-brand-row { @apply flex items-center justify-between px-4 py-3; }
|
||||
.sidebar-brand { @apply flex items-center gap-2 text-lg font-bold text-gray-900; }
|
||||
.brand-logo { display: block; width: 168px; height: 34px; object-fit: contain; }
|
||||
.brand-mark { @apply flex h-8 w-8 shrink-0 items-center justify-center rounded-lg; background: #ff6900; }
|
||||
.sidebar-menu-button { @apply flex h-11 w-11 items-center justify-center rounded-lg hover:bg-black/[0.04] lg:hidden; }
|
||||
.sidebar-content { @apply hidden min-h-0 flex-col px-4 pb-4; max-height: calc(100dvh - 68px); overflow-y: auto; }
|
||||
.sidebar-content.is-open { display: flex; }
|
||||
.sidebar-account { @apply flex items-center justify-between gap-3 rounded-xl bg-white px-3 py-2 text-sm; min-height: 56px; }
|
||||
.sidebar-group-title { @apply px-3 pb-2 pt-5 text-xs font-normal uppercase tracking-wide text-gray-500; }
|
||||
.sidebar-link { @apply flex min-h-[44px] items-center gap-3 rounded-lg px-3 py-3 text-sm text-gray-600 transition-colors hover:bg-black/[0.04]; }
|
||||
.sidebar-link.is-active { @apply bg-black/[0.04] font-semibold text-gray-900; }
|
||||
.sidebar-footer { @apply mt-auto pt-8; }
|
||||
.app-main { @apply min-w-0 px-4 py-6 sm:px-6 sm:py-8; }
|
||||
.app-content { @apply mx-auto w-full; max-width: 1206px; }
|
||||
.app-main.app-main-wide { padding: 24px; }
|
||||
.app-main-wide .app-content { max-width: 1608px; }
|
||||
.profile-menu { position: fixed; z-index: 60; padding: 8px; background: #fff; border-radius: 12px; box-shadow: 0 12px 32px #00000026, 0 2px 8px #0000000d; }
|
||||
.profile-menu-item { @apply flex w-full items-center gap-4 px-4 py-4 text-left text-sm text-gray-900 hover:bg-gray-50; border-radius: var(--control-radius); min-height: 56px; }
|
||||
.profile-menu-item svg { flex-shrink: 0; }
|
||||
.profile-menu-divider { margin: 4px 16px 4px 56px; height: 1px; background: #f0f0f0; }
|
||||
.route-badge { display: flex; flex: 0 0 auto; align-items: center; justify-content: center; min-width: 48px; max-width: 96px; min-height: 48px; padding: 8px; border-radius: var(--control-radius); color: #fff; font-size: 16px; font-weight: 600; line-height: 24px; overflow-wrap: anywhere; }
|
||||
.skip-link { @apply fixed left-4 top-4 z-50 -translate-y-24 rounded-lg bg-white px-4 py-3 text-primary-600 focus:translate-y-0; }
|
||||
.segmented-control { @apply flex w-fit max-w-full flex-wrap gap-1 rounded-lg bg-black/[0.04] p-1; }
|
||||
.segmented-option { @apply min-h-[44px] rounded-md px-3 py-2 text-sm text-gray-600 transition-colors hover:text-gray-900; }
|
||||
.segmented-option[aria-pressed="true"] { @apply bg-white font-medium text-gray-900 shadow-sm; }
|
||||
.stat-card { @apply flex h-full flex-col; }
|
||||
.stat-card-icon { @apply flex h-10 w-10 items-center justify-center rounded-lg border border-gray-200 text-gray-600; }
|
||||
.stat-card-link { @apply mt-auto flex items-center justify-between border-t border-gray-100 pt-3 text-sm text-primary-600; }
|
||||
.dashboard-intro { @apply flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between; }
|
||||
.login-surface { @apply flex min-h-screen items-center justify-center p-4; background: #f5f5f5; }
|
||||
.login-card { @apply rounded-xl bg-white p-6 sm:p-8; }
|
||||
@media (max-width: 767px) {
|
||||
.app-main.app-main-wide { padding: 24px 16px; }
|
||||
}
|
||||
@media (min-width: 1024px) {
|
||||
.app-sidebar { position: fixed; inset: 0 auto 0 0; width: 264px; padding: 16px; }
|
||||
.sidebar-brand-row { padding: 8px 8px 24px; }
|
||||
.sidebar-content { display: flex; flex: 1; max-height: none; padding: 0; }
|
||||
.app-main { margin-left: 264px; padding: 56px 40px; }
|
||||
}
|
||||
@media (min-width: 1536px) {
|
||||
.app-main { padding-inline: 80px; }
|
||||
}
|
||||
}
|
||||
|
||||
@layer base {
|
||||
button { border-radius: var(--control-radius); }
|
||||
:where(a, button, input, select, textarea):focus-visible {
|
||||
outline: 2px solid #0958d9;
|
||||
outline-offset: 3px;
|
||||
}
|
||||
button:disabled { cursor: not-allowed; }
|
||||
body {
|
||||
@apply bg-gray-50 text-gray-900;
|
||||
@apply text-gray-900;
|
||||
background: #f5f5f5;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
@@ -22,18 +93,18 @@
|
||||
|
||||
@layer components {
|
||||
.btn-primary {
|
||||
@apply bg-primary-600 text-white px-4 py-2.5 rounded-lg font-medium;
|
||||
@apply bg-primary-500 text-white px-4 py-2.5 rounded-lg font-medium;
|
||||
@apply transition-all duration-200 ease-out;
|
||||
@apply hover:bg-primary-700 hover:shadow-md hover:-translate-y-0.5;
|
||||
@apply active:scale-95 active:translate-y-0;
|
||||
@apply hover:bg-primary-700;
|
||||
@apply active:scale-[0.98];
|
||||
@apply text-sm sm:text-base;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
@apply bg-gray-200 text-gray-700 px-4 py-2.5 rounded-lg font-medium;
|
||||
@apply bg-black/[0.04] text-gray-900 px-4 py-2.5 rounded-lg font-medium;
|
||||
@apply transition-all duration-200 ease-out;
|
||||
@apply hover:bg-gray-300 hover:shadow-sm hover:-translate-y-0.5;
|
||||
@apply active:scale-95 active:translate-y-0;
|
||||
@apply hover:bg-black/[0.08];
|
||||
@apply active:scale-[0.98];
|
||||
@apply text-sm sm:text-base;
|
||||
}
|
||||
|
||||
@@ -41,17 +112,19 @@
|
||||
@apply bg-red-600 text-white px-4 py-2.5 rounded-lg font-medium;
|
||||
@apply transition-all duration-200 ease-out;
|
||||
@apply hover:bg-red-700 hover:shadow-md hover:-translate-y-0.5;
|
||||
@apply active:scale-95 active:translate-y-0;
|
||||
@apply active:scale-[0.98];
|
||||
@apply text-sm sm:text-base;
|
||||
}
|
||||
|
||||
.btn-primary, .btn-secondary, .btn-danger, .input { border-radius: var(--control-radius); }
|
||||
|
||||
.card {
|
||||
@apply bg-white rounded-lg shadow-sm border border-gray-200 p-4 sm:p-6;
|
||||
@apply bg-white rounded-xl border border-transparent p-4 sm:p-6;
|
||||
@apply transition-all duration-300 ease-out;
|
||||
}
|
||||
|
||||
.card-hover {
|
||||
@apply hover:shadow-lg hover:-translate-y-1 hover:border-gray-300;
|
||||
@apply hover:border-gray-200;
|
||||
}
|
||||
|
||||
.input {
|
||||
@@ -72,7 +145,11 @@
|
||||
|
||||
/* Page titles */
|
||||
.page-title {
|
||||
@apply text-2xl sm:text-3xl font-bold text-gray-900;
|
||||
@apply font-semibold text-gray-900;
|
||||
font-size: var(--page-title-size);
|
||||
line-height: var(--page-title-line-height);
|
||||
letter-spacing: -0.6px;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
/* Modal styles with animations */
|
||||
@@ -258,7 +335,7 @@
|
||||
}
|
||||
|
||||
.quick-action-icon {
|
||||
@apply w-10 h-10 rounded-full flex items-center justify-center mb-1;
|
||||
@apply w-10 h-10 rounded-lg flex items-center justify-center mb-1;
|
||||
@apply transition-transform duration-200;
|
||||
}
|
||||
|
||||
|
||||
97
frontend/src/pages/Dashboard.css
Normal file
97
frontend/src/pages/Dashboard.css
Normal file
@@ -0,0 +1,97 @@
|
||||
/* Main content of Figma 5:4392; shared application navigation stays in AppLayout. */
|
||||
.app-main.app-main-dashboard { padding: 24px; }
|
||||
.app-main-dashboard .app-content { max-width: 1608px; }
|
||||
.dashboard-page { display: flex; flex-direction: column; gap: 24px; color: rgba(0, 0, 0, .88); }
|
||||
.dashboard-heading { display: flex; align-items: center; justify-content: space-between; gap: 24px; margin-bottom: 12px; }
|
||||
.dashboard-date { color: #818181; }
|
||||
.dashboard-refresh { display: flex; align-items: center; justify-content: center; flex-shrink: 0; gap: 8px; min-height: 48px; font-size: 14px; font-weight: 400; }
|
||||
.dashboard-summary { position: relative; overflow: hidden; isolation: isolate; border-radius: 16px; background: #2082c4; padding: 20px 24px 24px; }
|
||||
.dashboard-summary-art { position: absolute; z-index: -1; width: 67.16%; height: 275%; max-width: none; object-fit: fill; left: 16.42%; top: -87.4%; pointer-events: none; }
|
||||
.dashboard-summary-heading { display: flex; align-items: center; justify-content: space-between; gap: 16px; margin-bottom: 28px; color: white; }
|
||||
.dashboard-summary-heading h2 { font-size: 20px; font-weight: 600; line-height: 28px; }
|
||||
.dashboard-summary-heading > span { font-size: 12px; color: #fff; background: #2082c4; padding: 2px 6px; border-radius: 4px; }
|
||||
.dashboard-summary-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 20px; }
|
||||
.dashboard-summary-card { background: white; border-radius: 12px; padding: 16px 20px; min-width: 0; min-height: 180px; }
|
||||
.dashboard-summary-card-heading { display: flex; justify-content: space-between; align-items: center; gap: 16px; padding-bottom: 16px; }
|
||||
.dashboard-summary-card-heading h3, .dashboard-muted { color: #737373; font-size: 12px; font-weight: 400; line-height: 18px; }
|
||||
.dashboard-summary-card-heading p { font-size: 20px; line-height: 28px; font-variant-numeric: tabular-nums; }
|
||||
.dashboard-summary-icon { display: flex; flex-shrink: 0; align-items: center; justify-content: center; width: 48px; height: 48px; border-radius: 12px; background: rgba(0, 0, 0, .06); }
|
||||
.dashboard-summary-card-body { border-top: 1px solid #f0f0f0; padding-top: 14px; font-size: 13px; line-height: 20px; }
|
||||
.dashboard-compact-metrics > div { display: flex; justify-content: space-between; gap: 12px; }
|
||||
.dashboard-compact-metrics > div + div { margin-top: 4px; }
|
||||
.dashboard-inline-link { display: inline-flex; align-items: center; margin-top: 4px; color: #0958d9; }
|
||||
.dashboard-panel-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 24px; }
|
||||
.dashboard-panel.card { display: flex; flex-direction: column; min-width: 0; padding: 0; min-height: 345px; overflow: hidden; }
|
||||
.dashboard-panel h2, .dashboard-network h2 { padding: 24px 24px 12px; font-size: 20px; font-weight: 600; line-height: 28px; }
|
||||
.dashboard-panel-body { padding: 24px; flex: 1; display: flex; flex-direction: column; justify-content: center; }
|
||||
.dashboard-panel-footer { display: flex; align-items: center; min-height: 64px; padding: 16px 24px; border-top: 1px solid #f0f0f0; font-size: 14px; color: #0958d9; }
|
||||
.dashboard-panel-footer:hover { background: #fafafa; }
|
||||
.dashboard-total { display: flex; flex-direction: column; }
|
||||
.dashboard-total strong { font-size: 30px; font-weight: 600; line-height: 40px; font-variant-numeric: tabular-nums; }
|
||||
.dashboard-metric-row { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 16px; margin-top: 16px; }
|
||||
.dashboard-metric-row dd { font-size: 16px; line-height: 24px; }
|
||||
.dashboard-detail-row { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-top: 16px; padding: 9px 16px; border: 1px solid #ededed; border-radius: 8px; font-size: 14px; }
|
||||
.dashboard-detail-row svg { flex-shrink: 0; }
|
||||
.dashboard-detail-row:hover { background: #fafafa; }
|
||||
.dashboard-empty { flex: 1; display: flex; flex-direction: column; justify-content: center; align-items: center; text-align: center; gap: 20px; padding: 16px; min-height: 168px; font-size: 14px; color: #737373; }
|
||||
.dashboard-empty svg { color: #bfbfbf; flex-shrink: 0; }
|
||||
.dashboard-network { display: grid; grid-template-columns: minmax(220px, 25%) minmax(0, 1fr); background: #fff; border-radius: 12px; min-height: 304px; }
|
||||
.dashboard-network-summary { border-right: 1px solid #f0f0f0; }
|
||||
.dashboard-network-summary dl { padding: 8px 24px 24px; display: grid; gap: 14px; }
|
||||
.dashboard-network-summary dd { font-size: 16px; line-height: 24px; }
|
||||
.dashboard-chart { position: relative; min-width: 0; padding: 32px 24px 20px; display: flex; flex-direction: column; }
|
||||
.dashboard-chart-plot { height: 200px; display: flex; align-items: stretch; position: relative; }
|
||||
.dashboard-chart-guides { display: flex; flex-direction: column; justify-content: space-between; position: absolute; inset: 0 0 34px; }
|
||||
.dashboard-chart-guides span { border-top: 1px solid #f0f0f0; width: 100%; }
|
||||
.dashboard-chart-column { position: relative; flex: 1; min-width: 0; display: flex; flex-direction: column; justify-content: flex-end; }
|
||||
.dashboard-chart-bars { display: flex; align-items: flex-end; justify-content: center; gap: 8px; height: calc(100% - 34px); }
|
||||
.dashboard-chart-bar { position: relative; width: min(28%, 36px); background: #1677ff; border-radius: 5px 5px 0 0; }
|
||||
.dashboard-chart-bar span { position: absolute; left: 50%; bottom: 100%; transform: translateX(-50%); font-size: 12px; line-height: 20px; }
|
||||
.dashboard-chart-label { font-size: 12px; text-align: center; line-height: 16px; min-height: 34px; padding-top: 10px; overflow-wrap: anywhere; }
|
||||
.dashboard-chart-legend { display: flex; flex-wrap: wrap; gap: 12px 24px; margin-top: 18px; font-size: 12px; color: #737373; }
|
||||
.dashboard-chart-legend span { display: inline-flex; align-items: center; gap: 8px; }
|
||||
.dashboard-chart-legend i { width: 8px; height: 8px; border-radius: 50%; background: #1677ff; }
|
||||
.dashboard-chart-bar.is-secondary, .dashboard-chart-legend .is-secondary { background: #faad14; }
|
||||
.dashboard-route-list, .dashboard-holiday-list { display: flex; flex-direction: column; gap: 12px; }
|
||||
.dashboard-route-row, .dashboard-holiday-row { display: flex; align-items: center; gap: 12px; font-size: 14px; padding: 4px 0; }
|
||||
.dashboard-route-row:hover { color: #0958d9; }
|
||||
.dashboard-route-number { flex-shrink: 0; display: flex; align-items: center; justify-content: center; min-width: 40px; max-width: 96px; min-height: 40px; padding: 4px 6px; border-radius: 8px; color: #fff; font-weight: 600; overflow-wrap: anywhere; }
|
||||
.dashboard-calendar-date { flex-shrink: 0; width: 48px; min-height: 52px; display: flex; flex-direction: column; align-items: center; justify-content: center; border-radius: 8px; background: #f5f5f5; }
|
||||
.dashboard-calendar-date strong { font-size: 20px; font-weight: 600; }
|
||||
.dashboard-calendar-date > span { font-size: 12px; color: #737373; }
|
||||
.dashboard-actions { display: flex; flex-wrap: wrap; gap: 12px; }
|
||||
.dashboard-action { display: flex; align-items: center; justify-content: center; gap: 8px; min-height: 44px; padding: 10px 16px; background: #fff; border: 1px solid #d9d9d9; border-radius: 8px; font-size: 14px; }
|
||||
.dashboard-action:hover { border-color: #1677ff; color: #0958d9; }
|
||||
.dashboard-notice { display: flex; align-items: center; gap: 12px; padding: 14px 16px; border-radius: 8px; font-size: 14px; }
|
||||
.dashboard-notice svg { flex-shrink: 0; }
|
||||
.dashboard-notice-error { background: #fff1f0; color: #a8071a; border: 1px solid #ffccc7; }
|
||||
.dashboard-notice button { text-decoration: underline; margin-left: auto; min-height: 44px; }
|
||||
@media (max-width: 1199px) {
|
||||
.dashboard-summary-grid { grid-template-columns: 1fr; gap: 12px; }
|
||||
.dashboard-summary-card { min-height: 0; }
|
||||
.dashboard-summary-art { width: 110%; left: 0; height: 100%; top: 0; object-fit: cover; }
|
||||
.dashboard-network { grid-template-columns: 1fr; }
|
||||
.dashboard-network-summary { border-right: 0; border-bottom: 1px solid #f0f0f0; }
|
||||
.dashboard-network-summary dl { grid-template-columns: repeat(3, minmax(0, 1fr)); }
|
||||
}
|
||||
@media (max-width: 767px) {
|
||||
.app-main.app-main-dashboard { padding: 24px 16px; }
|
||||
.dashboard-heading { flex-direction: column; align-items: flex-start; gap: 16px; margin-bottom: 0; }
|
||||
.dashboard-date { display: block; }
|
||||
.dashboard-refresh { align-self: flex-start; }
|
||||
.dashboard-summary { padding: 16px; }
|
||||
.dashboard-summary-heading { align-items: flex-start; flex-direction: column; gap: 4px; margin-bottom: 20px; }
|
||||
.dashboard-summary-heading > span { padding-left: 0; }
|
||||
.dashboard-summary-card { padding: 16px; }
|
||||
.dashboard-panel-grid { grid-template-columns: 1fr; }
|
||||
.dashboard-panel h2, .dashboard-network h2 { padding: 20px 20px 8px; }
|
||||
.dashboard-panel-body { padding: 20px; }
|
||||
.dashboard-panel-footer { padding-inline: 20px; }
|
||||
.dashboard-metric-row { gap: 12px; }
|
||||
.dashboard-network-summary dl { padding-inline: 20px; gap: 12px; }
|
||||
.dashboard-chart { padding-inline: 16px; }
|
||||
.dashboard-chart-bars { gap: 4px; }
|
||||
.dashboard-notice { flex-wrap: wrap; }
|
||||
.dashboard-notice > span { flex: 1; min-width: 160px; }
|
||||
.dashboard-actions { flex-direction: column; }
|
||||
}
|
||||
@@ -1,554 +1,226 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import {
|
||||
Bus, MapPin, Calendar, Activity, Clock, TrendingUp,
|
||||
ArrowRight, AlertCircle, Gift, RefreshCw, ChevronRight,
|
||||
Zap, BarChart3, PieChart
|
||||
} from 'lucide-react';
|
||||
import { routesApi, stopsApi, syncApi, holidaysApi, schedulesApi, alertsApi } from '../services/api';
|
||||
import { PageLoader, PulsingDot } from '../components/AnimatedComponents';
|
||||
import { Bus, MapPin, Calendar, Activity, AlertCircle, Gift, RefreshCw, ChevronRight, Info } from 'lucide-react';
|
||||
import { routesApi, stopsApi, syncApi, holidaysApi, alertsApi } from '../services/api';
|
||||
import { PageLoader } from '../components/AnimatedComponents';
|
||||
import { AlertList } from '../components/AlertComponents';
|
||||
import dashboardBackground from '../assets/dashboard-background.png';
|
||||
import './Dashboard.css';
|
||||
|
||||
const TIME_ZONE = 'Europe/Minsk';
|
||||
const numberFormat = new Intl.NumberFormat('ru-RU');
|
||||
const routeTypes = [
|
||||
{ key: 'bus', label: 'Автобусы' },
|
||||
{ key: 'minibus', label: 'Маршрутки' },
|
||||
{ key: 'trolleybus', label: 'Троллейбусы' },
|
||||
{ key: 'tram', label: 'Трамваи' },
|
||||
];
|
||||
const dataSources = [
|
||||
['routes', () => routesApi.getAll()],
|
||||
['stops', () => stopsApi.getAll()],
|
||||
['sync', () => syncApi.getStatus()],
|
||||
['holidays', () => holidaysApi.getUpcoming(3)],
|
||||
['alerts', () => alertsApi.getActive()],
|
||||
];
|
||||
const formatCount = value => value == null ? '—' : numberFormat.format(value);
|
||||
function formatDate(value, options) {
|
||||
if (!value) return 'Нет данных';
|
||||
const date = new Date(value);
|
||||
return Number.isNaN(date.getTime()) ? 'Нет данных' : date.toLocaleString('ru-RU', { timeZone: TIME_ZONE, ...options });
|
||||
}
|
||||
|
||||
export default function Dashboard() {
|
||||
const [stats, setStats] = useState({
|
||||
routes: 0,
|
||||
stops: 0,
|
||||
schedules: 0,
|
||||
lastSync: null
|
||||
});
|
||||
const [data, setData] = useState({ routes: null, stops: null, sync: null, holidays: null, alerts: null });
|
||||
const [failed, setFailed] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [routes, setRoutes] = useState([]);
|
||||
const [upcomingHolidays, setUpcomingHolidays] = useState([]);
|
||||
const [activeAlerts, setActiveAlerts] = useState([]);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
const [updatedAt, setUpdatedAt] = useState(null);
|
||||
const [today, setToday] = useState(() => new Date());
|
||||
const requestId = useRef(0);
|
||||
|
||||
const loadData = useCallback(async () => {
|
||||
const id = ++requestId.current;
|
||||
setLoading(true);
|
||||
const results = await Promise.allSettled(dataSources.map(([, fetch]) => fetch()));
|
||||
if (id !== requestId.current) return;
|
||||
const errors = [];
|
||||
const received = {};
|
||||
results.forEach((result, index) => {
|
||||
const key = dataSources[index][0];
|
||||
if (result.status === 'fulfilled') received[key] = result.value.data;
|
||||
else errors.push(key);
|
||||
});
|
||||
// Preserve the last successful snapshot if a refresh fails.
|
||||
setData(previous => ({ ...previous, ...received }));
|
||||
setFailed(errors);
|
||||
if (!errors.length) setUpdatedAt(new Date());
|
||||
setLoaded(true);
|
||||
setLoading(false);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
}, []);
|
||||
const timer = setInterval(() => setToday(new Date()), 60_000);
|
||||
return () => { requestId.current += 1; clearInterval(timer); };
|
||||
}, [loadData]);
|
||||
|
||||
const loadData = async () => {
|
||||
try {
|
||||
const [routesRes, stopsRes, syncRes, holidaysRes, alertsRes] = await Promise.all([
|
||||
routesApi.getAll(),
|
||||
stopsApi.getAll(),
|
||||
syncApi.getStatus(),
|
||||
holidaysApi.getUpcoming(3).catch(() => ({ data: [] })),
|
||||
alertsApi.getActive().catch(() => ({ data: [] }))
|
||||
]);
|
||||
|
||||
setRoutes(routesRes.data);
|
||||
setUpcomingHolidays(holidaysRes.data);
|
||||
setActiveAlerts(alertsRes.data);
|
||||
|
||||
setStats({
|
||||
routes: routesRes.data.length,
|
||||
stops: stopsRes.data.length,
|
||||
schedules: syncRes.data.database.schedules_count,
|
||||
lastSync: syncRes.data.database.last_change
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error loading stats:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRefresh = async () => {
|
||||
setRefreshing(true);
|
||||
await loadData();
|
||||
setTimeout(() => setRefreshing(false), 500);
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return <PageLoader />;
|
||||
}
|
||||
|
||||
// Calculate schedule coverage
|
||||
const routesWithSchedules = routes.filter(r => r.schedules_count > 0).length;
|
||||
const scheduleCoverage = routes.length > 0
|
||||
? Math.round((routesWithSchedules / routes.length) * 100)
|
||||
: 0;
|
||||
|
||||
// Calculate total trips today (estimated)
|
||||
const estimatedTripsToday = stats.schedules * 15; // Примерная оценка
|
||||
const routes = data.routes || [];
|
||||
const stops = data.stops || [];
|
||||
const alerts = data.alerts || [];
|
||||
const holidays = data.holidays || [];
|
||||
const withStops = routes.filter(route => Number(route.stops_count) > 0).length;
|
||||
const assignedStops = stops.filter(stop => Number(stop.routes_count) > 0).length;
|
||||
const multipleRoutes = stops.filter(stop => Number(stop.routes_count) > 1).length;
|
||||
const ready = data.sync?.status === 'ready' && failed.length === 0;
|
||||
const dayLabel = today.toLocaleDateString('ru-RU', { timeZone: TIME_ZONE, weekday: 'short', day: 'numeric', month: 'long' });
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="page-title">Dashboard</h1>
|
||||
<p className="text-sm text-gray-500 mt-1">
|
||||
Обзор системы общественного транспорта Полоцка
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleRefresh}
|
||||
className={`btn-secondary flex items-center justify-center ${refreshing ? 'opacity-70' : ''}`}
|
||||
disabled={refreshing}
|
||||
>
|
||||
<RefreshCw className={`h-4 w-4 mr-2 ${refreshing ? 'animate-spin' : ''}`} />
|
||||
Обновить
|
||||
<div className="dashboard-page">
|
||||
<header className="dashboard-heading">
|
||||
<h1 className="page-title"><span>Сегодня</span> <span className="dashboard-date">{dayLabel}</span></h1>
|
||||
<button type="button" onClick={loadData} className="btn-secondary dashboard-refresh" disabled={loading}>
|
||||
<RefreshCw size={16} className={loading ? 'animate-spin' : ''} aria-hidden="true" />
|
||||
{loading ? 'Обновление…' : 'Обновить данные'}
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Main Stats Grid */}
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3 sm:gap-4">
|
||||
<StatCard
|
||||
icon={<Bus className="h-5 w-5 sm:h-6 sm:w-6" />}
|
||||
title="Маршруты"
|
||||
value={stats.routes}
|
||||
subtitle={`${routesWithSchedules} с расписанием`}
|
||||
color="blue"
|
||||
trend={stats.routes > 0 ? '+' + stats.routes : null}
|
||||
link="/routes"
|
||||
delay={0}
|
||||
/>
|
||||
<StatCard
|
||||
icon={<MapPin className="h-5 w-5 sm:h-6 sm:w-6" />}
|
||||
title="Остановки"
|
||||
value={stats.stops}
|
||||
subtitle="по всему городу"
|
||||
color="green"
|
||||
link="/stops"
|
||||
delay={1}
|
||||
/>
|
||||
<StatCard
|
||||
icon={<Calendar className="h-5 w-5 sm:h-6 sm:w-6" />}
|
||||
title="Расписания"
|
||||
value={stats.schedules}
|
||||
subtitle={`${scheduleCoverage}% покрытие`}
|
||||
color="purple"
|
||||
link="/schedules"
|
||||
delay={2}
|
||||
/>
|
||||
<StatCard
|
||||
icon={<Zap className="h-5 w-5 sm:h-6 sm:w-6" />}
|
||||
title="Рейсов/день"
|
||||
value={estimatedTripsToday}
|
||||
subtitle="примерная оценка"
|
||||
color="amber"
|
||||
delay={3}
|
||||
/>
|
||||
</div>
|
||||
{failed.length > 0 && (
|
||||
<div className="dashboard-notice dashboard-notice-error" role="alert">
|
||||
<AlertCircle size={20} aria-hidden="true" />
|
||||
<span>Не удалось обновить часть данных. Доступные сведения могут быть устаревшими.</span>
|
||||
<button type="button" onClick={loadData} disabled={loading}>Повторить</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Charts Row */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4 sm:gap-6">
|
||||
{/* Routes by Type Chart */}
|
||||
<div className="card card-hover list-item-animated stagger-4">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-base sm:text-lg font-semibold flex items-center">
|
||||
<PieChart className="h-5 w-5 mr-2 text-primary-600" />
|
||||
Маршруты по типам
|
||||
</h2>
|
||||
{!loaded ? <PageLoader /> : <>
|
||||
<section className="dashboard-summary" aria-labelledby="summary-title">
|
||||
<div className="dashboard-summary-heading">
|
||||
<h2 id="summary-title">Транспорт Полоцка</h2>
|
||||
<span>{updatedAt ? `Обновлено в ${formatDate(updatedAt, { hour: '2-digit', minute: '2-digit' })}` : 'Ожидаем актуальные данные'}</span>
|
||||
</div>
|
||||
<RoutesByTypeChart routes={routes} />
|
||||
<div className="dashboard-summary-grid">
|
||||
<SummaryCard title="Маршрутов в сети" value={formatCount(data.routes?.length)} icon={Bus}>
|
||||
<dl className="dashboard-compact-metrics">
|
||||
<div><dt>С остановками</dt><dd>{data.routes ? formatCount(withStops) : '—'}</dd></div>
|
||||
<div><dt>Без остановок</dt><dd>{data.routes ? formatCount(routes.length - withStops) : '—'}</dd></div>
|
||||
</dl>
|
||||
</SummaryCard>
|
||||
<SummaryCard title="Расписаний в системе" value={formatCount(data.sync?.database?.schedules_count)} icon={Calendar}>
|
||||
<p className="dashboard-muted">Последнее изменение</p>
|
||||
<p>{formatDate(data.sync?.database?.last_change, { day: 'numeric', month: 'long', hour: '2-digit', minute: '2-digit' })}</p>
|
||||
</SummaryCard>
|
||||
<SummaryCard title="Доступность данных" value={ready ? 'Данные доступны' : 'Требуется проверка'} icon={Activity}>
|
||||
<p>{ready ? 'Сводка успешно загружена.' : 'Не все данные удалось получить.'}</p>
|
||||
<Link className="dashboard-inline-link" to="/schedules">Управление расписаниями <ChevronRight size={14} aria-hidden="true" /></Link>
|
||||
</SummaryCard>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="dashboard-panel-grid">
|
||||
<DashboardPanel title="Остановки" to="/stops" linkLabel="Все остановки">
|
||||
<div className="dashboard-total"><span className="dashboard-muted">Всего остановок в городе</span><strong>{formatCount(data.stops?.length)}</strong></div>
|
||||
<dl className="dashboard-metric-row">
|
||||
<Metric label="На маршрутах" value={data.stops ? formatCount(assignedStops) : '—'} />
|
||||
<Metric label="Несколько маршрутов" value={data.stops ? formatCount(multipleRoutes) : '—'} />
|
||||
<Metric label="Без маршрутов" value={data.stops ? formatCount(stops.length - assignedStops) : '—'} />
|
||||
</dl>
|
||||
<Link to="/stops" className="dashboard-detail-row">Расположение и привязка к маршрутам <ChevronRight size={16} aria-hidden="true" /></Link>
|
||||
</DashboardPanel>
|
||||
<DashboardPanel title="Активные уведомления" to="/alerts" linkLabel="Все уведомления">
|
||||
{failed.includes('alerts') || !data.alerts ? <PanelEmpty text="Не удалось загрузить уведомления" /> : alerts.length ? (
|
||||
<AlertList alerts={alerts.slice(0, 2)} />
|
||||
) : <PanelEmpty text="Нет активных уведомлений" />}
|
||||
</DashboardPanel>
|
||||
</div>
|
||||
|
||||
{/* Schedule Coverage Chart */}
|
||||
<div className="card card-hover list-item-animated stagger-5">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-base sm:text-lg font-semibold flex items-center">
|
||||
<BarChart3 className="h-5 w-5 mr-2 text-primary-600" />
|
||||
Покрытие расписаниями
|
||||
</h2>
|
||||
<section className="dashboard-network" aria-labelledby="network-title">
|
||||
<div className="dashboard-network-summary">
|
||||
<h2 id="network-title">Маршрутная сеть</h2>
|
||||
<dl>
|
||||
<Metric label="Всего маршрутов" value={formatCount(data.routes?.length)} />
|
||||
<Metric label="С остановками" value={data.routes ? formatCount(withStops) : '—'} />
|
||||
<Metric label="Без остановок" value={data.routes ? formatCount(routes.length - withStops) : '—'} />
|
||||
</dl>
|
||||
</div>
|
||||
<ScheduleCoverageChart routes={routes} />
|
||||
</div>
|
||||
</div>
|
||||
<RoutesChart routes={routes} unavailable={!data.routes} />
|
||||
</section>
|
||||
|
||||
{/* Quick Actions & Info Row */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4 sm:gap-6">
|
||||
{/* Quick Actions */}
|
||||
<div className="card list-item-animated stagger-4">
|
||||
<h2 className="text-base sm:text-lg font-semibold mb-4">Быстрые действия</h2>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<QuickAction
|
||||
icon={<Bus className="h-5 w-5" />}
|
||||
label="Новый маршрут"
|
||||
color="blue"
|
||||
to="/routes"
|
||||
/>
|
||||
<QuickAction
|
||||
icon={<MapPin className="h-5 w-5" />}
|
||||
label="Добавить остановку"
|
||||
color="green"
|
||||
to="/stops"
|
||||
/>
|
||||
<QuickAction
|
||||
icon={<Calendar className="h-5 w-5" />}
|
||||
label="Расписание"
|
||||
color="purple"
|
||||
to="/schedules"
|
||||
/>
|
||||
<QuickAction
|
||||
icon={<Gift className="h-5 w-5" />}
|
||||
label="Праздники"
|
||||
color="amber"
|
||||
to="/holidays"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Top Routes */}
|
||||
<div className="card list-item-animated stagger-5">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-base sm:text-lg font-semibold">Топ маршрутов</h2>
|
||||
<Link to="/routes" className="text-sm text-primary-600 hover:underline flex items-center">
|
||||
Все <ChevronRight className="h-4 w-4" />
|
||||
</Link>
|
||||
</div>
|
||||
{routes.length > 0 ? (
|
||||
<div className="space-y-3">
|
||||
{routes.slice(0, 4).map((route, index) => (
|
||||
<Link
|
||||
key={route.id}
|
||||
to={`/routes/${route.id}/edit`}
|
||||
className="flex items-center p-2 rounded-lg hover:bg-gray-50 transition-colors group"
|
||||
>
|
||||
<div
|
||||
className="w-8 h-8 rounded-lg flex items-center justify-center text-white font-bold text-sm mr-3 transition-transform group-hover:scale-110"
|
||||
style={{ backgroundColor: route.color }}
|
||||
>
|
||||
{route.route_number}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-medium text-gray-900 text-sm truncate">{route.name}</p>
|
||||
<p className="text-xs text-gray-500">{route.stops_count || 0} остановок</p>
|
||||
</div>
|
||||
<ChevronRight className="h-4 w-4 text-gray-400 group-hover:text-gray-600 transition-colors" />
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-4 text-gray-500 text-sm">
|
||||
Нет маршрутов
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Upcoming Holidays */}
|
||||
<div className="card list-item-animated stagger-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-base sm:text-lg font-semibold">Ближайшие праздники</h2>
|
||||
<Link to="/holidays" className="text-sm text-primary-600 hover:underline flex items-center">
|
||||
Все <ChevronRight className="h-4 w-4" />
|
||||
</Link>
|
||||
</div>
|
||||
{upcomingHolidays.length > 0 ? (
|
||||
<div className="space-y-3">
|
||||
{upcomingHolidays.map((holiday, index) => {
|
||||
const date = new Date(holiday.next_date);
|
||||
const daysUntil = Math.ceil((date - new Date()) / (1000 * 60 * 60 * 24));
|
||||
|
||||
return (
|
||||
<div key={index} className="flex items-center p-2 bg-amber-50 rounded-lg">
|
||||
<div className="w-12 h-12 bg-amber-100 rounded-lg flex flex-col items-center justify-center mr-3">
|
||||
<span className="text-xs text-amber-600 font-medium">
|
||||
{date.toLocaleDateString('ru-RU', { month: 'short' })}
|
||||
</span>
|
||||
<span className="text-lg font-bold text-amber-700">
|
||||
{date.getDate()}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-medium text-gray-900 text-sm truncate">{holiday.name}</p>
|
||||
<p className="text-xs text-amber-600">
|
||||
{daysUntil === 0 ? 'Сегодня!' :
|
||||
daysUntil === 1 ? 'Завтра' :
|
||||
`Через ${daysUntil} дн.`}
|
||||
</p>
|
||||
</div>
|
||||
<AlertCircle className="h-4 w-4 text-amber-500" />
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-4 text-gray-500 text-sm">
|
||||
Нет предстоящих праздников
|
||||
</div>
|
||||
)}
|
||||
<p className="text-xs text-gray-500 mt-3 flex items-center">
|
||||
<AlertCircle className="h-3 w-3 mr-1" />
|
||||
В праздники транспорт по расписанию воскресенья
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Active Alerts */}
|
||||
{activeAlerts.length > 0 && (
|
||||
<div className="card list-item-animated bg-gradient-to-br from-amber-50 to-orange-50 border-amber-200">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-base sm:text-lg font-semibold flex items-center">
|
||||
<AlertCircle className="h-5 w-5 mr-2 text-amber-600" />
|
||||
Активные уведомления
|
||||
<span className="ml-2 px-2 py-0.5 bg-amber-600 text-white text-xs rounded-full">
|
||||
{activeAlerts.length}
|
||||
</span>
|
||||
</h2>
|
||||
<Link to="/alerts" className="text-sm text-amber-700 hover:underline flex items-center font-medium">
|
||||
Управление <ChevronRight className="h-4 w-4" />
|
||||
</Link>
|
||||
</div>
|
||||
<AlertList
|
||||
alerts={activeAlerts.slice(0, 3)}
|
||||
emptyMessage="Нет активных уведомлений"
|
||||
/>
|
||||
{activeAlerts.length > 3 && (
|
||||
<div className="mt-3 pt-3 border-t border-amber-200">
|
||||
<Link
|
||||
to="/alerts"
|
||||
className="text-sm text-amber-700 hover:underline flex items-center justify-center"
|
||||
>
|
||||
Показать все ({activeAlerts.length}) <ArrowRight className="h-4 w-4 ml-1" />
|
||||
<div className="dashboard-panel-grid">
|
||||
<DashboardPanel title="Маршруты" to="/routes" linkLabel="Все маршруты">
|
||||
{routes.length ? <div className="dashboard-route-list">{routes.slice(0, 4).map(route => (
|
||||
<Link to={`/routes/${route.id}/edit`} key={route.id} className="dashboard-route-row">
|
||||
<span className="dashboard-route-number" style={{ backgroundColor: route.color || '#1677ff' }}>{route.route_number}</span>
|
||||
<span className="min-w-0 flex-1"><span className="block font-medium break-words">{route.name}</span><span className="dashboard-muted">Остановок: {formatCount(Number(route.stops_count) || 0)}</span></span>
|
||||
<ChevronRight size={16} className="shrink-0 text-gray-400" aria-hidden="true" />
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
))}</div> : <PanelEmpty icon={Bus} text={data.routes ? 'Пока нет маршрутов' : 'Не удалось загрузить маршруты'} />}
|
||||
</DashboardPanel>
|
||||
<DashboardPanel title="Ближайшие праздники" to="/holidays" linkLabel="Календарь праздников">
|
||||
{failed.includes('holidays') || !data.holidays ? <PanelEmpty text="Не удалось загрузить праздники" /> : holidays.length ? (
|
||||
<div className="dashboard-holiday-list">{holidays.map(holiday => (
|
||||
<div key={holiday.id || `${holiday.name}-${holiday.next_date}`} className="dashboard-holiday-row">
|
||||
<span className="dashboard-calendar-date"><strong>{formatDate(holiday.next_date, { day: 'numeric' })}</strong><span>{formatDate(holiday.next_date, { month: 'short' })}</span></span>
|
||||
<span className="min-w-0"><span className="block font-medium break-words">{holiday.name}</span><span className="dashboard-muted">{formatDate(holiday.next_date, { weekday: 'long' })}</span></span>
|
||||
</div>
|
||||
))}</div>
|
||||
) : <PanelEmpty icon={Calendar} text="Нет предстоящих праздников" />}
|
||||
</DashboardPanel>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* System Status */}
|
||||
<div className="card list-item-animated">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
||||
<div className="flex items-center">
|
||||
<div className="p-3 bg-emerald-50 rounded-lg mr-4">
|
||||
<Activity className="h-6 w-6 text-emerald-600" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center">
|
||||
<h3 className="font-semibold text-gray-900">Статус системы</h3>
|
||||
<PulsingDot color="green" size="sm" className="ml-2" />
|
||||
</div>
|
||||
<p className="text-sm text-gray-500">
|
||||
Последнее обновление: {stats.lastSync
|
||||
? new Date(stats.lastSync).toLocaleString('ru-RU')
|
||||
: 'Нет данных'
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2 sm:gap-4 text-sm">
|
||||
<div className="flex items-center bg-gray-50 px-3 py-1.5 rounded-full">
|
||||
<span className="text-gray-500 mr-1">API:</span>
|
||||
<span className="text-emerald-600 font-medium">Online</span>
|
||||
</div>
|
||||
<div className="flex items-center bg-gray-50 px-3 py-1.5 rounded-full">
|
||||
<span className="text-gray-500 mr-1">БД:</span>
|
||||
<span className="text-emerald-600 font-medium">PostgreSQL</span>
|
||||
</div>
|
||||
<div className="flex items-center bg-gray-50 px-3 py-1.5 rounded-full">
|
||||
<span className="text-gray-500 mr-1">Версия:</span>
|
||||
<span className="font-medium">1.0.0</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<nav className="dashboard-actions" aria-label="Быстрые действия">
|
||||
<ActionLink to="/routes" icon={Bus}>Управление маршрутами</ActionLink>
|
||||
<ActionLink to="/stops" icon={MapPin}>Остановки на карте</ActionLink>
|
||||
<ActionLink to="/schedules" icon={Calendar}>Расписания</ActionLink>
|
||||
<ActionLink to="/holidays" icon={Gift}>Праздники</ActionLink>
|
||||
</nav>
|
||||
</>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Stat Card Component
|
||||
function StatCard({ icon, title, value, subtitle, color, trend, link, delay = 0 }) {
|
||||
const colorClasses = {
|
||||
blue: 'bg-blue-50 text-blue-600',
|
||||
green: 'bg-green-50 text-green-600',
|
||||
purple: 'bg-purple-50 text-purple-600',
|
||||
amber: 'bg-amber-50 text-amber-600',
|
||||
emerald: 'bg-emerald-50 text-emerald-600'
|
||||
};
|
||||
|
||||
const trendColorClasses = {
|
||||
blue: 'text-blue-600 bg-blue-100',
|
||||
green: 'text-green-600 bg-green-100',
|
||||
purple: 'text-purple-600 bg-purple-100',
|
||||
amber: 'text-amber-600 bg-amber-100'
|
||||
};
|
||||
|
||||
const Content = (
|
||||
<div
|
||||
className="card card-hover list-item-animated group"
|
||||
style={{ animationDelay: `${delay * 100}ms` }}
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className={`p-2 sm:p-2.5 rounded-lg ${colorClasses[color]} transition-transform duration-300 group-hover:scale-110`}>
|
||||
{icon}
|
||||
</div>
|
||||
{trend && (
|
||||
<span className={`text-xs px-2 py-0.5 rounded-full font-medium ${trendColorClasses[color]}`}>
|
||||
{trend}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-3">
|
||||
<p className="text-2xl sm:text-3xl font-bold text-gray-900 counter">{value}</p>
|
||||
<p className="text-xs sm:text-sm font-medium text-gray-900 mt-0.5">{title}</p>
|
||||
{subtitle && (
|
||||
<p className="text-xs text-gray-500 mt-0.5">{subtitle}</p>
|
||||
)}
|
||||
</div>
|
||||
{link && (
|
||||
<div className="mt-3 pt-3 border-t border-gray-100">
|
||||
<span className="text-xs text-primary-600 font-medium flex items-center group-hover:underline">
|
||||
Подробнее <ArrowRight className="h-3 w-3 ml-1 transition-transform group-hover:translate-x-1" />
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
return link ? <Link to={link}>{Content}</Link> : Content;
|
||||
function SummaryCard({ title, value, icon: Icon, children }) {
|
||||
return <article className="dashboard-summary-card">
|
||||
<div className="dashboard-summary-card-heading"><div><h3>{title}</h3><p>{value}</p></div><span className="dashboard-summary-icon"><Icon size={24} aria-hidden="true" /></span></div>
|
||||
<div className="dashboard-summary-card-body">{children}</div>
|
||||
</article>;
|
||||
}
|
||||
|
||||
// Quick Action Component
|
||||
function QuickAction({ icon, label, color, to }) {
|
||||
const colorClasses = {
|
||||
blue: 'bg-blue-100 text-blue-600',
|
||||
green: 'bg-green-100 text-green-600',
|
||||
purple: 'bg-purple-100 text-purple-600',
|
||||
amber: 'bg-amber-100 text-amber-600'
|
||||
};
|
||||
|
||||
return (
|
||||
<Link
|
||||
to={to}
|
||||
className="quick-action group"
|
||||
>
|
||||
<div className={`quick-action-icon ${colorClasses[color]}`}>
|
||||
{icon}
|
||||
</div>
|
||||
<span className="quick-action-label">{label}</span>
|
||||
</Link>
|
||||
);
|
||||
function DashboardPanel({ title, to, linkLabel, children }) {
|
||||
return <section className="card dashboard-panel" aria-label={title}>
|
||||
<h2>{title}</h2>
|
||||
<div className="dashboard-panel-body">{children}</div>
|
||||
<Link to={to} className="dashboard-panel-footer">{linkLabel}</Link>
|
||||
</section>;
|
||||
}
|
||||
|
||||
// Routes by Type Chart (Simple visual)
|
||||
function RoutesByTypeChart({ routes }) {
|
||||
const typeLabels = {
|
||||
bus: { label: 'Автобус', color: '#3B82F6' },
|
||||
minibus: { label: 'Маршрутка', color: '#10B981' },
|
||||
trolleybus: { label: 'Троллейбус', color: '#8B5CF6' },
|
||||
tram: { label: 'Трамвай', color: '#F59E0B' }
|
||||
};
|
||||
|
||||
const typeCounts = routes.reduce((acc, route) => {
|
||||
acc[route.type] = (acc[route.type] || 0) + 1;
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
const total = routes.length || 1;
|
||||
|
||||
if (routes.length === 0) {
|
||||
return (
|
||||
<div className="text-center py-8 text-gray-500 text-sm">
|
||||
Нет данных для отображения
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{Object.entries(typeLabels).map(([type, { label, color }]) => {
|
||||
const count = typeCounts[type] || 0;
|
||||
const percentage = Math.round((count / total) * 100);
|
||||
|
||||
return (
|
||||
<div key={type} className="group">
|
||||
<div className="flex items-center justify-between text-sm mb-1">
|
||||
<span className="text-gray-600">{label}</span>
|
||||
<span className="font-medium">{count} <span className="text-gray-400">({percentage}%)</span></span>
|
||||
</div>
|
||||
<div className="h-2 bg-gray-100 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full rounded-full transition-all duration-500 ease-out"
|
||||
style={{
|
||||
width: `${percentage}%`,
|
||||
backgroundColor: color,
|
||||
boxShadow: count > 0 ? `0 0 8px ${color}40` : 'none'
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Legend */}
|
||||
<div className="flex flex-wrap gap-3 pt-3 border-t border-gray-100 mt-4">
|
||||
{Object.entries(typeLabels).map(([type, { label, color }]) => (
|
||||
<div key={type} className="flex items-center text-xs">
|
||||
<div className="w-3 h-3 rounded-full mr-1.5" style={{ backgroundColor: color }} />
|
||||
<span className="text-gray-600">{label}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
function Metric({ label, value }) {
|
||||
return <div><dt className="dashboard-muted">{label}</dt><dd>{value}</dd></div>;
|
||||
}
|
||||
|
||||
// Schedule Coverage Chart
|
||||
function ScheduleCoverageChart({ routes }) {
|
||||
const dayTypes = [
|
||||
{ key: 'weekday', label: 'Будни', short: 'Пн-Пт' },
|
||||
{ key: 'saturday', label: 'Суббота', short: 'Сб' },
|
||||
{ key: 'sunday', label: 'Воскресенье', short: 'Вс' },
|
||||
{ key: 'holiday', label: 'Праздники', short: '🎉' }
|
||||
];
|
||||
|
||||
// Simulate schedule coverage data (in real app, this would come from API)
|
||||
const totalRoutes = routes.length || 1;
|
||||
const coverageData = dayTypes.map(day => ({
|
||||
...day,
|
||||
coverage: routes.length > 0
|
||||
? Math.round(70 + Math.random() * 30) // Simulated data
|
||||
: 0
|
||||
}));
|
||||
|
||||
if (routes.length === 0) {
|
||||
return (
|
||||
<div className="text-center py-8 text-gray-500 text-sm">
|
||||
Добавьте маршруты для отображения статистики
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const maxCoverage = Math.max(...coverageData.map(d => d.coverage), 1);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Bar Chart */}
|
||||
<div className="flex items-end justify-between h-32 gap-2 sm:gap-4 px-2">
|
||||
{coverageData.map((day, index) => (
|
||||
<div key={day.key} className="flex-1 flex flex-col items-center">
|
||||
<div className="w-full max-w-[40px] bg-gray-100 rounded-t-lg relative" style={{ height: '100%' }}>
|
||||
<div
|
||||
className="absolute bottom-0 left-0 right-0 bg-gradient-to-t from-primary-600 to-primary-400 rounded-t-lg transition-all duration-700 ease-out"
|
||||
style={{
|
||||
height: `${(day.coverage / maxCoverage) * 100}%`,
|
||||
animationDelay: `${index * 100}ms`
|
||||
}}
|
||||
>
|
||||
<span className="absolute -top-6 left-1/2 -translate-x-1/2 text-xs font-semibold text-gray-700">
|
||||
{day.coverage}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<span className="text-xs text-gray-500 mt-2 text-center">{day.short}</span>
|
||||
function PanelEmpty({ text, icon: Icon = Info }) {
|
||||
return <div className="dashboard-empty"><Icon size={56} strokeWidth={1.5} aria-hidden="true" /><p>{text}</p></div>;
|
||||
}
|
||||
function ActionLink({ to, icon: Icon, children }) {
|
||||
return <Link to={to} className="dashboard-action"><Icon size={16} aria-hidden="true" />{children}</Link>;
|
||||
}
|
||||
function RoutesChart({ routes, unavailable }) {
|
||||
const types = [...routeTypes];
|
||||
if (routes.some(route => !routeTypes.some(type => type.key === route.type))) types.push({ key: 'other', label: 'Другие' });
|
||||
const series = types.map(type => {
|
||||
const matches = routes.filter(route => type.key === 'other' ? !routeTypes.some(known => known.key === route.type) : route.type === type.key);
|
||||
return { ...type, total: matches.length, configured: matches.filter(route => Number(route.stops_count) > 0).length };
|
||||
});
|
||||
const max = Math.max(...series.map(type => type.total), 1);
|
||||
return <div className="dashboard-chart">
|
||||
{!routes.length ? <PanelEmpty icon={Bus} text={unavailable ? 'Не удалось загрузить статистику' : 'Добавьте маршруты, чтобы увидеть статистику'} /> : <>
|
||||
<div className="dashboard-chart-plot" aria-hidden="true">
|
||||
<div className="dashboard-chart-guides">{[4, 3, 2, 1, 0].map(tick => <span key={tick} />)}</div>
|
||||
{series.map(type => <div className="dashboard-chart-column" key={type.key}>
|
||||
<div className="dashboard-chart-bars">
|
||||
<div className="dashboard-chart-bar" style={{ height: `${type.total / max * 100}%` }}><span>{type.total}</span></div>
|
||||
<div className="dashboard-chart-bar is-secondary" style={{ height: `${type.configured / max * 100}%` }}><span>{type.configured}</span></div>
|
||||
</div>
|
||||
))}
|
||||
<span className="dashboard-chart-label">{type.label}</span>
|
||||
</div>)}
|
||||
</div>
|
||||
|
||||
{/* Summary */}
|
||||
<div className="flex items-center justify-between pt-3 border-t border-gray-100">
|
||||
<div className="text-sm">
|
||||
<span className="text-gray-500">Среднее покрытие:</span>
|
||||
<span className="font-semibold ml-1">
|
||||
{Math.round(coverageData.reduce((a, b) => a + b.coverage, 0) / coverageData.length)}%
|
||||
</span>
|
||||
</div>
|
||||
<Link to="/schedules" className="text-xs text-primary-600 hover:underline flex items-center">
|
||||
Управление <ChevronRight className="h-3 w-3" />
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
<div className="dashboard-chart-legend" aria-hidden="true"><span><i />Всего маршрутов</span><span><i className="is-secondary" />С остановками</span></div>
|
||||
<div className="sr-only"><table><caption>Маршруты по типам транспорта</caption><thead><tr><th>Тип</th><th>Всего маршрутов</th><th>С остановками</th></tr></thead><tbody>{series.map(type => <tr key={type.key}><th scope="row">{type.label}</th><td>{type.total}</td><td>{type.configured}</td></tr>)}</tbody></table></div>
|
||||
</>}
|
||||
</div>;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState } from 'react';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import { Bus, Lock, User, AlertCircle, Loader2 } from 'lucide-react';
|
||||
import { Lock, User, AlertCircle, Loader2 } from 'lucide-react';
|
||||
import BrandLogo from '../components/BrandLogo';
|
||||
|
||||
export default function LoginPage() {
|
||||
const [username, setUsername] = useState('');
|
||||
@@ -23,26 +24,16 @@ export default function LoginPage() {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-primary-50 via-white to-blue-50 flex items-center justify-center p-4">
|
||||
{/* Background decorations */}
|
||||
<div className="absolute inset-0 overflow-hidden pointer-events-none">
|
||||
<div className="absolute -top-40 -right-40 w-80 h-80 bg-primary-200 rounded-full mix-blend-multiply filter blur-xl opacity-70 animate-blob"></div>
|
||||
<div className="absolute -bottom-40 -left-40 w-80 h-80 bg-blue-200 rounded-full mix-blend-multiply filter blur-xl opacity-70 animate-blob animation-delay-2000"></div>
|
||||
<div className="absolute top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 w-80 h-80 bg-purple-200 rounded-full mix-blend-multiply filter blur-xl opacity-70 animate-blob animation-delay-4000"></div>
|
||||
</div>
|
||||
|
||||
<div className="login-surface">
|
||||
<div className="w-full max-w-md relative">
|
||||
{/* Logo and Title */}
|
||||
<div className="text-center mb-8 animate-[fadeSlideIn_0.6s_ease-out]">
|
||||
<div className="inline-flex items-center justify-center w-20 h-20 bg-primary-600 rounded-2xl shadow-lg mb-4 animate-[bounce_1s_ease-in-out]">
|
||||
<Bus className="h-10 w-10 text-white" />
|
||||
</div>
|
||||
<h1 className="text-3xl font-bold text-gray-900 mb-2">Polotsk Transit</h1>
|
||||
<h1 className="mb-4"><BrandLogo className="mx-auto" /><span className="sr-only">Polotsk Transit</span></h1>
|
||||
<p className="text-gray-600">Административная панель</p>
|
||||
</div>
|
||||
|
||||
{/* Login Card */}
|
||||
<div className="bg-white/80 backdrop-blur-sm rounded-2xl shadow-2xl p-8 border border-gray-100 animate-[scaleIn_0.4s_ease-out_0.2s_backwards]">
|
||||
<div className="login-card">
|
||||
<h2 className="text-2xl font-bold text-gray-900 mb-6 text-center">Вход в систему</h2>
|
||||
|
||||
{error && (
|
||||
@@ -58,10 +49,12 @@ export default function LoginPage() {
|
||||
<form onSubmit={handleSubmit} className="space-y-5">
|
||||
{/* Username */}
|
||||
<div>
|
||||
<label className="label">Имя пользователя</label>
|
||||
<label htmlFor="username" className="label">Имя пользователя</label>
|
||||
<div className="relative">
|
||||
<User className="absolute left-3 top-1/2 -translate-y-1/2 h-5 w-5 text-gray-400" />
|
||||
<input
|
||||
id="username"
|
||||
autoComplete="username"
|
||||
type="text"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
@@ -76,10 +69,12 @@ export default function LoginPage() {
|
||||
|
||||
{/* Password */}
|
||||
<div>
|
||||
<label className="label">Пароль</label>
|
||||
<label htmlFor="password" className="label">Пароль</label>
|
||||
<div className="relative">
|
||||
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 h-5 w-5 text-gray-400" />
|
||||
<input
|
||||
id="password"
|
||||
autoComplete="current-password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { ArrowLeft, Plus, Trash2, Clock, MapPin, Bus, Radio, Send, Save, RotateC
|
||||
import { routesApi, routeStopsApi, realtimeApi, telemetryApi } from '../services/api';
|
||||
import { RouteRealtimeClient } from '../services/realtime';
|
||||
import { PageLoader } from '../components/AnimatedComponents';
|
||||
import RouteBadge from '../components/RouteBadge';
|
||||
import 'leaflet/dist/leaflet.css';
|
||||
import L from 'leaflet';
|
||||
|
||||
@@ -447,20 +448,15 @@ export default function RouteEditorPage() {
|
||||
return (
|
||||
<div>
|
||||
{/* Header */}
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 mb-4 sm:mb-6">
|
||||
<div className="flex items-center">
|
||||
<Link to="/routes" className="mr-3 sm:mr-4 text-gray-500 hover:text-gray-700 p-1">
|
||||
<div className="flex flex-wrap items-center justify-between gap-4 mb-4 sm:mb-6">
|
||||
<div className="flex min-w-0 items-center">
|
||||
<Link to="/routes" className="shrink-0 mr-3 sm:mr-4 text-gray-500 hover:text-gray-700 p-1">
|
||||
<ArrowLeft className="h-5 w-5 sm:h-6 sm:w-6" />
|
||||
</Link>
|
||||
<div className="flex items-center">
|
||||
<div
|
||||
className="w-10 h-10 sm:w-12 sm:h-12 rounded-lg flex items-center justify-center text-white font-bold text-base sm:text-lg mr-3"
|
||||
style={{ backgroundColor: route.color }}
|
||||
>
|
||||
{route.route_number}
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-lg sm:text-2xl font-bold text-gray-900">{route.name}</h1>
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
<RouteBadge route={route} />
|
||||
<div className="min-w-0">
|
||||
<h1 className="page-title">{route.name}</h1>
|
||||
<p className="text-xs sm:text-sm text-gray-500">Редактирование остановок</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
59
frontend/src/pages/RoutesPage.css
Normal file
59
frontend/src/pages/RoutesPage.css
Normal file
@@ -0,0 +1,59 @@
|
||||
/* Figma 5:3997: joined filters, white table, 48px identity badges, quiet dividers. */
|
||||
.routes-page { display: flex; flex-direction: column; gap: 24px; }
|
||||
.routes-heading { display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 16px; margin-bottom: 12px; }
|
||||
.routes-toolbar { display: flex; flex-wrap: wrap; align-items: center; gap: 12px; }
|
||||
.routes-filters-scroll { max-width: 100%; padding: 4px; margin: -4px; overflow-x: auto; }
|
||||
.routes-filters { display: flex; width: max-content; }
|
||||
.routes-filters button { display: flex; align-items: center; justify-content: center; gap: 8px; min-height: 44px; padding: 8px 14px; border: 1px solid #d9d9d9; border-radius: 0; background: #fff; font-size: 14px; white-space: nowrap; }
|
||||
.routes-filters button + button { margin-left: -1px; }
|
||||
.routes-filters button:first-child { border-radius: var(--control-radius) 0 0 var(--control-radius); }
|
||||
.routes-filters button:last-child { border-radius: 0 var(--control-radius) var(--control-radius) 0; }
|
||||
.routes-filters button:hover { color: #0958d9; }
|
||||
.routes-filters button[aria-pressed="true"] { position: relative; color: #fff; background: #1677ff; border-color: #1677ff; }
|
||||
.routes-filters button:focus-visible { position: relative; z-index: 1; }
|
||||
.routes-search { position: relative; flex: 1; min-width: min(280px, 100%); max-width: 440px; }
|
||||
.routes-search svg { position: absolute; left: 12px; top: 50%; transform: translateY(-50%); color: #999; pointer-events: none; }
|
||||
.routes-search input { width: 100%; min-height: 44px; background: #fff; border: 1px solid #d9d9d9; border-radius: var(--control-radius); padding: 10px 12px 10px 40px; font-size: 14px; }
|
||||
.routes-search input::placeholder { color: #8c8c8c; }
|
||||
.routes-table-container { background: white; border-radius: 12px; overflow: hidden; }
|
||||
.routes-table { width: 100%; text-align: left; font-size: 14px; border-collapse: collapse; }
|
||||
.routes-table thead { background: #fafafa; }
|
||||
.routes-table thead th { height: 53px; padding: 16px; font-weight: 600; border-bottom: 1px solid #f0f0f0; position: relative; white-space: nowrap; }
|
||||
.routes-table thead th + th::before { content: ''; position: absolute; left: 0; top: 16px; bottom: 16px; border-left: 1px solid #eee; }
|
||||
.routes-table tbody th, .routes-table td { padding: 16px; border-bottom: 1px solid #f0f0f0; font-weight: 400; }
|
||||
.routes-table tbody tr:hover { background: #fafcff; }
|
||||
.route-identity { display: flex; align-items: center; gap: 16px; min-width: 0; }
|
||||
.route-identity:hover .route-name { color: #0958d9; }
|
||||
.route-identity-text { min-width: 0; }
|
||||
.route-name { display: block; font-weight: 600; line-height: 22px; overflow-wrap: anywhere; }
|
||||
.route-caption { display: block; color: #8c8c8c; margin-top: 2px; font-size: 13px; line-height: 20px; }
|
||||
.route-type, .route-stop-count { display: flex; align-items: center; gap: 8px; white-space: nowrap; }
|
||||
.route-type svg, .route-stop-count svg { color: #8c8c8c; flex-shrink: 0; }
|
||||
.route-actions-cell { width: 1%; }
|
||||
.route-actions { display: flex; align-items: center; gap: 8px; }
|
||||
.route-open { display: flex; align-items: center; justify-content: center; min-height: 44px; padding: 8px 16px; border-radius: var(--control-radius); color: #fff; background: #1677ff; }
|
||||
.route-open:hover { background: #0958d9; }
|
||||
.route-icon-button { display: flex; align-items: center; justify-content: center; width: 44px; height: 44px; flex-shrink: 0; color: #737373; border-radius: var(--control-radius); }
|
||||
.route-icon-button:hover { background: #e6f4ff; color: #0958d9; }
|
||||
.route-icon-button.is-danger:hover { background: #fff1f0; color: #cf1322; }
|
||||
.routes-result-count { padding: 16px; color: #8c8c8c; font-size: 13px; }
|
||||
.routes-error { display: flex; align-items: center; flex-wrap: wrap; gap: 12px; padding: 16px; background: #fff1f0; border-radius: 12px; color: #a8071a; }
|
||||
.routes-error > svg { flex-shrink: 0; }
|
||||
@media (min-width: 640px) and (max-width: 1199px) {
|
||||
.routes-table thead th, .routes-table tbody th, .routes-table td { padding: 12px; }
|
||||
.route-identity { gap: 12px; }
|
||||
.route-actions { flex-wrap: wrap; min-width: 96px; justify-content: center; gap: 4px; }
|
||||
.route-open { flex-basis: 100%; }
|
||||
}
|
||||
@media (max-width: 639px) {
|
||||
.routes-heading { align-items: flex-start; flex-direction: column; margin-bottom: 0; }
|
||||
.routes-search { max-width: none; flex-basis: 100%; }
|
||||
.routes-table, .routes-table tbody { display: block; }
|
||||
.routes-table thead { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); }
|
||||
.routes-table tbody tr { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 12px; padding: 16px; border-bottom: 1px solid #f0f0f0; }
|
||||
.routes-table tbody th, .routes-table td { display: block; padding: 0; border: 0; }
|
||||
.routes-table tbody th { grid-column: 1 / -1; }
|
||||
.route-actions-cell { grid-column: 1 / -1; width: auto; }
|
||||
.route-open { flex: 1; }
|
||||
.route-type, .route-stop-count { font-size: 13px; gap: 4px; }
|
||||
}
|
||||
@@ -1,450 +1,164 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Plus, Edit, Trash2, Bus, MapPin, Search, Filter, Grid3x3, List, Clock } from 'lucide-react';
|
||||
import { Plus, Edit, Trash2, Bus, MapPin, Search, AlertCircle } from 'lucide-react';
|
||||
import { routesApi } from '../services/api';
|
||||
import RouteForm from '../components/RouteForm';
|
||||
import { PageLoader, CardSkeleton } from '../components/AnimatedComponents';
|
||||
import RouteBadge from '../components/RouteBadge';
|
||||
import { PageLoader } from '../components/AnimatedComponents';
|
||||
import './RoutesPage.css';
|
||||
|
||||
const ROUTE_TYPES = [
|
||||
{ value: 'all', label: 'Все' },
|
||||
{ value: 'bus', label: 'Автобусы', singular: 'Автобус' },
|
||||
{ value: 'minibus', label: 'Маршрутки', singular: 'Маршрутка' },
|
||||
{ value: 'trolleybus', label: 'Троллейбусы', singular: 'Троллейбус' },
|
||||
{ value: 'tram', label: 'Трамваи', singular: 'Трамвай' },
|
||||
];
|
||||
|
||||
export default function RoutesPage() {
|
||||
const [routes, setRoutes] = useState([]);
|
||||
const [filteredRoutes, setFilteredRoutes] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
const [editingRoute, setEditingRoute] = useState(null);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [typeFilter, setTypeFilter] = useState('all');
|
||||
const [viewMode, setViewMode] = useState('grid'); // 'grid' or 'list'
|
||||
|
||||
useEffect(() => {
|
||||
loadRoutes();
|
||||
}, []);
|
||||
useEffect(() => { loadRoutes(); }, []);
|
||||
|
||||
useEffect(() => {
|
||||
filterRoutes();
|
||||
}, [routes, searchQuery, typeFilter]);
|
||||
|
||||
const loadRoutes = async () => {
|
||||
async function loadRoutes() {
|
||||
setError('');
|
||||
try {
|
||||
const response = await routesApi.getAll();
|
||||
setRoutes(response.data);
|
||||
} catch (error) {
|
||||
console.error('Error loading routes:', error);
|
||||
} catch {
|
||||
setError('Не удалось загрузить маршруты. Попробуйте ещё раз.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const filterRoutes = () => {
|
||||
let filtered = routes;
|
||||
|
||||
// Filter by type
|
||||
if (typeFilter !== 'all') {
|
||||
filtered = filtered.filter(route => route.type === typeFilter);
|
||||
}
|
||||
|
||||
// Filter by search
|
||||
if (searchQuery) {
|
||||
filtered = filtered.filter(route =>
|
||||
route.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
route.route_number.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
);
|
||||
}
|
||||
|
||||
setFilteredRoutes(filtered);
|
||||
};
|
||||
|
||||
const handleCreate = () => {
|
||||
setEditingRoute(null);
|
||||
setShowForm(true);
|
||||
};
|
||||
|
||||
const handleEdit = (route) => {
|
||||
setEditingRoute(route);
|
||||
setShowForm(true);
|
||||
};
|
||||
|
||||
const handleDelete = async (id) => {
|
||||
if (!confirm('Вы уверены, что хотите удалить этот маршрут?')) return;
|
||||
|
||||
try {
|
||||
await routesApi.delete(id);
|
||||
loadRoutes();
|
||||
} catch (error) {
|
||||
console.error('Error deleting route:', error);
|
||||
alert('Ошибка удаления маршрута');
|
||||
}
|
||||
};
|
||||
|
||||
const handleFormSubmit = async (data) => {
|
||||
try {
|
||||
if (editingRoute) {
|
||||
await routesApi.update(editingRoute.id, data);
|
||||
} else {
|
||||
await routesApi.create(data);
|
||||
}
|
||||
setShowForm(false);
|
||||
loadRoutes();
|
||||
} catch (error) {
|
||||
console.error('Error saving route:', error);
|
||||
alert('Ошибка сохранения маршрута');
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div>
|
||||
<div className="flex flex-col sm:flex-row sm:justify-between sm:items-center gap-4 mb-6">
|
||||
<div className="skeleton h-9 w-40 rounded" />
|
||||
<div className="skeleton h-10 w-44 rounded-lg" />
|
||||
</div>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4 sm:gap-6">
|
||||
<CardSkeleton count={6} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const typeLabels = {
|
||||
all: { label: 'Все', icon: Bus, count: routes.length },
|
||||
bus: { label: 'Автобусы', icon: Bus, count: routes.filter(r => r.type === 'bus').length },
|
||||
minibus: { label: 'Маршрутки', icon: Bus, count: routes.filter(r => r.type === 'minibus').length },
|
||||
trolleybus: { label: 'Троллейбусы', icon: Bus, count: routes.filter(r => r.type === 'trolleybus').length },
|
||||
tram: { label: 'Трамваи', icon: Bus, count: routes.filter(r => r.type === 'tram').length },
|
||||
};
|
||||
const query = searchQuery.trim().toLocaleLowerCase('ru');
|
||||
const filteredRoutes = routes.filter(route =>
|
||||
(typeFilter === 'all' || route.type === typeFilter) &&
|
||||
(!query || `${route.name} ${route.route_number}`.toLocaleLowerCase('ru').includes(query))
|
||||
);
|
||||
|
||||
function handleCreate() {
|
||||
setEditingRoute(null);
|
||||
setShowForm(true);
|
||||
}
|
||||
|
||||
async function handleDelete(id) {
|
||||
if (!confirm('Вы уверены, что хотите удалить этот маршрут?')) return;
|
||||
try {
|
||||
await routesApi.delete(id);
|
||||
await loadRoutes();
|
||||
} catch {
|
||||
alert('Ошибка удаления маршрута');
|
||||
}
|
||||
}
|
||||
|
||||
async function handleFormSubmit(data) {
|
||||
try {
|
||||
if (editingRoute) await routesApi.update(editingRoute.id, data);
|
||||
else await routesApi.create(data);
|
||||
setShowForm(false);
|
||||
await loadRoutes();
|
||||
} catch {
|
||||
alert('Ошибка сохранения маршрута');
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Header */}
|
||||
<div className="flex flex-col sm:flex-row sm:justify-between sm:items-center gap-4 mb-6">
|
||||
<div>
|
||||
<h1 className="page-title">Маршруты</h1>
|
||||
<p className="text-sm text-gray-500 mt-1">
|
||||
{filteredRoutes.length} {filteredRoutes.length === 1 ? 'маршрут' : 'маршрутов'}
|
||||
</p>
|
||||
</div>
|
||||
<button onClick={handleCreate} className="btn-primary hidden sm:flex items-center justify-center">
|
||||
<Plus className="h-5 w-5 mr-2" />
|
||||
Добавить маршрут
|
||||
<div className="routes-page">
|
||||
<header className="routes-heading">
|
||||
<h1 className="page-title">Маршруты</h1>
|
||||
<button type="button" onClick={handleCreate} className="btn-primary inline-flex items-center justify-center gap-2">
|
||||
<Plus size={18} aria-hidden="true" />Добавить маршрут
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{showForm && (
|
||||
<RouteForm
|
||||
route={editingRoute}
|
||||
onSubmit={handleFormSubmit}
|
||||
onCancel={() => setShowForm(false)}
|
||||
/>
|
||||
)}
|
||||
{showForm && <RouteForm route={editingRoute} onSubmit={handleFormSubmit} onCancel={() => setShowForm(false)} />}
|
||||
|
||||
{/* Filters and Search Bar */}
|
||||
<div className="card mb-6 space-y-4">
|
||||
{/* Search */}
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-5 w-5 text-gray-400" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Поиск по названию или номеру..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="input pl-10 pr-4"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Type Filters */}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{Object.entries(typeLabels).map(([type, { label, count }]) => (
|
||||
<button
|
||||
key={type}
|
||||
onClick={() => setTypeFilter(type)}
|
||||
className={`px-4 py-2 rounded-lg text-sm font-medium transition-all duration-200 ${
|
||||
typeFilter === type
|
||||
? 'bg-primary-600 text-white shadow-md'
|
||||
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
|
||||
}`}
|
||||
>
|
||||
{label}
|
||||
<span className={`ml-1.5 ${typeFilter === type ? 'text-primary-100' : 'text-gray-500'}`}>
|
||||
({count})
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* View Mode Toggle */}
|
||||
<div className="flex items-center justify-between pt-4 border-t border-gray-200">
|
||||
<p className="text-sm text-gray-600">
|
||||
Отображение: <span className="font-medium">{filteredRoutes.length}</span> из {routes.length}
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => setViewMode('grid')}
|
||||
className={`p-2 rounded-lg transition-colors ${
|
||||
viewMode === 'grid' ? 'bg-primary-100 text-primary-600' : 'text-gray-400 hover:bg-gray-100'
|
||||
}`}
|
||||
title="Сетка"
|
||||
>
|
||||
<Grid3x3 className="h-5 w-5" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setViewMode('list')}
|
||||
className={`p-2 rounded-lg transition-colors ${
|
||||
viewMode === 'list' ? 'bg-primary-100 text-primary-600' : 'text-gray-400 hover:bg-gray-100'
|
||||
}`}
|
||||
title="Список"
|
||||
>
|
||||
<List className="h-5 w-5" />
|
||||
</button>
|
||||
<div className="routes-toolbar">
|
||||
<div className="routes-filters-scroll">
|
||||
<div className="routes-filters" role="group" aria-label="Тип транспорта">
|
||||
{ROUTE_TYPES.map(({ value, label }) => (
|
||||
<button type="button" key={value} onClick={() => setTypeFilter(value)} aria-pressed={typeFilter === value}>
|
||||
{label}<span>{value === 'all' ? routes.length : routes.filter(route => route.type === value).length}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="routes-search">
|
||||
<Search size={20} aria-hidden="true" />
|
||||
<input type="search" placeholder="Поиск по названию или номеру" aria-label="Поиск маршрутов"
|
||||
value={searchQuery} onChange={event => setSearchQuery(event.target.value)} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Routes Grid/List */}
|
||||
{filteredRoutes.length > 0 ? (
|
||||
<div className={viewMode === 'grid'
|
||||
? 'grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4 sm:gap-6'
|
||||
: 'space-y-4'
|
||||
}>
|
||||
{filteredRoutes.map((route, index) => (
|
||||
viewMode === 'grid' ? (
|
||||
<RouteCard
|
||||
key={route.id}
|
||||
route={route}
|
||||
index={index}
|
||||
onEdit={() => handleEdit(route)}
|
||||
onDelete={() => handleDelete(route.id)}
|
||||
/>
|
||||
) : (
|
||||
<RouteListItem
|
||||
key={route.id}
|
||||
route={route}
|
||||
index={index}
|
||||
onEdit={() => handleEdit(route)}
|
||||
onDelete={() => handleDelete(route.id)}
|
||||
/>
|
||||
)
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="empty-state">
|
||||
<Bus className="empty-state-icon" />
|
||||
<p className="empty-state-text">
|
||||
{searchQuery || typeFilter !== 'all'
|
||||
? 'Маршруты не найдены. Попробуйте изменить фильтры.'
|
||||
: 'Нет маршрутов. Создайте первый!'}
|
||||
</p>
|
||||
{!searchQuery && typeFilter === 'all' && (
|
||||
<button onClick={handleCreate} className="btn-primary">
|
||||
<Plus className="h-5 w-5 mr-2 inline" />
|
||||
Добавить маршрут
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{error && <div role="alert" className="routes-error">
|
||||
<AlertCircle size={20} aria-hidden="true" /><span>{error}</span>
|
||||
<button type="button" className="btn-secondary" onClick={loadRoutes}>Повторить</button>
|
||||
</div>}
|
||||
|
||||
{/* Mobile FAB */}
|
||||
{routes.length > 0 && (
|
||||
<button
|
||||
onClick={handleCreate}
|
||||
className="fab sm:hidden"
|
||||
title="Добавить маршрут"
|
||||
>
|
||||
<Plus className="h-6 w-6" />
|
||||
</button>
|
||||
{loading ? <PageLoader /> : filteredRoutes.length > 0 ? (
|
||||
<div className="routes-table-container">
|
||||
<table className="routes-table">
|
||||
<caption className="sr-only">Список маршрутов</caption>
|
||||
<thead><tr>
|
||||
<th scope="col">Маршрут</th>
|
||||
<th scope="col">Тип транспорта</th>
|
||||
<th scope="col">Остановки</th>
|
||||
<th scope="col"><span className="sr-only">Действия</span></th>
|
||||
</tr></thead>
|
||||
<tbody>
|
||||
{filteredRoutes.map(route => (
|
||||
<tr key={route.id}>
|
||||
<th scope="row">
|
||||
<Link to={`/routes/${route.id}/edit`} className="route-identity">
|
||||
<RouteBadge route={route} />
|
||||
<span className="route-identity-text">
|
||||
<span className="route-name">{route.name}</span>
|
||||
<span className="route-caption">Маршрут № {route.route_number}</span>
|
||||
</span>
|
||||
</Link>
|
||||
</th>
|
||||
<td className="route-type-cell">
|
||||
<span className="route-type"><Bus size={16} aria-hidden="true" />
|
||||
{ROUTE_TYPES.find(type => type.value === route.type)?.singular || route.type}
|
||||
</span>
|
||||
</td>
|
||||
<td className="route-stops-cell">
|
||||
<span className="route-stop-count"><MapPin size={16} aria-hidden="true" />{route.stops_count ?? 0}<span className="sm:hidden"> ост.</span></span>
|
||||
</td>
|
||||
<td className="route-actions-cell">
|
||||
<div className="route-actions">
|
||||
<Link to={`/routes/${route.id}/edit`} className="route-open" aria-label={`Остановки маршрута ${route.route_number}: ${route.name}`}>Остановки</Link>
|
||||
<button type="button" className="route-icon-button" title="Редактировать маршрут"
|
||||
aria-label={`Редактировать маршрут ${route.route_number}: ${route.name}`}
|
||||
onClick={() => { setEditingRoute(route); setShowForm(true); }}><Edit size={18} aria-hidden="true" /></button>
|
||||
<button type="button" className="route-icon-button is-danger" title="Удалить маршрут"
|
||||
aria-label={`Удалить маршрут ${route.route_number}: ${route.name}`}
|
||||
onClick={() => handleDelete(route.id)}><Trash2 size={18} aria-hidden="true" /></button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
<p className="routes-result-count" role="status">Показано {filteredRoutes.length} из {routes.length}</p>
|
||||
</div>
|
||||
) : !error && (
|
||||
<div className="card empty-state">
|
||||
<Bus className="empty-state-icon" aria-hidden="true" />
|
||||
<h2 className="text-lg font-semibold mb-2">{routes.length ? 'Маршруты не найдены' : 'Пока нет маршрутов'}</h2>
|
||||
<p className="empty-state-text">{routes.length ? 'Измените поисковый запрос или сбросьте фильтры.' : 'Добавьте первый маршрут, чтобы настроить остановки и расписание.'}</p>
|
||||
{routes.length ? <button type="button" className="btn-secondary" onClick={() => { setSearchQuery(''); setTypeFilter('all'); }}>Сбросить фильтры</button>
|
||||
: <button type="button" className="btn-primary" onClick={handleCreate}>Добавить маршрут</button>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RouteCard({ route, index, onEdit, onDelete }) {
|
||||
const typeLabels = {
|
||||
bus: 'Автобус',
|
||||
minibus: 'Маршрутка',
|
||||
trolleybus: 'Троллейбус',
|
||||
tram: 'Трамвай'
|
||||
};
|
||||
|
||||
// Lighten color for gradient
|
||||
const hexToRgb = (hex) => {
|
||||
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
|
||||
return result ? {
|
||||
r: parseInt(result[1], 16),
|
||||
g: parseInt(result[2], 16),
|
||||
b: parseInt(result[3], 16)
|
||||
} : null;
|
||||
};
|
||||
|
||||
const rgb = hexToRgb(route.color);
|
||||
const lightColor = rgb ? `rgba(${rgb.r}, ${rgb.g}, ${rgb.b}, 0.1)` : '#f3f4f6';
|
||||
|
||||
return (
|
||||
<div
|
||||
className="card overflow-hidden list-item-animated group relative"
|
||||
style={{
|
||||
animationDelay: `${index * 50}ms`,
|
||||
background: `linear-gradient(135deg, ${lightColor} 0%, white 100%)`
|
||||
}}
|
||||
>
|
||||
{/* Color accent bar */}
|
||||
<div
|
||||
className="absolute top-0 left-0 right-0 h-1"
|
||||
style={{ backgroundColor: route.color }}
|
||||
/>
|
||||
|
||||
{/* Header */}
|
||||
<div className="flex items-start justify-between mb-4 pt-2">
|
||||
<div className="flex items-center flex-1 min-w-0">
|
||||
<div
|
||||
className="w-16 h-16 rounded-2xl flex items-center justify-center text-white font-bold text-2xl shadow-lg transition-all duration-300 group-hover:scale-110 group-hover:rotate-3 flex-shrink-0"
|
||||
style={{
|
||||
backgroundColor: route.color,
|
||||
boxShadow: `0 4px 12px ${route.color}40`
|
||||
}}
|
||||
>
|
||||
{route.route_number}
|
||||
</div>
|
||||
<div className="ml-4 min-w-0 flex-1">
|
||||
<h3 className="font-bold text-gray-900 text-lg leading-tight mb-1 truncate">
|
||||
{route.name}
|
||||
</h3>
|
||||
<p className="text-sm text-gray-500 flex items-center">
|
||||
<Bus className="h-3.5 w-3.5 mr-1" />
|
||||
{typeLabels[route.type]}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="grid grid-cols-2 gap-3 mb-4">
|
||||
<div className="bg-white/60 backdrop-blur-sm rounded-lg p-3 border border-gray-100">
|
||||
<div className="flex items-center text-gray-500 text-xs mb-1">
|
||||
<MapPin className="h-3.5 w-3.5 mr-1" />
|
||||
Остановок
|
||||
</div>
|
||||
<p className="text-2xl font-bold text-gray-900">{route.stops_count || 0}</p>
|
||||
</div>
|
||||
<div className="bg-white/60 backdrop-blur-sm rounded-lg p-3 border border-gray-100">
|
||||
<div className="flex items-center text-gray-500 text-xs mb-1">
|
||||
<Clock className="h-3.5 w-3.5 mr-1" />
|
||||
Расписаний
|
||||
</div>
|
||||
<p className="text-2xl font-bold text-gray-900">{route.schedules_count || 0}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex flex-col gap-2">
|
||||
<Link
|
||||
to={`/routes/${route.id}/edit`}
|
||||
className="btn-primary flex items-center justify-center text-sm group/btn"
|
||||
>
|
||||
<MapPin className="h-4 w-4 mr-2 transition-transform group-hover/btn:scale-110" />
|
||||
Редактировать остановки
|
||||
</Link>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={onEdit}
|
||||
className="btn-secondary flex-1 flex items-center justify-center text-sm"
|
||||
>
|
||||
<Edit className="h-4 w-4 mr-1" />
|
||||
Изменить
|
||||
</button>
|
||||
<button
|
||||
onClick={onDelete}
|
||||
className="btn-danger flex items-center justify-center px-4"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RouteListItem({ route, index, onEdit, onDelete }) {
|
||||
const typeLabels = {
|
||||
bus: 'Автобус',
|
||||
minibus: 'Маршрутка',
|
||||
trolleybus: 'Троллейбус',
|
||||
tram: 'Трамвай'
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="card list-item-animated group hover:shadow-lg transition-all duration-200"
|
||||
style={{ animationDelay: `${index * 30}ms` }}
|
||||
>
|
||||
<div className="flex items-center gap-4">
|
||||
{/* Route Number Badge */}
|
||||
<div
|
||||
className="w-20 h-20 rounded-2xl flex items-center justify-center text-white font-bold text-2xl shadow-md transition-transform duration-300 group-hover:scale-110 flex-shrink-0"
|
||||
style={{
|
||||
backgroundColor: route.color,
|
||||
boxShadow: `0 4px 12px ${route.color}40`
|
||||
}}
|
||||
>
|
||||
{route.route_number}
|
||||
</div>
|
||||
|
||||
{/* Route Info */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="font-bold text-gray-900 text-xl mb-1 truncate">{route.name}</h3>
|
||||
<div className="flex flex-wrap items-center gap-3 text-sm text-gray-500">
|
||||
<span className="flex items-center">
|
||||
<Bus className="h-4 w-4 mr-1" />
|
||||
{typeLabels[route.type]}
|
||||
</span>
|
||||
<span className="flex items-center">
|
||||
<MapPin className="h-4 w-4 mr-1" />
|
||||
{route.stops_count || 0} остановок
|
||||
</span>
|
||||
<span className="flex items-center">
|
||||
<Clock className="h-4 w-4 mr-1" />
|
||||
{route.schedules_count || 0} расписаний
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="hidden sm:flex items-center gap-2 flex-shrink-0">
|
||||
<Link
|
||||
to={`/routes/${route.id}/edit`}
|
||||
className="btn-primary flex items-center text-sm"
|
||||
>
|
||||
<MapPin className="h-4 w-4 mr-1" />
|
||||
Остановки
|
||||
</Link>
|
||||
<button onClick={onEdit} className="btn-secondary flex items-center text-sm">
|
||||
<Edit className="h-4 w-4 mr-1" />
|
||||
Изменить
|
||||
</button>
|
||||
<button onClick={onDelete} className="btn-danger p-2">
|
||||
<Trash2 className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Mobile Actions */}
|
||||
<div className="flex sm:hidden flex-col gap-2 flex-shrink-0">
|
||||
<Link
|
||||
to={`/routes/${route.id}/edit`}
|
||||
className="p-2 bg-primary-100 text-primary-600 rounded-lg hover:bg-primary-200 transition-colors"
|
||||
>
|
||||
<MapPin className="h-5 w-5" />
|
||||
</Link>
|
||||
<button
|
||||
onClick={onEdit}
|
||||
className="p-2 bg-gray-100 text-gray-600 rounded-lg hover:bg-gray-200 transition-colors"
|
||||
>
|
||||
<Edit className="h-5 w-5" />
|
||||
</button>
|
||||
<button
|
||||
onClick={onDelete}
|
||||
className="p-2 bg-red-100 text-red-600 rounded-lg hover:bg-red-200 transition-colors"
|
||||
>
|
||||
<Trash2 className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
19
frontend/src/pages/SchedulesPage.css
Normal file
19
frontend/src/pages/SchedulesPage.css
Normal file
@@ -0,0 +1,19 @@
|
||||
.schedules-layout { display: grid; grid-template-columns: minmax(0, 1fr); gap: 24px; align-items: start; }
|
||||
.schedule-route-panel.card { padding: 20px; }
|
||||
.schedule-route-list { display: flex; flex-direction: column; gap: 8px; max-height: max(360px, calc(100dvh - 240px)); overflow-y: auto; padding: 4px; margin: -4px; }
|
||||
.schedule-route-option { display: flex; align-items: center; gap: 12px; width: 100%; padding: 12px; text-align: left; border: 1px solid #e5e7eb; border-radius: var(--control-radius); background: white; }
|
||||
.schedule-route-option:hover { background: #fafafa; }
|
||||
.schedule-route-option[aria-pressed="true"] { border-color: #1677ff; background: #eff6ff; }
|
||||
.schedule-route-text { min-width: 0; }
|
||||
.schedule-route-name { display: block; font-size: 14px; font-weight: 500; line-height: 21px; overflow-wrap: anywhere; }
|
||||
.schedule-route-count { display: block; color: #8c8c8c; font-size: 12px; line-height: 18px; margin-top: 4px; }
|
||||
.schedule-selected-route { display: flex; align-items: center; gap: 12px; margin-bottom: 20px; }
|
||||
.schedule-selected-route h2 { overflow-wrap: anywhere; }
|
||||
.schedule-details { min-width: 0; }
|
||||
.schedule-days-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 280px), 1fr)); gap: 16px; }
|
||||
@media (min-width: 1024px) {
|
||||
.schedules-layout { grid-template-columns: 300px minmax(0, 1fr); }
|
||||
}
|
||||
@media (min-width: 1440px) {
|
||||
.schedules-layout { grid-template-columns: 340px minmax(0, 1fr); }
|
||||
}
|
||||
@@ -1,7 +1,9 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { Calendar, Plus, Edit, Trash2, ChevronDown, ChevronUp, AlertCircle } from 'lucide-react';
|
||||
import { routesApi, routeStopsApi, schedulesApi, holidaysApi } from '../services/api';
|
||||
import { PageLoader } from '../components/AnimatedComponents';
|
||||
import RouteBadge from '../components/RouteBadge';
|
||||
import './SchedulesPage.css';
|
||||
|
||||
const DAY_TYPES = [
|
||||
{ value: 'weekday', label: 'Будние дни', description: 'Пн-Пт' },
|
||||
@@ -30,6 +32,10 @@ export default function SchedulesPage() {
|
||||
const [editingSchedule, setEditingSchedule] = useState(null);
|
||||
const [upcomingHolidays, setUpcomingHolidays] = useState([]);
|
||||
const [expandedSchedule, setExpandedSchedule] = useState(null);
|
||||
const [detailsLoading, setDetailsLoading] = useState(false);
|
||||
const [detailsError, setDetailsError] = useState('');
|
||||
const detailsRequest = useRef(0);
|
||||
const activeRouteId = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
loadRoutes();
|
||||
@@ -37,11 +43,14 @@ export default function SchedulesPage() {
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedRoute) {
|
||||
loadSchedules(selectedRoute.id);
|
||||
loadRouteStops(selectedRoute.id);
|
||||
}
|
||||
}, [selectedRoute]);
|
||||
activeRouteId.current = selectedRoute?.id;
|
||||
setSchedules([]);
|
||||
setRouteStops([]);
|
||||
setExpandedSchedule(null);
|
||||
setShowEditor(false);
|
||||
if (selectedRoute) loadRouteDetails(selectedRoute.id);
|
||||
return () => { activeRouteId.current = null; detailsRequest.current += 1; };
|
||||
}, [selectedRoute?.id]);
|
||||
|
||||
const loadRoutes = async () => {
|
||||
try {
|
||||
@@ -57,23 +66,25 @@ export default function SchedulesPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const loadSchedules = async (routeId) => {
|
||||
const loadRouteDetails = async (routeId) => {
|
||||
if (routeId !== activeRouteId.current) return;
|
||||
const request = ++detailsRequest.current;
|
||||
setDetailsLoading(true);
|
||||
setDetailsError('');
|
||||
try {
|
||||
const response = await schedulesApi.getRouteSchedules(routeId);
|
||||
setSchedules(response.data);
|
||||
} catch (error) {
|
||||
console.error('Error loading schedules:', error);
|
||||
setSchedules([]);
|
||||
}
|
||||
};
|
||||
|
||||
const loadRouteStops = async (routeId) => {
|
||||
try {
|
||||
const response = await routeStopsApi.getRouteStops(routeId);
|
||||
setRouteStops(response.data);
|
||||
} catch (error) {
|
||||
console.error('Error loading route stops:', error);
|
||||
setRouteStops([]);
|
||||
const [scheduleResponse, stopsResponse] = await Promise.all([
|
||||
schedulesApi.getRouteSchedules(routeId),
|
||||
routeStopsApi.getRouteStops(routeId),
|
||||
]);
|
||||
if (request !== detailsRequest.current) return;
|
||||
setSchedules(scheduleResponse.data);
|
||||
setRouteStops(stopsResponse.data);
|
||||
} catch {
|
||||
if (request === detailsRequest.current) {
|
||||
setDetailsError('Не удалось загрузить расписание и остановки маршрута.');
|
||||
}
|
||||
} finally {
|
||||
if (request === detailsRequest.current) setDetailsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -106,7 +117,7 @@ export default function SchedulesPage() {
|
||||
|
||||
try {
|
||||
await schedulesApi.delete(id);
|
||||
loadSchedules(selectedRoute.id);
|
||||
loadRouteDetails(selectedRoute.id);
|
||||
} catch (error) {
|
||||
console.error('Error deleting schedule:', error);
|
||||
alert('Ошибка удаления расписания');
|
||||
@@ -116,7 +127,7 @@ export default function SchedulesPage() {
|
||||
const handleCopySchedule = async (fromDayType, toDayType) => {
|
||||
try {
|
||||
await schedulesApi.copy(selectedRoute.id, fromDayType, toDayType);
|
||||
loadSchedules(selectedRoute.id);
|
||||
loadRouteDetails(selectedRoute.id);
|
||||
} catch (error) {
|
||||
console.error('Error copying schedule:', error);
|
||||
alert('Ошибка копирования расписания');
|
||||
@@ -132,7 +143,7 @@ export default function SchedulesPage() {
|
||||
}
|
||||
setShowEditor(false);
|
||||
setEditingSchedule(null);
|
||||
loadSchedules(selectedRoute.id);
|
||||
loadRouteDetails(selectedRoute.id);
|
||||
} catch (error) {
|
||||
console.error('Error saving schedule:', error);
|
||||
alert(error.response?.data?.message || 'Ошибка сохранения расписания');
|
||||
@@ -177,11 +188,12 @@ export default function SchedulesPage() {
|
||||
|
||||
{/* Mobile: Route selector dropdown */}
|
||||
<div className="lg:hidden mb-4">
|
||||
<label className="label">Выберите маршрут</label>
|
||||
<label htmlFor="schedule-route" className="label">Выберите маршрут</label>
|
||||
<select
|
||||
id="schedule-route"
|
||||
value={selectedRoute?.id || ''}
|
||||
onChange={(e) => {
|
||||
const route = routes.find(r => r.id === parseInt(e.target.value));
|
||||
const route = routes.find(r => String(r.id) === e.target.value);
|
||||
setSelectedRoute(route);
|
||||
}}
|
||||
className="input"
|
||||
@@ -194,58 +206,41 @@ export default function SchedulesPage() {
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-4 gap-4 sm:gap-6">
|
||||
{/* Routes List - Desktop only */}
|
||||
<div className="card lg:col-span-1 hidden lg:block">
|
||||
<h2 className="text-lg font-semibold mb-4">Маршруты</h2>
|
||||
<div className="space-y-2 max-h-96 overflow-y-auto">
|
||||
<div className="schedules-layout">
|
||||
<section className="card schedule-route-panel hidden lg:block" aria-labelledby="schedule-routes-heading">
|
||||
<h2 id="schedule-routes-heading" className="text-lg font-semibold mb-4">Маршруты</h2>
|
||||
<div className="schedule-route-list">
|
||||
{routes.map(route => (
|
||||
<button
|
||||
key={route.id}
|
||||
onClick={() => setSelectedRoute(route)}
|
||||
className={`w-full text-left p-3 rounded-lg border transition-colors ${
|
||||
selectedRoute?.id === route.id
|
||||
? 'border-primary-500 bg-primary-50'
|
||||
: 'border-gray-200 hover:bg-gray-50'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center">
|
||||
<div
|
||||
className="w-8 h-8 rounded flex items-center justify-center text-white font-bold text-sm mr-2"
|
||||
style={{ backgroundColor: route.color }}
|
||||
>
|
||||
{route.route_number}
|
||||
</div>
|
||||
<div className="truncate">
|
||||
<div className="font-medium text-gray-900 truncate">{route.name}</div>
|
||||
<div className="text-xs text-gray-500">
|
||||
{route.stops_count || 0} остановок
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" key={route.id} onClick={() => setSelectedRoute(route)}
|
||||
aria-pressed={selectedRoute?.id === route.id} className="schedule-route-option">
|
||||
<RouteBadge route={route} />
|
||||
<span className="schedule-route-text">
|
||||
<span className="schedule-route-name">{route.name}</span>
|
||||
<span className="schedule-route-count">Остановок: {route.stops_count ?? 0}</span>
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Schedules Grid */}
|
||||
<div className="lg:col-span-3">
|
||||
<div className="schedule-details" aria-busy={detailsLoading}>
|
||||
{selectedRoute ? (
|
||||
<>
|
||||
<div className="flex items-center mb-4">
|
||||
<div
|
||||
className="w-8 h-8 sm:w-10 sm:h-10 rounded-lg flex items-center justify-center text-white font-bold text-sm sm:text-base mr-2 sm:mr-3"
|
||||
style={{ backgroundColor: selectedRoute.color }}
|
||||
>
|
||||
{selectedRoute.route_number}
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-base sm:text-xl font-semibold">{selectedRoute.name}</h2>
|
||||
<div className="schedule-selected-route">
|
||||
<RouteBadge route={selectedRoute} />
|
||||
<div className="min-w-0">
|
||||
<h2 className="text-base sm:text-xl font-semibold break-words">{selectedRoute.name}</h2>
|
||||
<p className="text-xs sm:text-sm text-gray-500">Расписание движения</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3 sm:gap-4">
|
||||
{detailsLoading ? <PageLoader /> : detailsError ? (
|
||||
<div className="card" role="alert">
|
||||
<p className="text-red-700 mb-3">{detailsError}</p>
|
||||
<button type="button" className="btn-secondary" onClick={() => loadRouteDetails(selectedRoute.id)}>Повторить</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="schedule-days-grid">
|
||||
{DAY_TYPES.map((dayType, index) => {
|
||||
const schedule = getScheduleForDayType(dayType.value);
|
||||
const isExpanded = expandedSchedule === dayType.value;
|
||||
@@ -340,7 +335,7 @@ export default function SchedulesPage() {
|
||||
<button
|
||||
key={dt.value}
|
||||
onClick={() => handleCopySchedule(dayType.value, dt.value)}
|
||||
className="text-xs bg-gray-100 hover:bg-gray-200 px-2 py-1 rounded"
|
||||
className="text-xs bg-gray-100 hover:bg-gray-200 px-2 py-1 rounded-lg"
|
||||
>
|
||||
{dt.label}
|
||||
</button>
|
||||
@@ -362,6 +357,7 @@ export default function SchedulesPage() {
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<div className="text-center py-12">
|
||||
|
||||
@@ -6,13 +6,15 @@ export default {
|
||||
],
|
||||
theme: {
|
||||
extend: {
|
||||
fontFamily: { sans: ['Roboto', 'Arial', 'sans-serif'] },
|
||||
colors: {
|
||||
primary: {
|
||||
50: '#f0f9ff',
|
||||
100: '#e0f2fe',
|
||||
500: '#0ea5e9',
|
||||
600: '#0284c7',
|
||||
700: '#0369a1',
|
||||
50: '#eff6ff',
|
||||
100: '#dbeafe',
|
||||
200: '#bfdbfe',
|
||||
500: '#1677ff',
|
||||
600: '#0958d9',
|
||||
700: '#003eb3',
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user