Add main menu page with role-based modules (#7)
- `/` is now the main menu; orders/receipts page moved to `/receipts` - Module registry (features/menu/modules.ts) drives both the menu tiles and route guards (ModuleRoute); admin sees every module - Menu: module search with Ctrl+K, profile with logout, responsive grid, palette matching the login page - «← Головне меню» link on receipts and cash registers pages; «Каси» moved from the receipts toolbar into the menu Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -4,6 +4,12 @@
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link
|
||||
rel="stylesheet"
|
||||
href="https://fonts.googleapis.com/css2?family=Manrope:wght@400;500;600;700&family=Unbounded:wght@500;600&display=swap"
|
||||
/>
|
||||
<title>Assistant System</title>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -2,8 +2,10 @@ import { Navigate, Route, Routes } from 'react-router-dom'
|
||||
|
||||
import { LoginPage } from '@/features/auth/LoginPage'
|
||||
import { ProtectedRoute } from '@/features/auth/ProtectedRoute'
|
||||
import { ModuleRoute } from '@/features/menu/ModuleRoute'
|
||||
import { CashRegistersPage } from '@/pages/CashRegistersPage'
|
||||
import { DashboardPage } from '@/pages/DashboardPage'
|
||||
import { MainMenuPage } from '@/pages/MainMenuPage'
|
||||
|
||||
export function AppRoutes() {
|
||||
return (
|
||||
@@ -11,8 +13,13 @@ export function AppRoutes() {
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
|
||||
<Route element={<ProtectedRoute />}>
|
||||
<Route path="/" element={<DashboardPage />} />
|
||||
<Route path="/cash-registers" element={<CashRegistersPage />} />
|
||||
<Route path="/" element={<MainMenuPage />} />
|
||||
<Route element={<ModuleRoute moduleId="receipts" />}>
|
||||
<Route path="/receipts" element={<DashboardPage />} />
|
||||
</Route>
|
||||
<Route element={<ModuleRoute moduleId="cash-registers" />}>
|
||||
<Route path="/cash-registers" element={<CashRegistersPage />} />
|
||||
</Route>
|
||||
</Route>
|
||||
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
import type { UserRole } from '@/api/types'
|
||||
|
||||
export const ROLE_LABEL: Record<UserRole, string> = {
|
||||
admin: 'Адміністратор',
|
||||
cashier: 'Касир',
|
||||
viewer: 'Спостерігач',
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { Navigate, Outlet } from 'react-router-dom'
|
||||
|
||||
import { useAuth } from '@/features/auth/useAuth'
|
||||
import { canAccess, getModule } from '@/features/menu/modules'
|
||||
|
||||
/** Пускает в модуль только роли из реестра; остальных возвращает в главное меню. */
|
||||
export function ModuleRoute({ moduleId }: { moduleId: string }) {
|
||||
const { user } = useAuth()
|
||||
if (!canAccess(user, getModule(moduleId).roles)) return <Navigate to="/" replace />
|
||||
return <Outlet />
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import type { User, UserRole } from '@/api/types'
|
||||
|
||||
/**
|
||||
* Реестр модулей главного меню. Единственное место, где модуль связывается
|
||||
* с путём и ролями: по нему рисуется меню и защищается роут модуля.
|
||||
*/
|
||||
export interface AppModule {
|
||||
id: string
|
||||
title: string
|
||||
description: string
|
||||
path: string
|
||||
/** Роли, которым модуль доступен; администратор видит всё и в списке не нужен. */
|
||||
roles: readonly UserRole[]
|
||||
/** `d` для stroke-иконки 24×24. */
|
||||
icon: string
|
||||
}
|
||||
|
||||
export const MODULES: readonly AppModule[] = [
|
||||
{
|
||||
id: 'receipts',
|
||||
title: 'Чеки',
|
||||
description: 'Замовлення з CRM, ЕТТН-чеки Checkbox і статуси Нової Пошти',
|
||||
path: '/receipts',
|
||||
roles: ['cashier', 'viewer'],
|
||||
icon: 'M6 3h12v18l-3-2-3 2-3-2-3 2z M9 8h6 M9 12h6 M9 16h3',
|
||||
},
|
||||
{
|
||||
id: 'cash-registers',
|
||||
title: 'Каси',
|
||||
description: 'Каси Checkbox, ліцензійні ключі та ключі Нової Пошти',
|
||||
path: '/cash-registers',
|
||||
roles: [],
|
||||
icon: 'M4 10h16v10H4z M7 10V4h10v6 M8 14h2 M14 14h2 M8 17h2 M14 17h2',
|
||||
},
|
||||
]
|
||||
|
||||
export function canAccess(user: User | null | undefined, roles: readonly UserRole[]): boolean {
|
||||
if (!user) return false
|
||||
return user.role === 'admin' || roles.includes(user.role)
|
||||
}
|
||||
|
||||
export function getModule(id: string): AppModule {
|
||||
const module = MODULES.find((m) => m.id === id)
|
||||
if (!module) throw new Error(`Невідомий модуль: ${id}`)
|
||||
return module
|
||||
}
|
||||
@@ -74,6 +74,19 @@ input {
|
||||
|
||||
/* --- Общие утилиты, используются в нескольких экранах --- */
|
||||
|
||||
/* Скрыт визуально, но читается скринридером (подписи к полям без видимого label). */
|
||||
.visually-hidden {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0 0 0 0);
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.page-center {
|
||||
min-height: 100svh;
|
||||
display: flex;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { useState } from 'react'
|
||||
import type { FormEvent } from 'react'
|
||||
import { Link, Navigate } from 'react-router-dom'
|
||||
import { Link } from 'react-router-dom'
|
||||
|
||||
import {
|
||||
checkCashRegister,
|
||||
@@ -55,8 +55,6 @@ export function CashRegistersPage() {
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [message, setMessage] = useState<{ tone: 'ok' | 'error'; text: string } | null>(null)
|
||||
|
||||
if (user && user.role !== 'admin') return <Navigate to="/" replace />
|
||||
|
||||
async function run(action: () => Promise<unknown>, okText: string) {
|
||||
setBusy(true)
|
||||
try {
|
||||
@@ -123,10 +121,12 @@ export function CashRegistersPage() {
|
||||
return (
|
||||
<div className="dashboard-shell">
|
||||
<header className="dashboard-topbar">
|
||||
<span className="dashboard-brand">Assistant System</span>
|
||||
<Link to="/" className="orders-link-btn">
|
||||
← До замовлень
|
||||
</Link>
|
||||
<div className="dashboard-topbar-start">
|
||||
<Link to="/" className="orders-link-btn">
|
||||
← Головне меню
|
||||
</Link>
|
||||
<span className="dashboard-brand">Assistant System</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="orders-body">
|
||||
|
||||
@@ -13,6 +13,12 @@
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.dashboard-topbar-start {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.dashboard-brand {
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
|
||||
@@ -5,6 +5,7 @@ import { Link } from 'react-router-dom'
|
||||
import { deleteOrder } from '@/api/orders'
|
||||
import { cancelReceipt, createReceipts } from '@/api/receipts'
|
||||
import '@/pages/DashboardPage.css'
|
||||
import { ROLE_LABEL } from '@/features/auth/roles'
|
||||
import { useAuth } from '@/features/auth/useAuth'
|
||||
import { OrderDetailModal } from '@/features/orders/OrderDetailModal'
|
||||
import type { Order, OrderTab } from '@/features/orders/types'
|
||||
@@ -13,12 +14,6 @@ import { defaultPrepayment, prepaymentMatches, toKopecks } from '@/features/rece
|
||||
import { CANCELLABLE, RECEIPT_STATUS } from '@/features/receipts/types'
|
||||
import type { ReceiptRequestItem } from '@/features/receipts/types'
|
||||
|
||||
const ROLE_LABEL: Record<string, string> = {
|
||||
admin: 'Адміністратор',
|
||||
cashier: 'Касир',
|
||||
viewer: 'Спостерігач',
|
||||
}
|
||||
|
||||
const TABS: { key: OrderTab; label: string }[] = [
|
||||
{ key: 'no_receipt', label: 'Без чека' },
|
||||
{ key: 'has_receipt', label: 'Виписані чеки' },
|
||||
@@ -174,10 +169,15 @@ export function DashboardPage() {
|
||||
return (
|
||||
<div className="dashboard-shell">
|
||||
<header className="dashboard-topbar">
|
||||
<span className="dashboard-brand">Assistant System</span>
|
||||
<div className="dashboard-topbar-start">
|
||||
<Link to="/" className="orders-link-btn">
|
||||
← Головне меню
|
||||
</Link>
|
||||
<span className="dashboard-brand">Assistant System</span>
|
||||
</div>
|
||||
<div className="dashboard-user">
|
||||
<span>{user?.full_name}</span>
|
||||
<span className="dashboard-role">{user ? (ROLE_LABEL[user.role] ?? user.role) : ''}</span>
|
||||
<span className="dashboard-role">{user ? ROLE_LABEL[user.role] : ''}</span>
|
||||
<button type="button" className="dashboard-logout" onClick={() => void logout()}>
|
||||
Вийти
|
||||
</button>
|
||||
@@ -188,11 +188,6 @@ export function DashboardPage() {
|
||||
<div className="orders-toolbar">
|
||||
<h2>Замовлення</h2>
|
||||
<div className="orders-toolbar-actions">
|
||||
{user?.role === 'admin' && (
|
||||
<Link to="/cash-registers" className="orders-link-btn">
|
||||
Каси
|
||||
</Link>
|
||||
)}
|
||||
{tab === 'no_receipt' && canFiscalize && (
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -0,0 +1,458 @@
|
||||
/* Главное меню: плитки модулей. Палитра и «стекло» — как на странице входа. */
|
||||
|
||||
.menu-page {
|
||||
--menu-accent: #7c3aed;
|
||||
--menu-gradient: linear-gradient(135deg, var(--color-primary), var(--menu-accent));
|
||||
--menu-glass: rgba(255, 255, 255, 0.72);
|
||||
--menu-glass-border: rgba(255, 255, 255, 0.6);
|
||||
--menu-tint: color-mix(in srgb, var(--color-primary) 10%, transparent);
|
||||
--menu-shadow: 0 1px 2px rgba(16, 24, 40, 0.06);
|
||||
--menu-shadow-hover: 0 20px 40px -16px color-mix(in srgb, var(--color-primary) 45%, transparent);
|
||||
--menu-font-display: 'Unbounded', var(--font-sans);
|
||||
--menu-font-body: 'Manrope', var(--font-sans);
|
||||
|
||||
min-height: 100svh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 44px;
|
||||
padding: 32px 64px 48px;
|
||||
/* Тот же фон, что на странице входа. */
|
||||
background:
|
||||
radial-gradient(1200px 600px at 10% -10%, rgba(37, 99, 235, 0.14), transparent 60%),
|
||||
radial-gradient(900px 500px at 110% 110%, rgba(124, 58, 237, 0.14), transparent 60%),
|
||||
var(--color-bg);
|
||||
background-attachment: fixed;
|
||||
color: var(--color-text);
|
||||
font-family: var(--menu-font-body);
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.menu-page {
|
||||
--menu-accent: #a78bfa;
|
||||
--menu-glass: rgba(23, 26, 33, 0.7);
|
||||
--menu-glass-border: rgba(255, 255, 255, 0.08);
|
||||
--menu-tint: color-mix(in srgb, var(--color-primary) 18%, transparent);
|
||||
--menu-shadow: 0 1px 2px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
}
|
||||
|
||||
/* Стеклянная поверхность, как карточка входа. */
|
||||
.menu-search,
|
||||
.menu-profile,
|
||||
.menu-tile {
|
||||
background: var(--menu-glass);
|
||||
border: 1px solid var(--menu-glass-border);
|
||||
box-shadow: var(--menu-shadow);
|
||||
backdrop-filter: blur(18px) saturate(140%);
|
||||
-webkit-backdrop-filter: blur(18px) saturate(140%);
|
||||
}
|
||||
|
||||
/* --- Шапка --- */
|
||||
|
||||
.menu-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 24px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.menu-brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.menu-logo {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--menu-gradient);
|
||||
color: #fff;
|
||||
box-shadow: 0 10px 24px -8px var(--color-primary);
|
||||
}
|
||||
|
||||
.menu-brand-name {
|
||||
font-family: var(--menu-font-display);
|
||||
font-weight: 600;
|
||||
font-size: 18px;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
.menu-header-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.menu-search {
|
||||
width: 380px;
|
||||
height: 48px;
|
||||
padding: 0 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
border-radius: 14px;
|
||||
color: var(--color-text-muted);
|
||||
transition:
|
||||
border-color 0.15s ease,
|
||||
box-shadow 0.15s ease;
|
||||
}
|
||||
|
||||
.menu-search:focus-within {
|
||||
border-color: var(--color-primary);
|
||||
box-shadow: 0 0 0 4px color-mix(in srgb, var(--color-primary) 18%, transparent);
|
||||
}
|
||||
|
||||
.menu-search input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
border: 0;
|
||||
outline: none;
|
||||
background: transparent;
|
||||
color: var(--color-text);
|
||||
font: inherit;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.menu-kbd {
|
||||
font-family: inherit;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
padding: 3px 7px;
|
||||
border-radius: 6px;
|
||||
background: var(--menu-tint);
|
||||
color: var(--color-text-muted);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.menu-profile {
|
||||
height: 48px;
|
||||
padding: 0 6px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
border-radius: 14px;
|
||||
}
|
||||
|
||||
.menu-avatar {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
flex-shrink: 0;
|
||||
border-radius: 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--menu-gradient);
|
||||
color: #fff;
|
||||
font-weight: 700;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.menu-profile-text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.menu-profile-name {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.menu-profile-role {
|
||||
font-size: 12px;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.menu-logout {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border: 0;
|
||||
border-radius: 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: transparent;
|
||||
color: var(--color-text-muted);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.menu-logout:hover {
|
||||
background: color-mix(in srgb, var(--color-text) 8%, transparent);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.menu-logout:focus-visible {
|
||||
outline: 2px solid var(--color-primary);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* --- Приветствие --- */
|
||||
|
||||
.menu-main {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 44px;
|
||||
}
|
||||
|
||||
.menu-hero {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: space-between;
|
||||
gap: 40px;
|
||||
}
|
||||
|
||||
.menu-hero-title {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.menu-eyebrow {
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
.menu-hero h1 {
|
||||
font-family: var(--menu-font-display);
|
||||
font-weight: 600;
|
||||
font-size: 54px;
|
||||
line-height: 1.05;
|
||||
letter-spacing: -0.03em;
|
||||
background: linear-gradient(135deg, var(--color-text) 30%, var(--color-primary));
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
color: transparent;
|
||||
}
|
||||
|
||||
.menu-hero p {
|
||||
max-width: 360px;
|
||||
font-size: 16px;
|
||||
line-height: 1.55;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
/* --- Плитки модулей --- */
|
||||
|
||||
.menu-grid {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.menu-tile {
|
||||
height: 100%;
|
||||
min-height: 220px;
|
||||
padding: 26px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
border-radius: 22px;
|
||||
color: var(--color-text);
|
||||
text-decoration: none;
|
||||
transition:
|
||||
transform 0.18s ease,
|
||||
box-shadow 0.18s ease,
|
||||
border-color 0.18s ease;
|
||||
}
|
||||
|
||||
.menu-tile:hover {
|
||||
transform: translateY(-3px);
|
||||
box-shadow: var(--menu-shadow-hover);
|
||||
border-color: color-mix(in srgb, var(--color-primary) 55%, transparent);
|
||||
}
|
||||
|
||||
.menu-tile:focus-visible {
|
||||
outline: 2px solid var(--color-primary);
|
||||
outline-offset: 3px;
|
||||
}
|
||||
|
||||
.menu-tile-top {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.menu-tile-icon {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
border-radius: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--menu-tint);
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
.menu-tile-num {
|
||||
font-family: var(--menu-font-display);
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.menu-tile-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.menu-tile-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
font-family: var(--menu-font-display);
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
.menu-tile-arrow {
|
||||
display: flex;
|
||||
color: var(--color-primary);
|
||||
opacity: 0.35;
|
||||
transform: translateX(-4px);
|
||||
transition:
|
||||
opacity 0.18s ease,
|
||||
transform 0.18s ease;
|
||||
}
|
||||
|
||||
.menu-tile:hover .menu-tile-arrow,
|
||||
.menu-tile:focus-visible .menu-tile-arrow {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
|
||||
.menu-tile-desc {
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.menu-empty {
|
||||
padding: 48px 0;
|
||||
text-align: center;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.menu-tile,
|
||||
.menu-tile-arrow {
|
||||
transition: none;
|
||||
}
|
||||
|
||||
.menu-tile:hover {
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* --- Адаптив --- */
|
||||
|
||||
@media (max-width: 1200px) {
|
||||
.menu-grid {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 960px) {
|
||||
.menu-page {
|
||||
padding: 24px 32px 40px;
|
||||
}
|
||||
|
||||
.menu-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.menu-hero {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.menu-hero h1 {
|
||||
font-size: 40px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.menu-page {
|
||||
gap: 22px;
|
||||
padding: 20px 16px 28px;
|
||||
}
|
||||
|
||||
.menu-main {
|
||||
gap: 22px;
|
||||
}
|
||||
|
||||
.menu-header-actions {
|
||||
display: contents;
|
||||
}
|
||||
|
||||
/* Поиск уходит на отдельную строку под шапкой, на всю ширину. */
|
||||
.menu-search {
|
||||
order: 1;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.menu-kbd,
|
||||
.menu-profile-text,
|
||||
.menu-hero p {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.menu-brand-name {
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.menu-eyebrow {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.menu-hero-title {
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.menu-hero h1 {
|
||||
font-size: 30px;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.menu-grid {
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.menu-tile {
|
||||
min-height: 0;
|
||||
padding: 16px;
|
||||
gap: 12px;
|
||||
border-radius: 18px;
|
||||
}
|
||||
|
||||
.menu-tile-icon {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.menu-tile-num,
|
||||
.menu-tile-arrow,
|
||||
.menu-tile-desc {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.menu-tile-title {
|
||||
font-size: 15px;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
import { useEffect, useId, useMemo, useRef, useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
|
||||
import '@/pages/MainMenuPage.css'
|
||||
import { ROLE_LABEL } from '@/features/auth/roles'
|
||||
import { useAuth } from '@/features/auth/useAuth'
|
||||
import { canAccess, MODULES } from '@/features/menu/modules'
|
||||
|
||||
function initials(fullName: string): string {
|
||||
const letters = fullName
|
||||
.split(/\s+/)
|
||||
.filter(Boolean)
|
||||
.slice(0, 2)
|
||||
.map((word) => word[0])
|
||||
.join('')
|
||||
return letters.toUpperCase() || '?'
|
||||
}
|
||||
|
||||
function Icon({ d, size }: { d: string; size: number }) {
|
||||
return (
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.8"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d={d} />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function MainMenuPage() {
|
||||
const { user, logout } = useAuth()
|
||||
const [query, setQuery] = useState('')
|
||||
const searchRef = useRef<HTMLInputElement>(null)
|
||||
const searchId = useId()
|
||||
|
||||
const available = useMemo(() => MODULES.filter((module) => canAccess(user, module.roles)), [user])
|
||||
const visible = useMemo(() => {
|
||||
const q = query.trim().toLowerCase()
|
||||
if (!q) return available
|
||||
return available.filter(
|
||||
(module) => module.title.toLowerCase().includes(q) || module.description.toLowerCase().includes(q),
|
||||
)
|
||||
}, [available, query])
|
||||
|
||||
// Ctrl+K / ⌘K — фокус на поиске модулей.
|
||||
useEffect(() => {
|
||||
function onKeyDown(event: KeyboardEvent) {
|
||||
if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 'k') {
|
||||
event.preventDefault()
|
||||
searchRef.current?.focus()
|
||||
}
|
||||
}
|
||||
window.addEventListener('keydown', onKeyDown)
|
||||
return () => window.removeEventListener('keydown', onKeyDown)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className="menu-page">
|
||||
<header className="menu-header">
|
||||
<div className="menu-brand">
|
||||
<span className="menu-logo">
|
||||
<Icon d="M4 4h7v7H4z M13 13h7v7h-7z M13 4h7v7h-7z" size={22} />
|
||||
</span>
|
||||
<span className="menu-brand-name">Assistant System</span>
|
||||
</div>
|
||||
|
||||
<div className="menu-header-actions">
|
||||
<div className="menu-search">
|
||||
<label htmlFor={searchId} className="visually-hidden">
|
||||
Пошук модулів
|
||||
</label>
|
||||
<Icon d="M11 18a7 7 0 1 0 0-14 7 7 0 0 0 0 14z M20 20l-4-4" size={18} />
|
||||
<input
|
||||
ref={searchRef}
|
||||
id={searchId}
|
||||
type="search"
|
||||
placeholder="Знайти модуль…"
|
||||
autoComplete="off"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Escape') setQuery('')
|
||||
}}
|
||||
/>
|
||||
<kbd className="menu-kbd">Ctrl K</kbd>
|
||||
</div>
|
||||
|
||||
{user && (
|
||||
<div className="menu-profile">
|
||||
<span className="menu-avatar" aria-hidden="true">
|
||||
{initials(user.full_name)}
|
||||
</span>
|
||||
<span className="menu-profile-text">
|
||||
<span className="menu-profile-name">{user.full_name}</span>
|
||||
<span className="menu-profile-role">{ROLE_LABEL[user.role]}</span>
|
||||
</span>
|
||||
<button type="button" className="menu-logout" onClick={() => void logout()} aria-label="Вийти">
|
||||
<Icon d="M15 4h3a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2h-3 M10 17l5-5-5-5 M15 12H4" size={18} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="menu-main">
|
||||
<section className="menu-hero">
|
||||
<div className="menu-hero-title">
|
||||
<span className="menu-eyebrow">Головне меню</span>
|
||||
<h1>З чого почнемо сьогодні?</h1>
|
||||
</div>
|
||||
<p>Оберіть модуль, щоб перейти до роботи.</p>
|
||||
</section>
|
||||
|
||||
<nav aria-label="Модулі">
|
||||
{visible.length > 0 ? (
|
||||
<ul className="menu-grid">
|
||||
{visible.map((module, index) => (
|
||||
<li key={module.id}>
|
||||
<Link to={module.path} className="menu-tile">
|
||||
<span className="menu-tile-top">
|
||||
<span className="menu-tile-icon">
|
||||
<Icon d={module.icon} size={26} />
|
||||
</span>
|
||||
<span className="menu-tile-num" aria-hidden="true">
|
||||
{String(index + 1).padStart(2, '0')}
|
||||
</span>
|
||||
</span>
|
||||
<span className="menu-tile-body">
|
||||
<span className="menu-tile-title">
|
||||
{module.title}
|
||||
<span className="menu-tile-arrow">
|
||||
<Icon d="M5 12h14 M13 6l6 6-6 6" size={20} />
|
||||
</span>
|
||||
</span>
|
||||
<span className="menu-tile-desc">{module.description}</span>
|
||||
</span>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<p className="menu-empty">
|
||||
{available.length === 0 ? 'Для вашої ролі поки немає доступних модулів' : 'Нічого не знайдено'}
|
||||
</p>
|
||||
)}
|
||||
</nav>
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user