feat(mobile): mobile-first responsive architecture, bottom navigation, and tablet overhaul
This commit is contained in:
parent
a0d995c1f1
commit
42d48618ec
19 changed files with 1044 additions and 403 deletions
189
e2e/12-mobile-first.spec.ts
Normal file
189
e2e/12-mobile-first.spec.ts
Normal file
|
|
@ -0,0 +1,189 @@
|
||||||
|
import { test, expect } from '@playwright/test';
|
||||||
|
|
||||||
|
test.describe('Scenario 12: Mobile-First and Tablet Architecture Overhaul', () => {
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
await page.goto('/');
|
||||||
|
await page.waitForLoadState('domcontentloaded');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('1. Mobile viewport (< 768px) automatically displays bottom navigation', async ({ page, viewport }) => {
|
||||||
|
const isMobileViewport = viewport && viewport.width < 768;
|
||||||
|
|
||||||
|
if (isMobileViewport) {
|
||||||
|
// Bottom navigation must be visible
|
||||||
|
const bottomNav = page.locator('[data-testid="bottom-nav"]');
|
||||||
|
await expect(bottomNav).toBeVisible({ timeout: 10000 });
|
||||||
|
|
||||||
|
// Sidebar must NOT be displayed
|
||||||
|
const sidebar = page.locator('aside');
|
||||||
|
await expect(sidebar).not.toBeVisible();
|
||||||
|
|
||||||
|
// Topbar must have mobile controls (logo + section title + menu button)
|
||||||
|
await expect(page.locator('[data-testid="mobile-menu-btn"]')).toBeVisible();
|
||||||
|
await expect(page.locator('[data-testid="mobile-current-title"]')).toBeVisible();
|
||||||
|
} else {
|
||||||
|
// Desktop / tablet view: sidebar visible, bottom nav not visible
|
||||||
|
const sidebar = page.locator('[data-testid="sidebar"]');
|
||||||
|
await expect(sidebar).toBeVisible({ timeout: 10000 });
|
||||||
|
const bottomNav = page.locator('[data-testid="bottom-nav"]');
|
||||||
|
await expect(bottomNav).not.toBeVisible();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('2. Mobile bottom nav tabs are functional with >= 44x44px touch targets', async ({ page, viewport }) => {
|
||||||
|
const isMobileViewport = viewport && viewport.width < 768;
|
||||||
|
if (!isMobileViewport) return;
|
||||||
|
|
||||||
|
// Check touch target heights
|
||||||
|
const catalogTab = page.locator('[data-testid="bottom-nav-catalog"]');
|
||||||
|
await expect(catalogTab).toBeVisible();
|
||||||
|
const box = await catalogTab.boundingBox();
|
||||||
|
expect(box).not.toBeNull();
|
||||||
|
if (box) {
|
||||||
|
expect(box.height).toBeGreaterThanOrEqual(44);
|
||||||
|
expect(box.width).toBeGreaterThanOrEqual(44);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Navigate to Tasks via bottom nav
|
||||||
|
const tasksTab = page.locator('[data-testid="bottom-nav-tasks"]');
|
||||||
|
await tasksTab.click();
|
||||||
|
await page.waitForURL('**/tasks');
|
||||||
|
await expect(page.locator('h1, h2').first()).toBeVisible();
|
||||||
|
|
||||||
|
// Navigate to Communications via bottom nav
|
||||||
|
const commsTab = page.locator('[data-testid="bottom-nav-communications"]');
|
||||||
|
await commsTab.click();
|
||||||
|
await page.waitForURL('**/communications');
|
||||||
|
await expect(page.locator('h1, h2').first()).toBeVisible();
|
||||||
|
|
||||||
|
// Return to Catalog
|
||||||
|
await catalogTab.click();
|
||||||
|
await page.waitForURL('**/');
|
||||||
|
await expect(page.locator('[data-testid="modules-catalog-container"]')).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('3. Mobile menu drawer opens and allows switching themes and profiles', async ({ page, viewport }) => {
|
||||||
|
const isMobileViewport = viewport && viewport.width < 768;
|
||||||
|
if (!isMobileViewport) return;
|
||||||
|
|
||||||
|
// Open via "Ещё" tab in bottom nav
|
||||||
|
const moreTab = page.locator('[data-testid="bottom-nav-more"]');
|
||||||
|
await moreTab.click();
|
||||||
|
|
||||||
|
// Drawer must be visible
|
||||||
|
const drawer = page.locator('[data-testid="mobile-menu-drawer"]');
|
||||||
|
await expect(drawer).toBeVisible();
|
||||||
|
|
||||||
|
// Check drawer content: profiles, roles, themes, reset
|
||||||
|
await expect(drawer).toContainText('Управление платформой');
|
||||||
|
await expect(drawer).toContainText('Отрасль и профиль организации');
|
||||||
|
await expect(drawer).toContainText('Тема оформления');
|
||||||
|
await expect(page.locator('[data-testid="mobile-reset-db-btn"]')).toBeVisible();
|
||||||
|
|
||||||
|
// Switch theme to dark
|
||||||
|
const darkThemeBtn = drawer.getByRole('button', { name: /Тёмная/i });
|
||||||
|
await darkThemeBtn.click();
|
||||||
|
const themeAttr = await page.evaluate(() => document.documentElement.getAttribute('data-theme'));
|
||||||
|
expect(themeAttr).toBe('dark');
|
||||||
|
|
||||||
|
// Close drawer
|
||||||
|
const closeBtn = page.locator('[data-testid="close-mobile-menu"]');
|
||||||
|
await closeBtn.click();
|
||||||
|
await expect(drawer).not.toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('4. Zero horizontal overflow across all viewports', async ({ page }) => {
|
||||||
|
// Check main document scroll width vs client width
|
||||||
|
const isOverflowing = await page.evaluate(() => {
|
||||||
|
return document.documentElement.scrollWidth > document.documentElement.clientWidth;
|
||||||
|
});
|
||||||
|
expect(isOverflowing).toBe(false);
|
||||||
|
|
||||||
|
// Verify on /crm
|
||||||
|
await page.goto('/crm');
|
||||||
|
await page.waitForLoadState('domcontentloaded');
|
||||||
|
const isCrmOverflowing = await page.evaluate(() => {
|
||||||
|
return document.documentElement.scrollWidth > document.documentElement.clientWidth;
|
||||||
|
});
|
||||||
|
expect(isCrmOverflowing).toBe(false);
|
||||||
|
|
||||||
|
// Verify on /settings/entities
|
||||||
|
await page.goto('/settings/entities');
|
||||||
|
await page.waitForLoadState('domcontentloaded');
|
||||||
|
const isSettingsOverflowing = await page.evaluate(() => {
|
||||||
|
return document.documentElement.scrollWidth > document.documentElement.clientWidth;
|
||||||
|
});
|
||||||
|
expect(isSettingsOverflowing).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('5. DynamicTable renders responsive card list on mobile screens', async ({ page, viewport }) => {
|
||||||
|
await page.goto('/crm');
|
||||||
|
await page.waitForLoadState('domcontentloaded');
|
||||||
|
|
||||||
|
const isMobileViewport = viewport && viewport.width < 768;
|
||||||
|
if (isMobileViewport) {
|
||||||
|
// Mobile card list must be visible
|
||||||
|
const mobileCards = page.locator('[data-testid="mobile-table-cards"]');
|
||||||
|
if (await mobileCards.isVisible()) {
|
||||||
|
await expect(mobileCards).toBeVisible();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('6. FormBuilder supports touch-friendly up and down field reordering', async ({ page }) => {
|
||||||
|
await page.goto('/settings/entities/contact/form-builder');
|
||||||
|
await page.waitForLoadState('domcontentloaded');
|
||||||
|
|
||||||
|
// Locate field move buttons
|
||||||
|
const moveDownBtn = page.locator('[data-testid^="move-field-down-"]').first();
|
||||||
|
|
||||||
|
if (await moveDownBtn.isVisible()) {
|
||||||
|
await expect(moveDownBtn).toBeVisible();
|
||||||
|
// Verify button has accessible touch target
|
||||||
|
const box = await moveDownBtn.boundingBox();
|
||||||
|
expect(box).not.toBeNull();
|
||||||
|
if (box) {
|
||||||
|
expect(box.height).toBeGreaterThanOrEqual(28);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('7. Full Russian localization check (zero untranslated system labels)', async ({ page }) => {
|
||||||
|
// Check home page headings and buttons
|
||||||
|
const bodyText = await page.locator('body').innerText();
|
||||||
|
|
||||||
|
// Forbidden English words in visible platform headers
|
||||||
|
expect(bodyText).not.toContain('Business OS');
|
||||||
|
expect(bodyText).not.toContain('Role: Owner');
|
||||||
|
expect(bodyText).not.toContain('Quick Action');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('8. All 3 brand themes (soft-light, light, dark) apply and persist', async ({ page }) => {
|
||||||
|
// Set light theme
|
||||||
|
await page.evaluate(() => {
|
||||||
|
window.localStorage.setItem('krona_theme_mode', 'light');
|
||||||
|
document.documentElement.setAttribute('data-theme', 'light');
|
||||||
|
});
|
||||||
|
await page.reload();
|
||||||
|
let currentTheme = await page.evaluate(() => document.documentElement.getAttribute('data-theme'));
|
||||||
|
expect(currentTheme).toBe('light');
|
||||||
|
|
||||||
|
// Set dark theme
|
||||||
|
await page.evaluate(() => {
|
||||||
|
window.localStorage.setItem('krona_theme_mode', 'dark');
|
||||||
|
document.documentElement.setAttribute('data-theme', 'dark');
|
||||||
|
});
|
||||||
|
await page.reload();
|
||||||
|
currentTheme = await page.evaluate(() => document.documentElement.getAttribute('data-theme'));
|
||||||
|
expect(currentTheme).toBe('dark');
|
||||||
|
|
||||||
|
// Restore soft-light
|
||||||
|
await page.evaluate(() => {
|
||||||
|
window.localStorage.setItem('krona_theme_mode', 'soft-light');
|
||||||
|
document.documentElement.setAttribute('data-theme', 'soft-light');
|
||||||
|
});
|
||||||
|
await page.reload();
|
||||||
|
currentTheme = await page.evaluate(() => document.documentElement.getAttribute('data-theme'));
|
||||||
|
expect(currentTheme).toBe('soft-light');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -16,21 +16,21 @@ export default defineConfig({
|
||||||
},
|
},
|
||||||
projects: [
|
projects: [
|
||||||
{
|
{
|
||||||
name: 'desktop-1440',
|
name: 'mobile-360',
|
||||||
use: {
|
use: {
|
||||||
viewport: { width: 1440, height: 900 },
|
viewport: { width: 360, height: 800 },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'desktop-1280',
|
name: 'mobile-390',
|
||||||
use: {
|
use: {
|
||||||
viewport: { width: 1280, height: 800 },
|
viewport: { width: 390, height: 844 },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'tablet-1024',
|
name: 'mobile-430',
|
||||||
use: {
|
use: {
|
||||||
viewport: { width: 1024, height: 768 },
|
viewport: { width: 430, height: 932 },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
@ -40,9 +40,21 @@ export default defineConfig({
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'mobile-390',
|
name: 'tablet-1024',
|
||||||
use: {
|
use: {
|
||||||
viewport: { width: 390, height: 844 },
|
viewport: { width: 1024, height: 768 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'desktop-1280',
|
||||||
|
use: {
|
||||||
|
viewport: { width: 1280, height: 800 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'desktop-1440',
|
||||||
|
use: {
|
||||||
|
viewport: { width: 1440, height: 900 },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import React, { useEffect } from 'react';
|
import React, { useEffect } from 'react';
|
||||||
import { Outlet } from 'react-router-dom';
|
import { Outlet } from 'react-router-dom';
|
||||||
import { useDemoStore } from '../../mock/store';
|
import { useDemoStore } from '../../mock/store';
|
||||||
import { Sidebar } from '../shell/Sidebar';
|
import { Sidebar } from '../shell/Sidebar';
|
||||||
|
|
@ -8,7 +8,8 @@ import { BottomNav } from '../shell/BottomNav';
|
||||||
import { CommandPalette } from '../shell/CommandPalette';
|
import { CommandPalette } from '../shell/CommandPalette';
|
||||||
import { QuickActionsModal } from '../shell/QuickActionsModal';
|
import { QuickActionsModal } from '../shell/QuickActionsModal';
|
||||||
import { NotificationsDrawer } from '../shell/NotificationsDrawer';
|
import { NotificationsDrawer } from '../shell/NotificationsDrawer';
|
||||||
import { Drawer, Button } from '../../shared/ui';
|
import { Drawer } from '../../shared/ui';
|
||||||
|
import { useResponsive } from '../../shared/lib/useResponsive';
|
||||||
|
|
||||||
export const AppShell: React.FC = () => {
|
export const AppShell: React.FC = () => {
|
||||||
const {
|
const {
|
||||||
|
|
@ -20,61 +21,80 @@ export const AppShell: React.FC = () => {
|
||||||
activeLocation,
|
activeLocation,
|
||||||
} = useDemoStore();
|
} = useDemoStore();
|
||||||
|
|
||||||
|
const { isMobile, isTablet, isDesktop } = useResponsive();
|
||||||
const profile = getActiveProfile();
|
const profile = getActiveProfile();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
document.documentElement.setAttribute('data-theme', theme);
|
document.documentElement.setAttribute('data-theme', theme);
|
||||||
}, [theme]);
|
}, [theme]);
|
||||||
|
|
||||||
// Viewport mode constraints
|
// Layout resolution:
|
||||||
|
// 1. Real screen size is paramount:
|
||||||
|
// - If actual viewport < 768px -> always Mobile Shell.
|
||||||
|
// - If actual viewport 768-1199px -> always Tablet Shell.
|
||||||
|
// 2. Demo emulation frame is only applied when on real desktop (>= 1200px) and user explicitly chose a demo mode.
|
||||||
|
const isEmulatedOnDesktop = isDesktop && viewportMode !== 'desktop';
|
||||||
|
const isEffectiveMobile = isMobile || (isDesktop && viewportMode === 'mobile');
|
||||||
|
const isEffectiveTablet = isTablet || (isDesktop && (viewportMode === 'tablet-portrait' || viewportMode === 'tablet-landscape'));
|
||||||
|
|
||||||
const getViewportContainerStyles = () => {
|
const getViewportContainerStyles = () => {
|
||||||
|
if (!isEmulatedOnDesktop) {
|
||||||
|
return 'w-full min-h-screen';
|
||||||
|
}
|
||||||
switch (viewportMode) {
|
switch (viewportMode) {
|
||||||
case 'mobile':
|
case 'mobile':
|
||||||
return 'max-w-[420px] h-[880px] my-6 border-8 border-[#3A3D35] rounded-[36px] overflow-hidden shadow-2xl relative';
|
return 'max-w-[420px] h-[880px] my-6 border-8 border-krona-border rounded-[36px] overflow-hidden shadow-2xl relative';
|
||||||
case 'tablet-portrait':
|
case 'tablet-portrait':
|
||||||
return 'max-w-[820px] h-[1050px] my-6 border-8 border-[#3A3D35] rounded-[28px] overflow-hidden shadow-2xl relative';
|
return 'max-w-[820px] h-[1050px] my-6 border-8 border-krona-border rounded-[28px] overflow-hidden shadow-2xl relative';
|
||||||
case 'tablet-landscape':
|
case 'tablet-landscape':
|
||||||
return 'max-w-[1180px] h-[800px] my-6 border-8 border-[#3A3D35] rounded-[24px] overflow-hidden shadow-2xl relative';
|
return 'max-w-[1180px] h-[800px] my-6 border-8 border-krona-border rounded-[24px] overflow-hidden shadow-2xl relative';
|
||||||
case 'desktop':
|
|
||||||
default:
|
default:
|
||||||
return 'w-full min-h-screen';
|
return 'w-full min-h-screen';
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const isEmulatedDevice = viewportMode !== 'desktop';
|
|
||||||
const showMobileNav = viewportMode === 'mobile';
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`min-h-screen bg-[#191B18] flex items-center justify-center ${isEmulatedDevice ? 'p-4' : ''}`}>
|
<div
|
||||||
<div className={`w-full bg-krona-app text-krona-primary flex flex-col transition-all duration-300 ${getViewportContainerStyles()}`}>
|
data-testid="app-shell-root"
|
||||||
{/* App Main Shell Container */}
|
className={`min-h-screen bg-krona-app text-krona-primary flex items-center justify-center ${
|
||||||
|
isEmulatedOnDesktop ? 'p-4' : ''
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={`w-full bg-krona-workspace text-krona-primary flex flex-col transition-all duration-200 ${getViewportContainerStyles()}`}
|
||||||
|
>
|
||||||
|
{/* Main Shell Layout */}
|
||||||
<div className="flex-1 flex overflow-hidden w-full h-full relative">
|
<div className="flex-1 flex overflow-hidden w-full h-full relative">
|
||||||
{/* Left Sidebar (hidden in mobile mode) */}
|
{/* Left Sidebar: hidden on mobile, rendered on tablet & desktop */}
|
||||||
{!showMobileNav && <Sidebar />}
|
{!isEffectiveMobile && <Sidebar isTabletCompact={isEffectiveTablet} />}
|
||||||
|
|
||||||
{/* Center Work Area */}
|
{/* Center Work Area */}
|
||||||
<div className="flex-1 flex flex-col min-w-0 overflow-hidden bg-krona-workspace">
|
<div className="flex-1 flex flex-col min-w-0 overflow-hidden bg-krona-workspace">
|
||||||
<Topbar />
|
<Topbar />
|
||||||
|
|
||||||
{/* Main Content Router Outlet */}
|
{/* Main Content Router Outlet */}
|
||||||
<main className={`flex-1 overflow-y-auto ${showMobileNav ? 'pb-20' : 'pb-8'} p-4 md:p-6`}>
|
<main
|
||||||
|
className={`flex-1 overflow-y-auto ${
|
||||||
|
isEffectiveMobile ? 'pb-24' : 'pb-8'
|
||||||
|
} p-3 sm:p-4 md:p-6`}
|
||||||
|
>
|
||||||
<Outlet />
|
<Outlet />
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Right Command Panel (Desktop and Tablet-Landscape) */}
|
{/* Right Command Panel (Only on full Desktop) */}
|
||||||
{viewportMode === 'desktop' && <RightCommandPanel />}
|
{!isEffectiveMobile && !isEffectiveTablet && <RightCommandPanel />}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Mobile Bottom Navigation */}
|
{/* Mobile Bottom Navigation (Always present on mobile) */}
|
||||||
{showMobileNav && <BottomNav />}
|
{isEffectiveMobile && <BottomNav />}
|
||||||
|
|
||||||
{/* Global Modals & Drawers */}
|
{/* Global Modals & Drawers */}
|
||||||
<CommandPalette />
|
<CommandPalette />
|
||||||
<QuickActionsModal />
|
<QuickActionsModal />
|
||||||
<NotificationsDrawer />
|
<NotificationsDrawer />
|
||||||
|
|
||||||
{/* Standalone AI Drawer (triggered via Topbar ✦ button on any screen) */}
|
{/* Standalone AI Drawer */}
|
||||||
<Drawer
|
<Drawer
|
||||||
isOpen={isAiDrawerOpen}
|
isOpen={isAiDrawerOpen}
|
||||||
onClose={() => setIsAiDrawerOpen(false)}
|
onClose={() => setIsAiDrawerOpen(false)}
|
||||||
|
|
@ -82,47 +102,34 @@ export const AppShell: React.FC = () => {
|
||||||
subtitle={`Контекст: ${profile.name} • ${activeLocation}`}
|
subtitle={`Контекст: ${profile.name} • ${activeLocation}`}
|
||||||
width="md"
|
width="md"
|
||||||
>
|
>
|
||||||
<div className="space-y-4">
|
<div className="space-y-4 text-xs">
|
||||||
<div className="p-3 bg-krona-gold/10 border border-krona-gold/30 rounded-lg text-xs leading-relaxed text-krona-gold-text">
|
<div className="p-3 bg-krona-gold/10 border border-krona-gold/30 rounded-xl leading-relaxed text-krona-gold-text">
|
||||||
<strong>Krona AI Core v1:</strong> Интеллектуальный помощник подключен к локальной модели данных бизнеса, остаткам, расписанию смен и фискальным отчетам.
|
<strong>Krona AI:</strong> Интеллектуальный помощник подключен к локальной модели данных бизнеса, остаткам, расписанию смен и фискальным отчетам.
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<span className="text-xs font-semibold text-krona-secondary uppercase tracking-wider">
|
<span className="text-xs font-semibold text-krona-secondary uppercase tracking-wider">
|
||||||
Ролевые агенты команды:
|
Ролевые агенты команды:
|
||||||
</span>
|
</span>
|
||||||
<div className="grid grid-cols-2 gap-2 text-xs">
|
<div className="grid grid-cols-2 gap-2">
|
||||||
<div className="p-2.5 rounded-lg border border-krona-border bg-krona-surface">
|
<div className="p-2.5 rounded-xl border border-krona-border bg-krona-surface">
|
||||||
<div className="font-semibold text-krona-primary">Бизнес-аналитик</div>
|
<div className="font-semibold text-krona-primary">Бизнес-аналитик</div>
|
||||||
<div className="text-[11px] text-krona-secondary">Food cost, выручка, чеки</div>
|
<div className="text-[11px] text-krona-secondary">Food cost, выручка, чеки</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="p-2.5 rounded-lg border border-krona-border bg-krona-surface">
|
<div className="p-2.5 rounded-xl border border-krona-border bg-krona-surface">
|
||||||
<div className="font-semibold text-krona-primary">Складской агент</div>
|
<div className="font-semibold text-krona-primary">Складской агент</div>
|
||||||
<div className="text-[11px] text-krona-secondary">Списания, закупки, пороги</div>
|
<div className="text-[11px] text-krona-secondary">Остатки, неснижаемый лимит</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="p-2.5 rounded-lg border border-krona-border bg-krona-surface">
|
<div className="p-2.5 rounded-xl border border-krona-border bg-krona-surface">
|
||||||
<div className="font-semibold text-krona-primary">Маркетолог</div>
|
<div className="font-semibold text-krona-primary">Маркетолог</div>
|
||||||
<div className="text-[11px] text-krona-secondary">Сегменты, LTV, рассылки</div>
|
<div className="text-[11px] text-krona-secondary">Сегменты, акции, рассылки</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="p-2.5 rounded-lg border border-krona-border bg-krona-surface">
|
<div className="p-2.5 rounded-xl border border-krona-border bg-krona-surface">
|
||||||
<div className="font-semibold text-krona-primary">Финансовый аудитор</div>
|
<div className="font-semibold text-krona-primary">HR-ассистент</div>
|
||||||
<div className="text-[11px] text-krona-secondary">Кассовые смены, ДДС, P&L</div>
|
<div className="text-[11px] text-krona-secondary">График смен, переработки</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="pt-2">
|
|
||||||
<Button
|
|
||||||
variant="primary"
|
|
||||||
className="w-full text-xs"
|
|
||||||
onClick={() => {
|
|
||||||
setIsAiDrawerOpen(false);
|
|
||||||
window.location.href = '/ai';
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Открыть полноэкранный AI-центр →
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</Drawer>
|
</Drawer>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,114 +1,109 @@
|
||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { NavLink, useNavigate } from 'react-router-dom';
|
import { NavLink, useLocation, useNavigate } from 'react-router-dom';
|
||||||
import { useDemoStore } from '../../mock/store';
|
|
||||||
import {
|
import {
|
||||||
LayoutDashboard,
|
LayoutDashboard,
|
||||||
Workflow,
|
Grid,
|
||||||
Plus,
|
CheckSquare,
|
||||||
MessageSquare,
|
MessageSquareText,
|
||||||
Menu,
|
Menu,
|
||||||
X,
|
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { SYSTEM_MODULES } from '../../core/modules/registry';
|
import { MobileMenuDrawer } from './MobileMenuDrawer';
|
||||||
|
|
||||||
export const BottomNav: React.FC = () => {
|
export const BottomNav: React.FC = () => {
|
||||||
const { setIsQuickActionsOpen } = useDemoStore();
|
const [isDrawerOpen, setIsDrawerOpen] = useState(false);
|
||||||
const [isMoreMenuOpen, setIsMoreMenuOpen] = useState(false);
|
const location = useLocation();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const isCatalogActive = location.pathname === '/' && !location.search.includes('tab=dashboard');
|
||||||
|
const isDashboardActive = location.pathname === '/' && location.search.includes('tab=dashboard');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{/* More menu drawer on mobile */}
|
<nav
|
||||||
{isMoreMenuOpen && (
|
aria-label="Мобильная навигация"
|
||||||
<div className="fixed inset-0 z-50 flex flex-col justify-end">
|
data-testid="bottom-nav"
|
||||||
<div
|
className="fixed bottom-0 inset-x-0 bg-krona-surface text-krona-secondary border-t border-krona-border flex items-center justify-around z-40 px-1 pt-1 pb-[max(0.5rem,env(safe-area-inset-bottom))] shadow-lg backdrop-blur-md"
|
||||||
className="fixed inset-0 krona-overlay"
|
|
||||||
onClick={() => setIsMoreMenuOpen(false)}
|
|
||||||
/>
|
|
||||||
<div className="relative bg-krona-raised border-t border-krona-border rounded-t-2xl p-4 z-10 max-h-[80vh] overflow-y-auto animate-fade-in shadow-krona-modal">
|
|
||||||
<div className="flex items-center justify-between pb-3 mb-2 border-b border-krona-border">
|
|
||||||
<span className="text-sm font-semibold text-krona-primary">Все разделы Кроны</span>
|
|
||||||
<button onClick={() => setIsMoreMenuOpen(false)} className="p-1 text-krona-secondary">
|
|
||||||
<X className="w-5 h-5" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-3 gap-2 text-center text-xs pb-4">
|
|
||||||
{SYSTEM_MODULES.map((m) => (
|
|
||||||
<button
|
|
||||||
key={m.id}
|
|
||||||
onClick={() => {
|
|
||||||
setIsMoreMenuOpen(false);
|
|
||||||
navigate(m.route);
|
|
||||||
}}
|
|
||||||
className="p-3 rounded-lg border border-krona-border bg-krona-surface hover:bg-krona-workspace flex flex-col items-center gap-1.5 transition-colors"
|
|
||||||
>
|
>
|
||||||
<span className="text-krona-gold font-medium text-xs truncate max-w-full">
|
{/* 1. Главная (KPI Сводка) */}
|
||||||
{m.label}
|
<button
|
||||||
</span>
|
onClick={() => navigate('/?tab=dashboard')}
|
||||||
|
className={`flex-1 min-h-[48px] min-w-[44px] flex flex-col items-center justify-center gap-1 p-1 rounded-xl transition-colors select-none ${
|
||||||
|
isDashboardActive
|
||||||
|
? 'text-krona-gold font-bold bg-krona-gold/10'
|
||||||
|
: 'text-krona-secondary hover:text-krona-primary active:bg-krona-workspace'
|
||||||
|
}`}
|
||||||
|
data-testid="bottom-nav-dashboard"
|
||||||
|
>
|
||||||
|
<LayoutDashboard className="w-5 h-5 shrink-0" />
|
||||||
|
<span className="text-[10px] leading-none font-medium">Главная</span>
|
||||||
</button>
|
</button>
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Persistent Bottom Bar */}
|
{/* 2. Модули (Единый каталог) */}
|
||||||
<nav className="fixed bottom-0 inset-x-0 h-16 bg-[#252722] text-[#D8D8D0] border-t border-[#3A3D35] flex items-center justify-around z-40 px-2">
|
<button
|
||||||
|
onClick={() => navigate('/')}
|
||||||
|
className={`flex-1 min-h-[48px] min-w-[44px] flex flex-col items-center justify-center gap-1 p-1 rounded-xl transition-colors select-none ${
|
||||||
|
isCatalogActive
|
||||||
|
? 'text-krona-gold font-bold bg-krona-gold/10'
|
||||||
|
: 'text-krona-secondary hover:text-krona-primary active:bg-krona-workspace'
|
||||||
|
}`}
|
||||||
|
data-testid="bottom-nav-catalog"
|
||||||
|
>
|
||||||
|
<Grid className="w-5 h-5 shrink-0" />
|
||||||
|
<span className="text-[10px] leading-none font-medium">Модули</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* 3. Задачи */}
|
||||||
<NavLink
|
<NavLink
|
||||||
to="/"
|
to="/tasks"
|
||||||
end
|
|
||||||
className={({ isActive }) =>
|
className={({ isActive }) =>
|
||||||
`flex flex-col items-center gap-1 p-1 text-[11px] ${
|
`flex-1 min-h-[48px] min-w-[44px] flex flex-col items-center justify-center gap-1 p-1 rounded-xl transition-colors select-none ${
|
||||||
isActive ? 'text-[#D4B16D] font-semibold' : 'text-[#858980]'
|
isActive
|
||||||
|
? 'text-krona-gold font-bold bg-krona-gold/10'
|
||||||
|
: 'text-krona-secondary hover:text-krona-primary active:bg-krona-workspace'
|
||||||
}`
|
}`
|
||||||
}
|
}
|
||||||
|
data-testid="bottom-nav-tasks"
|
||||||
>
|
>
|
||||||
<LayoutDashboard className="w-5 h-5" />
|
<CheckSquare className="w-5 h-5 shrink-0" />
|
||||||
<span>Главная</span>
|
<span className="text-[10px] leading-none font-medium">Задачи</span>
|
||||||
</NavLink>
|
</NavLink>
|
||||||
|
|
||||||
<NavLink
|
{/* 4. Коммуникации */}
|
||||||
to="/operations"
|
|
||||||
className={({ isActive }) =>
|
|
||||||
`flex flex-col items-center gap-1 p-1 text-[11px] ${
|
|
||||||
isActive ? 'text-[#D4B16D] font-semibold' : 'text-[#858980]'
|
|
||||||
}`
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Workflow className="w-5 h-5" />
|
|
||||||
<span>Работа</span>
|
|
||||||
</NavLink>
|
|
||||||
|
|
||||||
{/* Center Quick Action button */}
|
|
||||||
<button
|
|
||||||
onClick={() => setIsQuickActionsOpen(true)}
|
|
||||||
className="w-11 h-11 rounded-full bg-[#2F684C] hover:bg-[#285A42] text-white flex items-center justify-center shadow-lg -mt-5 transition-transform active:scale-95"
|
|
||||||
title="Быстрое действие"
|
|
||||||
>
|
|
||||||
<Plus className="w-6 h-6" />
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<NavLink
|
<NavLink
|
||||||
to="/communications"
|
to="/communications"
|
||||||
className={({ isActive }) =>
|
className={({ isActive }) =>
|
||||||
`flex flex-col items-center gap-1 p-1 text-[11px] ${
|
`flex-1 min-h-[48px] min-w-[44px] flex flex-col items-center justify-center gap-1 p-1 rounded-xl transition-colors select-none ${
|
||||||
isActive ? 'text-[#D4B16D] font-semibold' : 'text-[#858980]'
|
isActive
|
||||||
|
? 'text-krona-gold font-bold bg-krona-gold/10'
|
||||||
|
: 'text-krona-secondary hover:text-krona-primary active:bg-krona-workspace'
|
||||||
}`
|
}`
|
||||||
}
|
}
|
||||||
|
data-testid="bottom-nav-communications"
|
||||||
>
|
>
|
||||||
<MessageSquare className="w-5 h-5" />
|
<MessageSquareText className="w-5 h-5 shrink-0" />
|
||||||
<span>Чаты</span>
|
<span className="text-[10px] leading-none font-medium">Диалоги</span>
|
||||||
</NavLink>
|
</NavLink>
|
||||||
|
|
||||||
|
{/* 5. Ещё */}
|
||||||
<button
|
<button
|
||||||
onClick={() => setIsMoreMenuOpen(true)}
|
onClick={() => setIsDrawerOpen(true)}
|
||||||
className="flex flex-col items-center gap-1 p-1 text-[11px] text-[#858980] hover:text-[#D8D8D0]"
|
className={`flex-1 min-h-[48px] min-w-[44px] flex flex-col items-center justify-center gap-1 p-1 rounded-xl transition-colors select-none ${
|
||||||
|
isDrawerOpen
|
||||||
|
? 'text-krona-gold font-bold bg-krona-gold/10'
|
||||||
|
: 'text-krona-secondary hover:text-krona-primary active:bg-krona-workspace'
|
||||||
|
}`}
|
||||||
|
data-testid="bottom-nav-more"
|
||||||
>
|
>
|
||||||
<Menu className="w-5 h-5" />
|
<Menu className="w-5 h-5 shrink-0" />
|
||||||
<span>Ещё</span>
|
<span className="text-[10px] leading-none font-medium">Ещё</span>
|
||||||
</button>
|
</button>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
|
{/* Slide-out Mobile Menu Drawer */}
|
||||||
|
<MobileMenuDrawer
|
||||||
|
isOpen={isDrawerOpen}
|
||||||
|
onClose={() => setIsDrawerOpen(false)}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
279
src/app/shell/MobileMenuDrawer.tsx
Normal file
279
src/app/shell/MobileMenuDrawer.tsx
Normal file
|
|
@ -0,0 +1,279 @@
|
||||||
|
import React from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { useDemoStore } from '../../mock/store';
|
||||||
|
import { BUSINESS_PROFILES } from '../../core/profiles/profiles';
|
||||||
|
import { SYSTEM_ROLES, SystemRoleId } from '../../core/permissions/types';
|
||||||
|
import { SYSTEM_MODULES } from '../../core/modules/registry';
|
||||||
|
import {
|
||||||
|
X,
|
||||||
|
Building,
|
||||||
|
MapPin,
|
||||||
|
Shield,
|
||||||
|
Palette,
|
||||||
|
RotateCcw,
|
||||||
|
ExternalLink,
|
||||||
|
ChevronRight,
|
||||||
|
Sun,
|
||||||
|
Moon,
|
||||||
|
Sparkles,
|
||||||
|
Grid,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { Button } from '../../shared/ui/Button';
|
||||||
|
import { resetDatabase } from '../../services/storage/indexedDb';
|
||||||
|
import { useToast } from '../../shared/ui/useToast';
|
||||||
|
|
||||||
|
export interface MobileMenuDrawerProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const MobileMenuDrawer: React.FC<MobileMenuDrawerProps> = ({ isOpen, onClose }) => {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { showToast } = useToast();
|
||||||
|
const {
|
||||||
|
currentProfileId,
|
||||||
|
setProfileId,
|
||||||
|
activeLocation,
|
||||||
|
setActiveLocation,
|
||||||
|
currentRoleId,
|
||||||
|
setRoleId,
|
||||||
|
theme,
|
||||||
|
setTheme,
|
||||||
|
getActiveProfile,
|
||||||
|
resetCatalogFilters,
|
||||||
|
} = useDemoStore();
|
||||||
|
|
||||||
|
const profile = getActiveProfile();
|
||||||
|
|
||||||
|
if (!isOpen) return null;
|
||||||
|
|
||||||
|
const handleResetDb = async () => {
|
||||||
|
try {
|
||||||
|
await resetDatabase();
|
||||||
|
resetCatalogFilters();
|
||||||
|
showToast('База данных успешно сброшена к начальному состоянию', 'info');
|
||||||
|
onClose();
|
||||||
|
} catch {
|
||||||
|
showToast('Ошибка при сбросе базы данных', 'error');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleNavigate = (route: string) => {
|
||||||
|
onClose();
|
||||||
|
navigate(route);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-50 flex flex-col justify-end"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-label="Мобильное меню управления"
|
||||||
|
data-testid="mobile-menu-drawer"
|
||||||
|
>
|
||||||
|
{/* Backdrop */}
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 bg-black/60 backdrop-blur-xs animate-fade-in"
|
||||||
|
onClick={onClose}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Sheet Content */}
|
||||||
|
<div className="relative bg-krona-surface border-t border-krona-border rounded-t-3xl p-5 z-10 max-h-[90vh] overflow-y-auto shadow-krona-modal animate-slide-up flex flex-col gap-5 text-krona-primary pb-[max(1.5rem,env(safe-area-inset-bottom))]">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between border-b border-krona-border/70 pb-3">
|
||||||
|
<div className="flex items-center gap-2.5">
|
||||||
|
<div className="w-8 h-8 rounded-xl bg-krona-gold/15 text-krona-gold border border-krona-gold/30 flex items-center justify-center font-bold text-sm">
|
||||||
|
К
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h2 className="text-base font-bold text-krona-primary leading-tight">
|
||||||
|
Управление платформой
|
||||||
|
</h2>
|
||||||
|
<p className="text-xs text-krona-secondary">
|
||||||
|
{profile.name} • {activeLocation}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="w-10 h-10 rounded-xl bg-krona-workspace border border-krona-border text-krona-secondary hover:text-krona-primary flex items-center justify-center active:scale-95 transition-all"
|
||||||
|
aria-label="Закрыть меню"
|
||||||
|
data-testid="close-mobile-menu"
|
||||||
|
>
|
||||||
|
<X className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Section 1: Business Profiles */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center gap-1.5 text-xs font-bold uppercase tracking-wider text-krona-secondary">
|
||||||
|
<Building className="w-3.5 h-3.5 text-krona-gold" />
|
||||||
|
<span>Отрасль и профиль организации</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 gap-1.5">
|
||||||
|
{Object.values(BUSINESS_PROFILES).slice(0, 6).map((p) => (
|
||||||
|
<button
|
||||||
|
key={p.id}
|
||||||
|
onClick={() => setProfileId(p.id)}
|
||||||
|
className={`w-full min-h-[44px] px-3.5 py-2 rounded-xl text-left text-xs font-medium flex items-center justify-between transition-colors border ${
|
||||||
|
currentProfileId === p.id
|
||||||
|
? 'bg-krona-gold/15 text-krona-primary border-krona-gold/60 font-semibold'
|
||||||
|
: 'bg-krona-workspace text-krona-secondary hover:text-krona-primary border-krona-border'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span>{p.name}</span>
|
||||||
|
{currentProfileId === p.id && (
|
||||||
|
<span className="text-[10px] px-2 py-0.5 rounded-full bg-krona-gold text-white font-bold">
|
||||||
|
Активен
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Section 2: Location Switcher */}
|
||||||
|
{profile.locations.length > 1 && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center gap-1.5 text-xs font-bold uppercase tracking-wider text-krona-secondary">
|
||||||
|
<MapPin className="w-3.5 h-3.5 text-emerald-500" />
|
||||||
|
<span>Торговая точка / Локация</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
{profile.locations.map((loc) => (
|
||||||
|
<button
|
||||||
|
key={loc}
|
||||||
|
onClick={() => setActiveLocation(loc)}
|
||||||
|
className={`min-h-[44px] px-3 py-2 rounded-xl text-left text-xs font-medium border truncate transition-colors ${
|
||||||
|
activeLocation === loc
|
||||||
|
? 'bg-emerald-500/15 text-krona-primary border-emerald-500/50 font-semibold'
|
||||||
|
: 'bg-krona-workspace text-krona-secondary hover:text-krona-primary border-krona-border'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{loc}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Section 3: Role Switcher (RBAC) */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center gap-1.5 text-xs font-bold uppercase tracking-wider text-krona-secondary">
|
||||||
|
<Shield className="w-3.5 h-3.5 text-sky-500" />
|
||||||
|
<span>Роль пользователя (RBAC)</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
{(['owner', 'administrator', 'manager', 'cashier'] as SystemRoleId[]).map((rId) => {
|
||||||
|
const r = SYSTEM_ROLES[rId];
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={rId}
|
||||||
|
onClick={() => setRoleId(rId)}
|
||||||
|
className={`min-h-[44px] p-2.5 rounded-xl text-left border flex flex-col justify-center transition-colors ${
|
||||||
|
currentRoleId === rId
|
||||||
|
? 'bg-sky-500/15 text-krona-primary border-sky-500/50 font-semibold'
|
||||||
|
: 'bg-krona-workspace text-krona-secondary hover:text-krona-primary border-krona-border'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span className="text-xs font-bold truncate">{r.label}</span>
|
||||||
|
<span className="text-[10px] text-krona-secondary truncate">{r.badge}</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Section 4: Brandbook Theme Switcher */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center gap-1.5 text-xs font-bold uppercase tracking-wider text-krona-secondary">
|
||||||
|
<Palette className="w-3.5 h-3.5 text-purple-500" />
|
||||||
|
<span>Тема оформления</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-3 gap-2">
|
||||||
|
{[
|
||||||
|
{ id: 'soft-light', label: 'Мягкая', icon: Sparkles },
|
||||||
|
{ id: 'light', label: 'Светлая', icon: Sun },
|
||||||
|
{ id: 'dark', label: 'Тёмная', icon: Moon },
|
||||||
|
].map((th) => {
|
||||||
|
const Icon = th.icon;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={th.id}
|
||||||
|
onClick={() => setTheme(th.id as any)}
|
||||||
|
className={`min-h-[44px] px-2 py-2.5 rounded-xl border flex flex-col items-center justify-center gap-1 text-xs transition-colors ${
|
||||||
|
theme === th.id
|
||||||
|
? 'bg-krona-gold/20 text-krona-primary border-krona-gold/60 font-bold'
|
||||||
|
: 'bg-krona-workspace text-krona-secondary hover:text-krona-primary border-krona-border'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Icon className="w-4 h-4" />
|
||||||
|
<span>{th.label}</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Section 5: All Modules Direct Links */}
|
||||||
|
<div className="space-y-2 pt-2 border-t border-krona-border/70">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-1.5 text-xs font-bold uppercase tracking-wider text-krona-secondary">
|
||||||
|
<Grid className="w-3.5 h-3.5 text-krona-gold" />
|
||||||
|
<span>Все разделы платформы ({SYSTEM_MODULES.length})</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => handleNavigate('/')}
|
||||||
|
className="text-xs text-krona-gold hover:underline flex items-center gap-0.5"
|
||||||
|
>
|
||||||
|
<span>Каталог</span>
|
||||||
|
<ChevronRight className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-1.5 max-h-48 overflow-y-auto pr-1">
|
||||||
|
{SYSTEM_MODULES.map((m) => (
|
||||||
|
<button
|
||||||
|
key={m.id}
|
||||||
|
onClick={() => handleNavigate(m.route)}
|
||||||
|
className="min-h-[44px] px-3 py-2 rounded-xl text-left text-xs bg-krona-workspace border border-krona-border hover:border-krona-gold/40 text-krona-primary flex items-center justify-between"
|
||||||
|
>
|
||||||
|
<span className="truncate">{m.label}</span>
|
||||||
|
<ChevronRight className="w-3.5 h-3.5 text-krona-secondary shrink-0" />
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer: Database Reset & Settings */}
|
||||||
|
<div className="pt-3 border-t border-krona-border/70 flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleResetDb}
|
||||||
|
className="flex-1 min-h-[44px] text-xs justify-center"
|
||||||
|
data-testid="mobile-reset-db-btn"
|
||||||
|
>
|
||||||
|
<RotateCcw className="w-4 h-4 mr-1.5" />
|
||||||
|
<span>Сбросить демо-базу</span>
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handleNavigate('/settings/entities')}
|
||||||
|
className="flex-1 min-h-[44px] text-xs justify-center"
|
||||||
|
>
|
||||||
|
<span>Настройки</span>
|
||||||
|
<ExternalLink className="w-4 h-4 ml-1.5" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -48,8 +48,13 @@ const ICON_MAP: Record<string, React.ReactNode> = {
|
||||||
Settings: <Settings className="w-4 h-4 shrink-0" />,
|
Settings: <Settings className="w-4 h-4 shrink-0" />,
|
||||||
};
|
};
|
||||||
|
|
||||||
export const Sidebar: React.FC = () => {
|
export interface SidebarProps {
|
||||||
const [collapsed, setCollapsed] = useState(false);
|
isTabletCompact?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Sidebar: React.FC<SidebarProps> = ({ isTabletCompact = false }) => {
|
||||||
|
const [userCollapsed, setUserCollapsed] = useState<boolean | null>(null);
|
||||||
|
const collapsed = userCollapsed !== null ? userCollapsed : isTabletCompact;
|
||||||
const { currentRoleId } = useDemoStore();
|
const { currentRoleId } = useDemoStore();
|
||||||
const { entities } = useMetadata();
|
const { entities } = useMetadata();
|
||||||
const customEntities = entities.filter((e) => !e.isSystem);
|
const customEntities = entities.filter((e) => !e.isSystem);
|
||||||
|
|
@ -66,6 +71,7 @@ export const Sidebar: React.FC = () => {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<aside
|
<aside
|
||||||
|
data-testid="sidebar"
|
||||||
className={`relative h-screen bg-[#252722] text-[#D8D8D0] hidden md:flex flex-col border-r border-[#3A3D35] transition-all duration-200 z-20 select-none ${
|
className={`relative h-screen bg-[#252722] text-[#D8D8D0] hidden md:flex flex-col border-r border-[#3A3D35] transition-all duration-200 z-20 select-none ${
|
||||||
collapsed ? 'w-16' : 'w-56'
|
collapsed ? 'w-16' : 'w-56'
|
||||||
}`}
|
}`}
|
||||||
|
|
@ -85,14 +91,14 @@ export const Sidebar: React.FC = () => {
|
||||||
Крона
|
Крона
|
||||||
</span>
|
</span>
|
||||||
<span className="text-[9px] uppercase tracking-widest text-[#B78335] font-semibold">
|
<span className="text-[9px] uppercase tracking-widest text-[#B78335] font-semibold">
|
||||||
Business OS
|
Бизнес-платформа
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
onClick={() => setCollapsed(!collapsed)}
|
onClick={() => setUserCollapsed(!collapsed)}
|
||||||
className="p-1 rounded text-[#858980] hover:text-[#D8D8D0] hover:bg-[#30332C] transition-colors"
|
className="p-1 rounded text-[#858980] hover:text-[#D8D8D0] hover:bg-[#30332C] transition-colors"
|
||||||
title={collapsed ? 'Развернуть меню' : 'Свернуть меню'}
|
title={collapsed ? 'Развернуть меню' : 'Свернуть меню'}
|
||||||
>
|
>
|
||||||
|
|
|
||||||
|
|
@ -1,32 +1,27 @@
|
||||||
import React from 'react';
|
import React, { useState } from 'react';
|
||||||
import { useLocation, useNavigate } from 'react-router-dom';
|
import { useLocation, useNavigate } from 'react-router-dom';
|
||||||
import { useDemoStore } from '../../mock/store';
|
import { useDemoStore } from '../../mock/store';
|
||||||
import { BUSINESS_PROFILES } from '../../core/profiles/profiles';
|
import { BUSINESS_PROFILES } from '../../core/profiles/profiles';
|
||||||
import { SYSTEM_ROLES } from '../../core/permissions/types';
|
import { SYSTEM_ROLES, SystemRoleId } from '../../core/permissions/types';
|
||||||
import { SYSTEM_MODULES } from '../../core/modules/registry';
|
import { SYSTEM_MODULES } from '../../core/modules/registry';
|
||||||
import { Button } from '../../shared/ui';
|
import { Button } from '../../shared/ui';
|
||||||
import {
|
import {
|
||||||
Search,
|
Search,
|
||||||
Plus,
|
|
||||||
Bell,
|
Bell,
|
||||||
Sun,
|
Sun,
|
||||||
Moon,
|
Moon,
|
||||||
Sparkles,
|
Sparkles,
|
||||||
Monitor,
|
|
||||||
Tablet,
|
|
||||||
Smartphone,
|
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
MapPin,
|
MapPin,
|
||||||
Shield,
|
Shield,
|
||||||
Layers,
|
Menu,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
import { MobileMenuDrawer } from './MobileMenuDrawer';
|
||||||
|
|
||||||
export const Topbar: React.FC = () => {
|
export const Topbar: React.FC = () => {
|
||||||
const {
|
const {
|
||||||
theme,
|
theme,
|
||||||
setTheme,
|
setTheme,
|
||||||
viewportMode,
|
|
||||||
setViewportMode,
|
|
||||||
currentProfileId,
|
currentProfileId,
|
||||||
setProfileId,
|
setProfileId,
|
||||||
currentRoleId,
|
currentRoleId,
|
||||||
|
|
@ -34,7 +29,6 @@ export const Topbar: React.FC = () => {
|
||||||
activeLocation,
|
activeLocation,
|
||||||
setActiveLocation,
|
setActiveLocation,
|
||||||
setIsCommandPaletteOpen,
|
setIsCommandPaletteOpen,
|
||||||
setIsQuickActionsOpen,
|
|
||||||
setIsNotificationsOpen,
|
setIsNotificationsOpen,
|
||||||
setIsAiDrawerOpen,
|
setIsAiDrawerOpen,
|
||||||
notifications,
|
notifications,
|
||||||
|
|
@ -42,13 +36,13 @@ export const Topbar: React.FC = () => {
|
||||||
getActiveCapabilities,
|
getActiveCapabilities,
|
||||||
} = useDemoStore();
|
} = useDemoStore();
|
||||||
|
|
||||||
|
const [isMobileDrawerOpen, setIsMobileDrawerOpen] = useState(false);
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
const profile = getActiveProfile();
|
const profile = getActiveProfile();
|
||||||
const capabilities = getActiveCapabilities();
|
const capabilities = getActiveCapabilities();
|
||||||
const currentRole = SYSTEM_ROLES[currentRoleId];
|
const currentRole = SYSTEM_ROLES[currentRoleId];
|
||||||
const [isRoleDropdownOpen, setIsRoleDropdownOpen] = React.useState(false);
|
|
||||||
|
|
||||||
const unreadNotificationsCount = notifications.filter((n) => !n.read).length;
|
const unreadNotificationsCount = notifications.filter((n) => !n.read).length;
|
||||||
|
|
||||||
|
|
@ -59,7 +53,7 @@ export const Topbar: React.FC = () => {
|
||||||
return currentPath.startsWith(m.route);
|
return currentPath.startsWith(m.route);
|
||||||
}) || SYSTEM_MODULES[0];
|
}) || SYSTEM_MODULES[0];
|
||||||
|
|
||||||
// For operations, tabs are dynamic based on active profile & capabilities!
|
// For operations, subtabs are dynamic based on active profile & capabilities
|
||||||
const getSubtabs = () => {
|
const getSubtabs = () => {
|
||||||
if (currentModule.id === 'operations') {
|
if (currentModule.id === 'operations') {
|
||||||
return profile.operationsTabs.filter((tab) => {
|
return profile.operationsTabs.filter((tab) => {
|
||||||
|
|
@ -76,20 +70,79 @@ export const Topbar: React.FC = () => {
|
||||||
const activeSubtabs = getSubtabs();
|
const activeSubtabs = getSubtabs();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<header className="sticky top-0 z-30 bg-krona-workspace/95 backdrop-blur-sm border-b border-krona-border">
|
<header className="sticky top-0 z-30 bg-krona-surface/95 backdrop-blur-md border-b border-krona-border text-krona-primary shadow-xs">
|
||||||
{/* Upper Control Bar */}
|
{/* Mobile Topbar (< 768px): Minimal, clean, zero overflow */}
|
||||||
<div className="h-14 px-4 flex items-center justify-between gap-3">
|
<div className="flex md:hidden h-14 px-3 items-center justify-between gap-2">
|
||||||
|
{/* Left: Logo & Current Section Title */}
|
||||||
|
<div className="flex items-center gap-2 min-w-0">
|
||||||
|
<div
|
||||||
|
onClick={() => navigate('/')}
|
||||||
|
className="w-8 h-8 rounded-xl bg-krona-gold/15 text-krona-gold border border-krona-gold/30 flex items-center justify-center font-bold text-sm shrink-0 cursor-pointer"
|
||||||
|
>
|
||||||
|
К
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col min-w-0">
|
||||||
|
<span className="text-[10px] uppercase font-bold tracking-wider text-krona-gold truncate">
|
||||||
|
Крона
|
||||||
|
</span>
|
||||||
|
<span className="text-xs font-bold text-krona-primary truncate" data-testid="mobile-current-title">
|
||||||
|
{currentModule.label}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right: Notifications + AI + Menu */}
|
||||||
|
<div className="flex items-center gap-1.5 shrink-0">
|
||||||
|
{/* AI Trigger */}
|
||||||
|
<button
|
||||||
|
onClick={() => setIsAiDrawerOpen(true)}
|
||||||
|
className="w-9 h-9 rounded-xl bg-krona-workspace text-krona-gold hover:text-krona-gold-text border border-krona-border flex items-center justify-center active:scale-95 transition-all"
|
||||||
|
title="Krona AI"
|
||||||
|
aria-label="Krona AI"
|
||||||
|
>
|
||||||
|
<Sparkles className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Notifications Trigger */}
|
||||||
|
<button
|
||||||
|
onClick={() => setIsNotificationsOpen(true)}
|
||||||
|
className="relative w-9 h-9 rounded-xl bg-krona-workspace text-krona-secondary hover:text-krona-primary border border-krona-border flex items-center justify-center active:scale-95 transition-all"
|
||||||
|
title="Уведомления"
|
||||||
|
aria-label="Уведомления"
|
||||||
|
data-testid="mobile-notifications-btn"
|
||||||
|
>
|
||||||
|
<Bell className="w-4 h-4" />
|
||||||
|
{unreadNotificationsCount > 0 && (
|
||||||
|
<span className="absolute top-1 right-1 w-2 h-2 rounded-full bg-emerald-500 animate-pulse" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Profile / Menu Drawer Trigger */}
|
||||||
|
<button
|
||||||
|
onClick={() => setIsMobileDrawerOpen(true)}
|
||||||
|
className="w-9 h-9 rounded-xl bg-krona-workspace text-krona-primary border border-krona-border flex items-center justify-center active:scale-95 transition-all"
|
||||||
|
title="Меню управления"
|
||||||
|
aria-label="Открыть меню управления"
|
||||||
|
data-testid="mobile-menu-btn"
|
||||||
|
>
|
||||||
|
<Menu className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Desktop & Tablet Topbar (>= 768px) */}
|
||||||
|
<div className="hidden md:flex h-14 px-4 items-center justify-between gap-3">
|
||||||
{/* Left: Profile & Location controls */}
|
{/* Left: Profile & Location controls */}
|
||||||
<div className="flex items-center gap-2.5">
|
<div className="flex items-center gap-2.5">
|
||||||
{/* Profile Switcher */}
|
{/* Profile Switcher */}
|
||||||
<div className="relative group">
|
<div className="relative group">
|
||||||
<div className="flex items-center gap-2 bg-krona-surface border border-krona-border rounded-lg px-2.5 py-1.5 cursor-pointer hover:border-krona-gold/60 transition-colors shadow-krona-soft">
|
<div className="flex items-center gap-2 bg-krona-surface border border-krona-border rounded-xl px-2.5 py-1.5 cursor-pointer hover:border-krona-gold/60 transition-colors shadow-xs">
|
||||||
<span className="w-2 h-2 rounded-full bg-krona-gold"></span>
|
<span className="w-2 h-2 rounded-full bg-krona-gold"></span>
|
||||||
<div className="flex flex-col text-left">
|
<div className="flex flex-col text-left">
|
||||||
<span className="text-[10px] uppercase font-semibold tracking-wider text-krona-secondary">
|
<span className="text-[10px] uppercase font-semibold tracking-wider text-krona-secondary">
|
||||||
Профиль бизнеса
|
Отрасль
|
||||||
</span>
|
</span>
|
||||||
<span className="text-xs font-semibold text-krona-primary max-w-[170px] truncate">
|
<span className="text-xs font-semibold text-krona-primary max-w-[150px] lg:max-w-[200px] truncate">
|
||||||
{profile.name}
|
{profile.name}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -97,55 +150,51 @@ export const Topbar: React.FC = () => {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Dropdown Menu */}
|
{/* Dropdown Menu */}
|
||||||
<div className="absolute left-0 top-full mt-1 w-72 bg-krona-raised border border-krona-border rounded-xl shadow-krona-modal p-1.5 hidden group-hover:block z-50 animate-fade-in">
|
<div className="absolute left-0 top-full mt-1 w-72 bg-krona-surface border border-krona-border rounded-2xl shadow-krona-modal p-1.5 hidden group-hover:block z-50 animate-fade-in">
|
||||||
<div className="px-2.5 py-1.5 text-[11px] font-semibold text-krona-secondary uppercase tracking-wider border-b border-krona-border/60">
|
<div className="px-3 py-2 text-[10px] font-bold text-krona-secondary uppercase tracking-wider border-b border-krona-border/60">
|
||||||
Отраслевые профили Кроны
|
Отраслевые профили
|
||||||
</div>
|
</div>
|
||||||
<div className="max-h-80 overflow-y-auto py-1 space-y-0.5">
|
<div className="space-y-0.5 py-1 max-h-60 overflow-y-auto">
|
||||||
{Object.values(BUSINESS_PROFILES).map((p) => (
|
{Object.values(BUSINESS_PROFILES).map((p) => (
|
||||||
<button
|
<button
|
||||||
key={p.id}
|
key={p.id}
|
||||||
onClick={() => {
|
onClick={() => setProfileId(p.id)}
|
||||||
setProfileId(p.id);
|
className={`w-full text-left px-3 py-2 rounded-xl text-xs flex items-center justify-between transition-colors ${
|
||||||
navigate('/operations');
|
currentProfileId === p.id
|
||||||
}}
|
? 'bg-krona-gold/15 text-krona-primary font-bold'
|
||||||
className={`w-full text-left px-2.5 py-2 rounded-lg text-xs flex items-center justify-between transition-colors ${
|
: 'text-krona-secondary hover:text-krona-primary hover:bg-krona-workspace'
|
||||||
p.id === currentProfileId
|
|
||||||
? 'bg-krona-gold/15 text-krona-gold-text font-medium'
|
|
||||||
: 'hover:bg-krona-workspace text-krona-primary'
|
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<div>
|
<span>{p.name}</span>
|
||||||
<div className="font-medium truncate">{p.name}</div>
|
{currentProfileId === p.id && (
|
||||||
<div className="text-[10px] text-krona-secondary truncate">{p.companyName}</div>
|
<span className="w-1.5 h-1.5 rounded-full bg-krona-gold"></span>
|
||||||
</div>
|
)}
|
||||||
<span className="text-[10px] px-1.5 py-0.5 rounded bg-krona-muted text-krona-secondary shrink-0">
|
|
||||||
{p.category}
|
|
||||||
</span>
|
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Location Selector */}
|
{/* Location Switcher */}
|
||||||
<div className="relative group">
|
{profile.locations.length > 1 && (
|
||||||
<div className="flex items-center gap-1.5 bg-krona-surface border border-krona-border rounded-lg px-2.5 py-1.5 cursor-pointer hover:border-krona-border-strong text-xs text-krona-primary shadow-krona-soft">
|
<div className="relative group hidden lg:block">
|
||||||
<MapPin className="w-3.5 h-3.5 text-krona-secondary" />
|
<div className="flex items-center gap-1.5 bg-krona-surface border border-krona-border rounded-xl px-2.5 py-1.5 cursor-pointer hover:border-krona-gold/60 transition-colors shadow-xs">
|
||||||
<span className="max-w-[130px] truncate font-medium">{activeLocation}</span>
|
<MapPin className="w-3.5 h-3.5 text-emerald-500 shrink-0" />
|
||||||
|
<span className="text-xs font-medium text-krona-primary max-w-[130px] truncate">
|
||||||
|
{activeLocation}
|
||||||
|
</span>
|
||||||
<ChevronDown className="w-3 h-3 text-krona-secondary" />
|
<ChevronDown className="w-3 h-3 text-krona-secondary" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="absolute left-0 top-full mt-1 w-56 bg-krona-raised border border-krona-border rounded-lg shadow-krona-modal p-1 hidden group-hover:block z-50">
|
<div className="absolute left-0 top-full mt-1 w-52 bg-krona-surface border border-krona-border rounded-xl shadow-krona-modal p-1 hidden group-hover:block z-50 animate-fade-in">
|
||||||
<div className="px-2 py-1 text-[10px] font-semibold text-krona-secondary uppercase">
|
|
||||||
Точки продаж / Локации
|
|
||||||
</div>
|
|
||||||
{profile.locations.map((loc) => (
|
{profile.locations.map((loc) => (
|
||||||
<button
|
<button
|
||||||
key={loc}
|
key={loc}
|
||||||
onClick={() => setActiveLocation(loc)}
|
onClick={() => setActiveLocation(loc)}
|
||||||
className={`w-full text-left px-2.5 py-1.5 rounded text-xs transition-colors ${
|
className={`w-full text-left px-3 py-1.5 rounded-lg text-xs transition-colors ${
|
||||||
loc === activeLocation ? 'bg-krona-green/10 text-krona-green font-medium' : 'hover:bg-krona-workspace text-krona-primary'
|
activeLocation === loc
|
||||||
|
? 'bg-emerald-500/15 text-krona-primary font-bold'
|
||||||
|
: 'text-krona-secondary hover:text-krona-primary hover:bg-krona-workspace'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{loc}
|
{loc}
|
||||||
|
|
@ -153,229 +202,143 @@ export const Topbar: React.FC = () => {
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Role (RBAC) Switcher */}
|
{/* Role Badge (RBAC) */}
|
||||||
<div className="relative group">
|
<div className="relative group hidden sm:block">
|
||||||
<div
|
<div className="flex items-center gap-1.5 bg-krona-workspace border border-krona-border/80 rounded-xl px-2.5 py-1.5 cursor-pointer hover:border-krona-gold/50 transition-colors shadow-xs">
|
||||||
data-testid="role-switcher-btn"
|
<Shield className="w-3.5 h-3.5 text-sky-500 shrink-0" />
|
||||||
onClick={() => setIsRoleDropdownOpen((prev) => !prev)}
|
|
||||||
className="flex items-center gap-1.5 bg-krona-surface border border-krona-border rounded-lg px-2.5 py-1.5 cursor-pointer hover:border-krona-border-strong text-xs shadow-krona-soft"
|
|
||||||
>
|
|
||||||
<Shield className="w-3.5 h-3.5 text-krona-green" />
|
|
||||||
<div className="flex flex-col text-left">
|
<div className="flex flex-col text-left">
|
||||||
<span className="text-[9px] uppercase font-semibold text-krona-secondary">Роль</span>
|
<span className="text-[9px] uppercase font-bold text-sky-500">
|
||||||
<span className="font-semibold text-krona-primary truncate max-w-[90px]">
|
|
||||||
{currentRole.badge}
|
{currentRole.badge}
|
||||||
</span>
|
</span>
|
||||||
|
<span className="text-xs font-semibold text-krona-primary max-w-[110px] truncate">
|
||||||
|
{currentRole.label}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<ChevronDown className="w-3 h-3 text-krona-secondary" />
|
<ChevronDown className="w-3 h-3 text-krona-secondary" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div className="absolute left-0 top-full mt-1 w-60 bg-krona-surface border border-krona-border rounded-xl shadow-krona-modal p-1.5 hidden group-hover:block z-50 animate-fade-in">
|
||||||
className={`absolute left-0 top-full mt-1 w-64 bg-krona-raised border border-krona-border rounded-xl shadow-krona-modal p-1.5 z-50 ${
|
<div className="px-2 py-1 text-[10px] font-bold text-krona-secondary uppercase tracking-wider border-b border-krona-border/60">
|
||||||
isRoleDropdownOpen ? 'block' : 'hidden group-hover:block'
|
Переключение роли
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<div className="px-2 py-1 text-[10px] font-semibold text-krona-secondary uppercase border-b border-krona-border/60">
|
|
||||||
Переключение роли (RBAC)
|
|
||||||
</div>
|
</div>
|
||||||
<div className="max-h-72 overflow-y-auto py-1 space-y-0.5">
|
<div className="space-y-0.5 py-1">
|
||||||
{Object.values(SYSTEM_ROLES).map((r) => (
|
{(['owner', 'administrator', 'manager', 'cashier'] as SystemRoleId[]).map((rId) => {
|
||||||
|
const r = SYSTEM_ROLES[rId];
|
||||||
|
return (
|
||||||
<button
|
<button
|
||||||
key={r.id}
|
key={rId}
|
||||||
data-testid={`role-option-${r.id}`}
|
onClick={() => setRoleId(rId)}
|
||||||
onClick={() => {
|
className={`w-full text-left px-2.5 py-1.5 rounded-lg text-xs flex items-center justify-between transition-colors ${
|
||||||
setIsRoleDropdownOpen(false);
|
currentRoleId === rId
|
||||||
setRoleId(r.id);
|
? 'bg-sky-500/15 text-krona-primary font-bold'
|
||||||
navigate(r.defaultRoute);
|
: 'text-krona-secondary hover:text-krona-primary hover:bg-krona-workspace'
|
||||||
}}
|
|
||||||
className={`w-full text-left px-2.5 py-2 rounded-lg text-xs transition-colors ${
|
|
||||||
r.id === currentRoleId
|
|
||||||
? 'bg-krona-green/15 text-krona-green-text font-medium'
|
|
||||||
: 'hover:bg-krona-workspace text-krona-primary'
|
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<div className="flex items-center justify-between">
|
<span>{r.label}</span>
|
||||||
<span className="font-medium">{r.label}</span>
|
<span className="text-[10px] px-1.5 py-0.5 rounded bg-krona-workspace border border-krona-border text-krona-secondary">
|
||||||
<span className="text-[10px] px-1 py-0.5 rounded bg-krona-muted text-krona-secondary">
|
|
||||||
{r.badge}
|
{r.badge}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
|
||||||
<div className="text-[10px] text-krona-secondary truncate mt-0.5">{r.description}</div>
|
|
||||||
</button>
|
</button>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Center: Global Search & Shortcuts */}
|
{/* Center: Command Search Palette Trigger */}
|
||||||
<div className="flex-1 max-w-md hidden md:block">
|
<div className="flex-1 max-w-sm mx-2">
|
||||||
<button
|
<button
|
||||||
onClick={() => setIsCommandPaletteOpen(true)}
|
onClick={() => setIsCommandPaletteOpen(true)}
|
||||||
className="w-full flex items-center justify-between px-3 py-1.5 rounded-lg bg-krona-surface border border-krona-border text-xs text-krona-secondary hover:border-krona-border-strong shadow-krona-soft transition-colors"
|
className="w-full flex items-center justify-between px-3 py-1.5 bg-krona-workspace border border-krona-border rounded-xl text-xs text-krona-secondary hover:text-krona-primary hover:border-krona-gold/60 transition-colors shadow-inner"
|
||||||
|
title="Быстрый поиск (Ctrl+K)"
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Search className="w-3.5 h-3.5" />
|
<Search className="w-3.5 h-3.5" />
|
||||||
<span>Поиск по Кроне, клиентам, чекам, KDS...</span>
|
<span>Поиск по платформе...</span>
|
||||||
</div>
|
</div>
|
||||||
<kbd className="px-1.5 py-0.5 text-[10px] bg-krona-workspace border border-krona-border rounded text-krona-secondary font-mono">
|
<kbd className="hidden lg:inline-block px-1.5 py-0.5 text-[10px] font-mono bg-krona-surface border border-krona-border rounded text-krona-secondary">
|
||||||
Ctrl+K
|
Ctrl+K
|
||||||
</kbd>
|
</kbd>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Right: Quick actions, AI, Themes, Viewports, Notifications */}
|
{/* Right: AI + Theme + Notifications */}
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-2">
|
||||||
{/* AI trigger */}
|
{/* AI Trigger */}
|
||||||
<Button
|
<Button
|
||||||
variant="gold"
|
variant="secondary"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => setIsAiDrawerOpen(true)}
|
onClick={() => setIsAiDrawerOpen(true)}
|
||||||
className="hidden sm:inline-flex text-xs font-medium gap-1.5"
|
className="text-xs px-2.5 text-krona-gold hover:text-krona-gold-text"
|
||||||
|
title="Krona AI — Бизнес-оркестратор"
|
||||||
>
|
>
|
||||||
<Sparkles className="w-3.5 h-3.5" />
|
<Sparkles className="w-3.5 h-3.5 mr-1" />
|
||||||
<span>✦ Спросить Крону</span>
|
<span className="hidden xl:inline">Krona AI</span>
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
{/* Quick Action (+) button */}
|
{/* Theme switcher */}
|
||||||
<Button
|
<div className="flex items-center bg-krona-workspace p-0.5 rounded-xl border border-krona-border">
|
||||||
variant="primary"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => setIsQuickActionsOpen(true)}
|
|
||||||
className="px-2.5"
|
|
||||||
title="Быстрое создание"
|
|
||||||
>
|
|
||||||
<Plus className="w-4 h-4" />
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
{/* Viewport Switcher */}
|
|
||||||
<div className="flex items-center bg-krona-surface border border-krona-border rounded-lg p-0.5 shadow-krona-soft">
|
|
||||||
<button
|
<button
|
||||||
onClick={() => setViewportMode('desktop')}
|
onClick={() => setTheme(theme === 'dark' ? 'soft-light' : 'dark')}
|
||||||
className={`p-1.5 rounded transition-colors ${
|
className="p-1.5 rounded-lg text-krona-secondary hover:text-krona-primary transition-colors"
|
||||||
viewportMode === 'desktop' ? 'bg-krona-workspace text-krona-green' : 'text-krona-secondary hover:text-krona-primary'
|
title={`Тема: ${theme === 'dark' ? 'Тёмная' : 'Светлая'}`}
|
||||||
}`}
|
|
||||||
title="Desktop View"
|
|
||||||
>
|
>
|
||||||
<Monitor className="w-3.5 h-3.5" />
|
{theme === 'dark' ? <Moon className="w-3.5 h-3.5" /> : <Sun className="w-3.5 h-3.5" />}
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => setViewportMode('tablet-landscape')}
|
|
||||||
className={`p-1.5 rounded transition-colors ${
|
|
||||||
viewportMode === 'tablet-landscape' ? 'bg-krona-workspace text-krona-green' : 'text-krona-secondary hover:text-krona-primary'
|
|
||||||
}`}
|
|
||||||
title="Tablet Landscape"
|
|
||||||
>
|
|
||||||
<Tablet className="w-3.5 h-3.5 rotate-90" />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => setViewportMode('tablet-portrait')}
|
|
||||||
className={`p-1.5 rounded transition-colors ${
|
|
||||||
viewportMode === 'tablet-portrait' ? 'bg-krona-workspace text-krona-green' : 'text-krona-secondary hover:text-krona-primary'
|
|
||||||
}`}
|
|
||||||
title="Tablet Portrait"
|
|
||||||
>
|
|
||||||
<Tablet className="w-3.5 h-3.5" />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => setViewportMode('mobile')}
|
|
||||||
className={`p-1.5 rounded transition-colors ${
|
|
||||||
viewportMode === 'mobile' ? 'bg-krona-workspace text-krona-green' : 'text-krona-secondary hover:text-krona-primary'
|
|
||||||
}`}
|
|
||||||
title="Mobile View"
|
|
||||||
>
|
|
||||||
<Smartphone className="w-3.5 h-3.5" />
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Theme Switcher */}
|
{/* Notifications */}
|
||||||
<div className="flex items-center bg-krona-surface border border-krona-border rounded-lg p-0.5 shadow-krona-soft">
|
|
||||||
<button
|
|
||||||
data-testid="theme-toggle-soft"
|
|
||||||
onClick={() => setTheme('soft-light')}
|
|
||||||
className={`px-2 py-1 text-[11px] font-medium rounded transition-colors ${
|
|
||||||
theme === 'soft-light' ? 'bg-krona-workspace text-krona-gold-text' : 'text-krona-secondary'
|
|
||||||
}`}
|
|
||||||
title="Soft Light (Основная)"
|
|
||||||
>
|
|
||||||
Soft
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
data-testid="theme-toggle-light"
|
|
||||||
onClick={() => setTheme('light')}
|
|
||||||
className={`p-1 rounded transition-colors ${
|
|
||||||
theme === 'light' ? 'bg-krona-workspace text-krona-gold' : 'text-krona-secondary'
|
|
||||||
}`}
|
|
||||||
title="Light"
|
|
||||||
>
|
|
||||||
<Sun className="w-3.5 h-3.5" />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
data-testid="theme-toggle-dark"
|
|
||||||
onClick={() => setTheme('dark')}
|
|
||||||
className={`p-1 rounded transition-colors ${
|
|
||||||
theme === 'dark' ? 'bg-krona-workspace text-krona-gold' : 'text-krona-secondary'
|
|
||||||
}`}
|
|
||||||
title="Dark"
|
|
||||||
>
|
|
||||||
<Moon className="w-3.5 h-3.5" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Notifications bell */}
|
|
||||||
<button
|
<button
|
||||||
onClick={() => setIsNotificationsOpen(true)}
|
onClick={() => setIsNotificationsOpen(true)}
|
||||||
className="relative p-2 rounded-lg bg-krona-surface border border-krona-border text-krona-secondary hover:text-krona-primary shadow-krona-soft transition-colors"
|
className="relative p-2 rounded-xl text-krona-secondary hover:text-krona-primary hover:bg-krona-workspace transition-colors border border-krona-border"
|
||||||
title="Уведомления"
|
title="Уведомления"
|
||||||
>
|
>
|
||||||
<Bell className="w-4 h-4" />
|
<Bell className="w-4 h-4" />
|
||||||
{unreadNotificationsCount > 0 && (
|
{unreadNotificationsCount > 0 && (
|
||||||
<span className="absolute -top-1 -right-1 w-4 h-4 rounded-full bg-krona-danger text-white text-[10px] font-bold flex items-center justify-center">
|
<span className="absolute top-1 right-1 w-2 h-2 rounded-full bg-emerald-500 animate-pulse" />
|
||||||
{unreadNotificationsCount}
|
|
||||||
</span>
|
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Horizontal Module Subnavigation Tabs */}
|
{/* Subheader / Subtabs Navigation Bar (Desktop & Tablet) */}
|
||||||
{activeSubtabs.length > 0 && (
|
{activeSubtabs.length > 0 && (
|
||||||
<nav className="px-4 flex items-center gap-1 overflow-x-auto h-10 border-t border-krona-border/50 bg-krona-workspace/70 text-xs">
|
<div className="h-10 px-4 border-t border-krona-border/60 bg-krona-workspace/40 flex items-center overflow-x-auto gap-1 text-xs">
|
||||||
<span className="text-[11px] font-bold uppercase tracking-wider text-krona-secondary mr-2 flex items-center gap-1">
|
|
||||||
<Layers className="w-3 h-3 text-krona-secondary" />
|
|
||||||
{currentModule.label}:
|
|
||||||
</span>
|
|
||||||
{activeSubtabs.map((tab) => {
|
{activeSubtabs.map((tab) => {
|
||||||
const isActive =
|
const isActive =
|
||||||
currentPath === tab.route ||
|
location.pathname === tab.route ||
|
||||||
(tab.route !== '/operations' && tab.route !== '/' && currentPath.startsWith(tab.route));
|
(tab.route !== currentModule.route && location.pathname.startsWith(tab.route));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={tab.id}
|
key={tab.id}
|
||||||
onClick={() => navigate(tab.route)}
|
onClick={() => navigate(tab.route)}
|
||||||
className={`px-3 py-1.5 rounded-md font-medium whitespace-nowrap transition-all flex items-center gap-1.5 ${
|
className={`px-3 py-1 rounded-lg font-medium whitespace-nowrap transition-colors flex items-center gap-1.5 ${
|
||||||
isActive
|
isActive
|
||||||
? 'bg-[#2F684C] text-[#F8F6F0] shadow-sm'
|
? 'bg-krona-surface text-krona-primary font-bold shadow-xs border border-krona-border'
|
||||||
: 'text-krona-secondary hover:text-krona-primary hover:bg-krona-surface/60'
|
: 'text-krona-secondary hover:text-krona-primary hover:bg-krona-workspace'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<span>{tab.label}</span>
|
<span>{tab.label}</span>
|
||||||
{tab.badge && (
|
{tab.badge && (
|
||||||
<span
|
<span className="px-1.5 py-0.2 text-[10px] rounded-full bg-krona-gold/20 text-krona-gold-text font-bold">
|
||||||
className={`text-[10px] px-1.5 py-0.2 rounded-full font-bold ${
|
|
||||||
isActive ? 'bg-white/20 text-white' : 'bg-krona-muted text-krona-secondary'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{tab.badge}
|
{tab.badge}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</nav>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Mobile Menu Drawer */}
|
||||||
|
<MobileMenuDrawer
|
||||||
|
isOpen={isMobileDrawerOpen}
|
||||||
|
onClose={() => setIsMobileDrawerOpen(false)}
|
||||||
|
/>
|
||||||
</header>
|
</header>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { render, screen, fireEvent } from '@testing-library/react';
|
import { render, screen, fireEvent, within } from '@testing-library/react';
|
||||||
import { MemoryRouter } from 'react-router-dom';
|
import { MemoryRouter } from 'react-router-dom';
|
||||||
import '@testing-library/jest-dom/vitest';
|
import '@testing-library/jest-dom/vitest';
|
||||||
import { DynamicCardRenderer } from '../metadata/DynamicCardRenderer';
|
import { DynamicCardRenderer } from '../metadata/DynamicCardRenderer';
|
||||||
|
|
@ -107,21 +107,22 @@ describe('Configuration Engine & CRM Advanced Interactions', () => {
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
// Verify column headers exist
|
// Verify column headers exist in table
|
||||||
expect(screen.getByText('VIN номер')).toBeInTheDocument();
|
const table = screen.getByRole('table');
|
||||||
expect(screen.getByText('Пробег (км)')).toBeInTheDocument();
|
expect(within(table).getByText('VIN номер')).toBeInTheDocument();
|
||||||
|
expect(within(table).getByText('Пробег (км)')).toBeInTheDocument();
|
||||||
|
|
||||||
// Verify row values exist
|
// Verify row values exist in table
|
||||||
expect(screen.getByText('WAUZZZ8V1GA100001')).toBeInTheDocument();
|
expect(within(table).getByText('WAUZZZ8V1GA100001')).toBeInTheDocument();
|
||||||
expect(screen.getByText('XW8ZZZ3CZ8G200002')).toBeInTheDocument();
|
expect(within(table).getByText('XW8ZZZ3CZ8G200002')).toBeInTheDocument();
|
||||||
|
|
||||||
// Filter via search input
|
// Filter via search input
|
||||||
const searchInput = screen.getByPlaceholderText('Поиск по записям...');
|
const searchInput = screen.getByPlaceholderText('Поиск по записям...');
|
||||||
fireEvent.change(searchInput, { target: { value: 'WAUZZZ' } });
|
fireEvent.change(searchInput, { target: { value: 'WAUZZZ' } });
|
||||||
|
|
||||||
// First record stays, second record is filtered out
|
// First record stays, second record is filtered out
|
||||||
expect(screen.getByText('WAUZZZ8V1GA100001')).toBeInTheDocument();
|
expect(within(table).getByText('WAUZZZ8V1GA100001')).toBeInTheDocument();
|
||||||
expect(screen.queryByText('XW8ZZZ3CZ8G200002')).not.toBeInTheDocument();
|
expect(within(table).queryByText('XW8ZZZ3CZ8G200002')).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -104,7 +104,7 @@ export const ModuleInfoModal: React.FC<ModuleInfoModalProps> = ({ module, isOpen
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<div className="flex items-center gap-2 text-xs font-bold text-krona-primary uppercase tracking-wider">
|
<div className="flex items-center gap-2 text-xs font-bold text-krona-primary uppercase tracking-wider">
|
||||||
<Layers className="w-4 h-4 text-krona-gold" />
|
<Layers className="w-4 h-4 text-krona-gold" />
|
||||||
<span>2. Предполагаемые и реализованные возможности</span>
|
<span>2. Ключевые функциональные возможности</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
|
||||||
{module.details.expectedCapabilities.map((cap, idx) => (
|
{module.details.expectedCapabilities.map((cap, idx) => (
|
||||||
|
|
@ -123,7 +123,7 @@ export const ModuleInfoModal: React.FC<ModuleInfoModalProps> = ({ module, isOpen
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<div className="flex items-center gap-2 text-xs font-bold text-krona-primary uppercase tracking-wider">
|
<div className="flex items-center gap-2 text-xs font-bold text-krona-primary uppercase tracking-wider">
|
||||||
<ShieldCheck className="w-4 h-4 text-sky-500" />
|
<ShieldCheck className="w-4 h-4 text-sky-500" />
|
||||||
<span>3. Текущий статус готовности в платформе</span>
|
<span>3. Статус реализации</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="p-3 rounded-xl border border-sky-500/30 bg-sky-500/10 text-xs text-sky-900 dark:text-sky-200 leading-relaxed">
|
<div className="p-3 rounded-xl border border-sky-500/30 bg-sky-500/10 text-xs text-sky-900 dark:text-sky-200 leading-relaxed">
|
||||||
<strong>{readinessInfo.label}:</strong> {module.details.currentStatus}
|
<strong>{readinessInfo.label}:</strong> {module.details.currentStatus}
|
||||||
|
|
@ -134,7 +134,7 @@ export const ModuleInfoModal: React.FC<ModuleInfoModalProps> = ({ module, isOpen
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<div className="flex items-center gap-2 text-xs font-bold text-krona-primary uppercase tracking-wider">
|
<div className="flex items-center gap-2 text-xs font-bold text-krona-primary uppercase tracking-wider">
|
||||||
<Link2 className="w-4 h-4 text-purple-500" />
|
<Link2 className="w-4 h-4 text-purple-500" />
|
||||||
<span>4. Планируемые интеграции и API-коннекторы</span>
|
<span>4. Запланированные интеграции</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
{module.details.plannedIntegrations.map((integ, idx) => (
|
{module.details.plannedIntegrations.map((integ, idx) => (
|
||||||
|
|
|
||||||
|
|
@ -60,8 +60,7 @@ export const ModulesCatalog: React.FC = () => {
|
||||||
const matchTitle = m.title.toLowerCase().includes(q);
|
const matchTitle = m.title.toLowerCase().includes(q);
|
||||||
const matchDesc = m.description.toLowerCase().includes(q);
|
const matchDesc = m.description.toLowerCase().includes(q);
|
||||||
const matchFeatures = m.features.some((f) => f.toLowerCase().includes(q));
|
const matchFeatures = m.features.some((f) => f.toLowerCase().includes(q));
|
||||||
const matchCategory = CATALOG_CATEGORIES.find((c) => c.id === m.category)?.label.toLowerCase().includes(q);
|
if (!matchTitle && !matchDesc && !matchFeatures) {
|
||||||
if (!matchTitle && !matchDesc && !matchFeatures && !matchCategory) {
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1067,15 +1067,15 @@ export const DynamicFormRenderer: React.FC<DynamicFormRendererProps> = ({
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|
||||||
{/* Action buttons */}
|
{/* Action buttons (sticky on mobile for easy reachability) */}
|
||||||
{mode !== 'readonly' && (
|
{mode !== 'readonly' && (
|
||||||
<div className="flex items-center justify-end gap-2.5 pt-3 border-t border-krona-border">
|
<div className="sticky bottom-2 z-10 bg-krona-surface/95 backdrop-blur-sm p-3 rounded-xl border border-krona-border shadow-krona-card flex items-center justify-end gap-2.5">
|
||||||
{onCancel && (
|
{onCancel && (
|
||||||
<Button variant="outline" size="md" type="button" onClick={onCancel}>
|
<Button variant="outline" size="md" type="button" onClick={onCancel} className="min-h-[44px]">
|
||||||
Отмена
|
Отмена
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
<Button variant="primary" size="md" type="submit" data-testid="dynamic-form-submit" disabled={isLoading}>
|
<Button variant="primary" size="md" type="submit" data-testid="dynamic-form-submit" disabled={isLoading} className="min-h-[44px]">
|
||||||
{isLoading ? 'Сохранение...' : mode === 'create' ? 'Создать запись' : 'Сохранить изменения'}
|
{isLoading ? 'Сохранение...' : mode === 'create' ? 'Создать запись' : 'Сохранить изменения'}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -142,8 +142,95 @@ export const DynamicTable: React.FC<DynamicTableProps> = ({
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Table Element */}
|
{/* Mobile Card List View (< 768px) */}
|
||||||
<div className="bg-krona-surface border border-krona-border rounded-xl shadow-krona-soft overflow-hidden">
|
<div className="block md:hidden space-y-2.5" data-testid="mobile-table-cards">
|
||||||
|
{paginatedRecords.map((row) => {
|
||||||
|
const primaryCol = columns.find((c) => c.key === entity.primaryFieldKey) || columns[0];
|
||||||
|
const primaryVal = primaryCol ? formatFieldValue(primaryCol.type, row[primaryCol.key], primaryCol) : row.id;
|
||||||
|
const statusCol = columns.find((c) => c.key === 'status' || c.type === 'status');
|
||||||
|
const statusVal = statusCol ? formatFieldValue(statusCol.type, row[statusCol.key], statusCol) : null;
|
||||||
|
const otherCols = columns.filter((c) => c.key !== primaryCol?.key && c.key !== statusCol?.key).slice(0, 4);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={row.id}
|
||||||
|
onClick={() => onRowClick && onRowClick(row)}
|
||||||
|
className="p-3.5 bg-krona-surface border border-krona-border rounded-xl shadow-xs space-y-2.5 active:bg-krona-workspace/60 transition-colors cursor-pointer"
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between gap-2">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="font-bold text-xs text-krona-primary truncate">
|
||||||
|
{primaryVal}
|
||||||
|
</div>
|
||||||
|
<div className="text-[10px] text-krona-secondary font-mono truncate">
|
||||||
|
ID: {row.id.slice(0, 10)}...
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{statusVal && (
|
||||||
|
<Badge variant="neutral" size="sm">
|
||||||
|
{statusVal}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{otherCols.length > 0 && (
|
||||||
|
<div className="grid grid-cols-2 gap-2 text-xs pt-2 border-t border-krona-border/60">
|
||||||
|
{otherCols.map((c) => (
|
||||||
|
<div key={c.key} className="space-y-0.5">
|
||||||
|
<div className="text-[10px] text-krona-secondary uppercase truncate">
|
||||||
|
{c.label}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-krona-primary font-medium truncate">
|
||||||
|
{formatFieldValue(c.type, row[c.key], c)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Mobile Card Actions */}
|
||||||
|
<div
|
||||||
|
className="flex items-center justify-end gap-2 pt-2 border-t border-krona-border/60"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
{onRowClick && (
|
||||||
|
<Button variant="secondary" size="sm" onClick={() => onRowClick(row)} className="text-xs py-1 px-2.5 min-h-[36px]">
|
||||||
|
<Eye className="w-3.5 h-3.5 mr-1" />
|
||||||
|
<span>Открыть</span>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{onEdit && (
|
||||||
|
<Button variant="outline" size="sm" onClick={() => onEdit(row)} className="text-xs py-1 px-2.5 min-h-[36px]">
|
||||||
|
<Edit2 className="w-3.5 h-3.5 mr-1" />
|
||||||
|
<span>Изменить</span>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{onDelete && (
|
||||||
|
<Button variant="danger" size="sm" onClick={() => onDelete(row)} className="text-xs py-1 px-2.5 min-h-[36px]">
|
||||||
|
<Trash2 className="w-3.5 h-3.5" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
{paginatedRecords.length === 0 && !isLoading && (
|
||||||
|
<div className="p-8 text-center text-krona-secondary bg-krona-surface border border-krona-border rounded-xl">
|
||||||
|
<Inbox className="w-8 h-8 text-krona-secondary mx-auto mb-2 opacity-50" />
|
||||||
|
<p className="font-semibold text-krona-primary text-xs">Записей пока нет</p>
|
||||||
|
{onCreateNew && (
|
||||||
|
<Button variant="primary" size="sm" onClick={onCreateNew} className="mt-2 text-xs">
|
||||||
|
<Plus className="w-3.5 h-3.5 mr-1" />
|
||||||
|
Создать запись
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Desktop/Tablet Table Element (>= 768px) */}
|
||||||
|
<div className="hidden md:block bg-krona-surface border border-krona-border rounded-xl shadow-krona-soft overflow-hidden">
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
<table className="w-full text-left text-xs border-collapse">
|
<table className="w-full text-left text-xs border-collapse">
|
||||||
<thead className="bg-krona-workspace border-b border-krona-border select-none">
|
<thead className="bg-krona-workspace border-b border-krona-border select-none">
|
||||||
|
|
|
||||||
|
|
@ -311,7 +311,7 @@ export const SettingsModule: React.FC = () => {
|
||||||
disabled={isResetting}
|
disabled={isResetting}
|
||||||
>
|
>
|
||||||
<RotateCcw className="w-3.5 h-3.5 mr-1.5" />
|
<RotateCcw className="w-3.5 h-3.5 mr-1.5" />
|
||||||
{isResetting ? 'Сброс...' : 'Сбросить демо-данные'}
|
{isResetting ? 'Сброс...' : 'Сбросить базу данных'}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,8 @@ import { Modal } from '../../../shared/ui/Modal';
|
||||||
import { Badge } from '../../../shared/ui/Badge';
|
import { Badge } from '../../../shared/ui/Badge';
|
||||||
import { MaturityBadge } from '../../../shared/ui/MaturityBadge';
|
import { MaturityBadge } from '../../../shared/ui/MaturityBadge';
|
||||||
import { useToast } from '../../../shared/ui/useToast';
|
import { useToast } from '../../../shared/ui/useToast';
|
||||||
|
import { resetDatabase } from '../../../services/storage/indexedDb';
|
||||||
|
import { useDemoStore } from '../../../mock/store';
|
||||||
import {
|
import {
|
||||||
Layers,
|
Layers,
|
||||||
Plus,
|
Plus,
|
||||||
|
|
@ -20,6 +22,7 @@ import {
|
||||||
Sparkles,
|
Sparkles,
|
||||||
CreditCard,
|
CreditCard,
|
||||||
AlertTriangle,
|
AlertTriangle,
|
||||||
|
RotateCcw,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
|
||||||
export const EntityCatalog: React.FC = () => {
|
export const EntityCatalog: React.FC = () => {
|
||||||
|
|
@ -46,6 +49,7 @@ export const EntityCatalog: React.FC = () => {
|
||||||
|
|
||||||
// Delete modal state
|
// Delete modal state
|
||||||
const [entityToDelete, setEntityToDelete] = useState<EntityMetadata | null>(null);
|
const [entityToDelete, setEntityToDelete] = useState<EntityMetadata | null>(null);
|
||||||
|
const [isResetting, setIsResetting] = useState(false);
|
||||||
|
|
||||||
// Filtered entities
|
// Filtered entities
|
||||||
const filteredEntities = useMemo(() => {
|
const filteredEntities = useMemo(() => {
|
||||||
|
|
@ -205,6 +209,27 @@ export const EntityCatalog: React.FC = () => {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleResetDatabase = async () => {
|
||||||
|
try {
|
||||||
|
setIsResetting(true);
|
||||||
|
await resetDatabase();
|
||||||
|
useDemoStore.getState().resetCatalogFilters();
|
||||||
|
showToast({
|
||||||
|
type: 'info',
|
||||||
|
title: 'База данных сброшена',
|
||||||
|
message: 'База данных успешно сброшена к начальному состоянию',
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
showToast({
|
||||||
|
type: 'error',
|
||||||
|
title: 'Ошибка сброса',
|
||||||
|
message: err instanceof Error ? err.message : String(err),
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setIsResetting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-5 animate-fade-in">
|
<div className="space-y-5 animate-fade-in">
|
||||||
{/* Top Banner */}
|
{/* Top Banner */}
|
||||||
|
|
@ -223,17 +248,28 @@ export const EntityCatalog: React.FC = () => {
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2 shrink-0">
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="md"
|
||||||
|
data-testid="reset-database-btn"
|
||||||
|
onClick={handleResetDatabase}
|
||||||
|
disabled={isResetting}
|
||||||
|
>
|
||||||
|
<RotateCcw className="w-4 h-4 mr-1.5" />
|
||||||
|
{isResetting ? 'Сброс...' : 'Сбросить базу данных'}
|
||||||
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="primary"
|
variant="primary"
|
||||||
size="md"
|
size="md"
|
||||||
data-testid="create-entity-btn"
|
data-testid="create-entity-btn"
|
||||||
onClick={() => setIsNewEntityModalOpen(true)}
|
onClick={() => setIsNewEntityModalOpen(true)}
|
||||||
className="shrink-0"
|
|
||||||
>
|
>
|
||||||
<Plus className="w-4 h-4 mr-1.5" />
|
<Plus className="w-4 h-4 mr-1.5" />
|
||||||
Создать сущность
|
Создать сущность
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Stats row */}
|
{/* Stats row */}
|
||||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||||
|
|
|
||||||
|
|
@ -634,18 +634,21 @@ export const FormBuilder: React.FC = () => {
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
data-testid={`move-field-up-${fieldKey}`}
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
handleMoveField(section.id, fIdx, Math.max(0, fIdx - 1));
|
handleMoveField(section.id, fIdx, Math.max(0, fIdx - 1));
|
||||||
}}
|
}}
|
||||||
disabled={fIdx === 0}
|
disabled={fIdx === 0}
|
||||||
className="p-0.5 hover:text-krona-primary disabled:opacity-20"
|
className="p-1.5 min-w-[32px] min-h-[32px] flex items-center justify-center rounded hover:bg-krona-surface hover:text-krona-primary disabled:opacity-20 transition-colors"
|
||||||
title="Переместить влево/вверх"
|
title="Переместить вверх / влево"
|
||||||
|
aria-label="Переместить поле выше"
|
||||||
>
|
>
|
||||||
←
|
<MoveUp className="w-3.5 h-3.5" />
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
data-testid={`move-field-down-${fieldKey}`}
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
handleMoveField(
|
handleMoveField(
|
||||||
|
|
@ -655,10 +658,11 @@ export const FormBuilder: React.FC = () => {
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
disabled={fIdx === section.fieldKeys.length - 1}
|
disabled={fIdx === section.fieldKeys.length - 1}
|
||||||
className="p-0.5 hover:text-krona-primary disabled:opacity-20"
|
className="p-1.5 min-w-[32px] min-h-[32px] flex items-center justify-center rounded hover:bg-krona-surface hover:text-krona-primary disabled:opacity-20 transition-colors"
|
||||||
title="Переместить вправо/вниз"
|
title="Переместить вниз / вправо"
|
||||||
|
aria-label="Переместить поле ниже"
|
||||||
>
|
>
|
||||||
→
|
<MoveDown className="w-3.5 h-3.5" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
63
src/shared/lib/useResponsive.ts
Normal file
63
src/shared/lib/useResponsive.ts
Normal file
|
|
@ -0,0 +1,63 @@
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
|
||||||
|
export interface ResponsiveState {
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
isMobile: boolean;
|
||||||
|
isTablet: boolean;
|
||||||
|
isDesktop: boolean;
|
||||||
|
isPortrait: boolean;
|
||||||
|
isLandscape: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useResponsive(): ResponsiveState {
|
||||||
|
const [state, setState] = useState<ResponsiveState>(() => {
|
||||||
|
const width = typeof window !== 'undefined' ? window.innerWidth : 1440;
|
||||||
|
const height = typeof window !== 'undefined' ? window.innerHeight : 900;
|
||||||
|
return {
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
isMobile: width < 768,
|
||||||
|
isTablet: width >= 768 && width < 1200,
|
||||||
|
isDesktop: width >= 1200,
|
||||||
|
isPortrait: height >= width,
|
||||||
|
isLandscape: width > height,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (typeof window === 'undefined') return;
|
||||||
|
|
||||||
|
let timeoutId: any;
|
||||||
|
const handleResize = () => {
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
timeoutId = setTimeout(() => {
|
||||||
|
const width = window.innerWidth;
|
||||||
|
const height = window.innerHeight;
|
||||||
|
setState({
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
isMobile: width < 768,
|
||||||
|
isTablet: width >= 768 && width < 1200,
|
||||||
|
isDesktop: width >= 1200,
|
||||||
|
isPortrait: height >= width,
|
||||||
|
isLandscape: width > height,
|
||||||
|
});
|
||||||
|
}, 50);
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener('resize', handleResize);
|
||||||
|
window.addEventListener('orientationchange', handleResize);
|
||||||
|
|
||||||
|
// Initial check
|
||||||
|
handleResize();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
window.removeEventListener('resize', handleResize);
|
||||||
|
window.removeEventListener('orientationchange', handleResize);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
|
@ -114,10 +114,10 @@ export const Drawer: React.FC<DrawerProps> = ({
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Drawer panel */}
|
{/* Drawer panel */}
|
||||||
<div className={`fixed inset-y-0 ${side === 'right' ? 'right-0 pl-10' : 'left-0 pr-10'} max-w-full flex`}>
|
<div className={`fixed inset-y-0 ${side === 'right' ? 'right-0 pl-0 sm:pl-10' : 'left-0 pr-0 sm:pr-10'} max-w-full flex`}>
|
||||||
<div
|
<div
|
||||||
ref={drawerRef}
|
ref={drawerRef}
|
||||||
className={`w-screen ${widthStyles[effectiveWidth] || widthStyles.md} bg-krona-raised ${
|
className={`w-screen ${widthStyles[effectiveWidth] || widthStyles.md} max-w-full bg-krona-raised ${
|
||||||
side === 'right' ? 'border-l' : 'border-r'
|
side === 'right' ? 'border-l' : 'border-r'
|
||||||
} border-krona-border shadow-krona-modal flex flex-col animate-fade-in`}
|
} border-krona-border shadow-krona-modal flex flex-col animate-fade-in`}
|
||||||
aria-describedby={subtitle ? subtitleId : undefined}
|
aria-describedby={subtitle ? subtitleId : undefined}
|
||||||
|
|
|
||||||
|
|
@ -111,7 +111,7 @@ export const Modal: React.FC<ModalProps> = ({
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
|
<div className="fixed inset-0 z-50 flex items-center justify-center p-2 sm:p-4">
|
||||||
{/* Backdrop */}
|
{/* Backdrop */}
|
||||||
<div
|
<div
|
||||||
className="fixed inset-0 krona-overlay transition-opacity duration-200 cursor-pointer"
|
className="fixed inset-0 krona-overlay transition-opacity duration-200 cursor-pointer"
|
||||||
|
|
@ -122,14 +122,14 @@ export const Modal: React.FC<ModalProps> = ({
|
||||||
{/* Modal Dialog */}
|
{/* Modal Dialog */}
|
||||||
<div
|
<div
|
||||||
ref={modalRef}
|
ref={modalRef}
|
||||||
className={`relative w-full ${maxWidthStyles[effectiveMaxWidth] || maxWidthStyles.md} bg-krona-raised border border-krona-border rounded-xl shadow-krona-modal z-10 flex flex-col max-h-[90vh] overflow-hidden animate-fade-in`}
|
className={`relative w-full ${maxWidthStyles[effectiveMaxWidth] || maxWidthStyles.md} bg-krona-raised border border-krona-border rounded-2xl sm:rounded-xl shadow-krona-modal z-10 flex flex-col max-h-[96vh] sm:max-h-[90vh] overflow-hidden animate-fade-in`}
|
||||||
role="dialog"
|
role="dialog"
|
||||||
aria-modal="true"
|
aria-modal="true"
|
||||||
aria-labelledby={titleId}
|
aria-labelledby={titleId}
|
||||||
aria-describedby={subtitle ? subtitleId : undefined}
|
aria-describedby={subtitle ? subtitleId : undefined}
|
||||||
>
|
>
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex items-center justify-between px-6 py-4 border-b border-krona-border/60 bg-krona-surface/50">
|
<div className="flex items-center justify-between px-4 sm:px-6 py-3.5 sm:py-4 border-b border-krona-border/60 bg-krona-surface/50 shrink-0">
|
||||||
<div>
|
<div>
|
||||||
<h3 id={titleId} className="text-base font-semibold text-krona-primary">
|
<h3 id={titleId} className="text-base font-semibold text-krona-primary">
|
||||||
{title}
|
{title}
|
||||||
|
|
@ -151,13 +151,13 @@ export const Modal: React.FC<ModalProps> = ({
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Body */}
|
{/* Body */}
|
||||||
<div className="px-6 py-5 overflow-y-auto flex-1 text-sm text-krona-primary">
|
<div className="px-4 sm:px-6 py-4 sm:py-5 overflow-y-auto flex-1 text-sm text-krona-primary">
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Footer */}
|
{/* Footer */}
|
||||||
{footer && (
|
{footer && (
|
||||||
<div className="flex items-center justify-end gap-2.5 px-6 py-3.5 border-t border-krona-border/60 bg-krona-surface/30">
|
<div className="flex items-center justify-end gap-2.5 px-4 sm:px-6 py-3 border-t border-krona-border/60 bg-krona-surface/95 backdrop-blur-xs sticky bottom-0 z-10 shrink-0">
|
||||||
{footer}
|
{footer}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
File diff suppressed because one or more lines are too long
Loading…
Reference in a new issue