Compare commits

...

2 commits

30 changed files with 3079 additions and 417 deletions

View file

@ -2,8 +2,8 @@ import { test, expect } from '@playwright/test';
test.describe('Scenario 10: IndexedDB Snapshots (Export, Reset, Import)', () => {
test('exports database snapshot, resets demo data, imports snapshot, and verifies restored state', async ({ page }) => {
await page.goto('/settings');
await page.waitForLoadState('domcontentloaded');
test.setTimeout(60000);
await page.goto('/settings', { waitUntil: 'domcontentloaded' });
// 1. Export database snapshot
const exportBtn = page.locator('[data-testid="export-snapshot-btn"]');

View file

@ -0,0 +1,162 @@
import { test, expect } from '@playwright/test';
test.describe('Scenario 11: Universal Modules Catalog & Profiles', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/');
await page.waitForLoadState('domcontentloaded');
});
test('1. First open immediately displays all 18 modules with "Все модули" active by default', async ({ page }) => {
const container = page.locator('[data-testid="modules-catalog-container"]');
await expect(container).toBeVisible({ timeout: 15000 });
await expect(page.locator('h1')).toContainText('Единый каталог модулей Krona');
const allProfileBtn = page.locator('[data-testid="profile-filter-all"]');
await expect(allProfileBtn).toBeVisible();
await expect(allProfileBtn).toContainText('Все модули (18)');
const cards = page.locator('[data-testid^="module-card-"]');
await expect(cards).toHaveCount(18);
await expect(page.locator('[data-testid="module-card-crm"]')).toBeVisible();
await expect(page.locator('[data-testid="module-card-procurement"]')).toBeVisible();
await expect(page.locator('[data-testid="module-card-operations"]')).toBeVisible();
await expect(page.locator('[data-testid="module-card-finance"]')).toBeVisible();
});
test('2. Industry profile highlights recommended modules without hiding the rest', async ({ page }) => {
const restaurantBtn = page.locator('[data-testid="profile-filter-restaurant_hall_delivery"]');
await expect(restaurantBtn).toBeVisible();
await restaurantBtn.click();
const recommendedSection = page.locator('[data-testid="recommended-section"]');
const otherSection = page.locator('[data-testid="other-modules-section"]');
await expect(recommendedSection).toBeVisible();
await expect(otherSection).toBeVisible();
// Recommended section must contain restaurant modules (e.g. operations)
await expect(recommendedSection.locator('[data-testid="module-card-operations"]')).toBeVisible();
// Other section must contain non-restaurant recommended modules (e.g. helpdesk, not hidden!)
await expect(otherSection.locator('[data-testid="module-card-helpdesk"]')).toBeVisible();
// Total count of modules across both sections must still be 18
const totalCards = page.locator('[data-testid^="module-card-"]');
await expect(totalCards).toHaveCount(18);
// Reset button returns to "Все модули"
const resetBtn = page.locator('[data-testid="reset-profile-filter-btn"]');
await expect(resetBtn).toBeVisible();
await resetBtn.click();
await expect(page.locator('[data-testid="all-modules-grid"]')).toBeVisible();
});
test('3. Search and category filters work properly', async ({ page }) => {
const goodsCategoryBtn = page.locator('[data-testid="category-filter-inventory"]');
await expect(goodsCategoryBtn).toBeVisible();
await goodsCategoryBtn.click();
const filteredCards = page.locator('[data-testid^="module-card-"]');
await expect(filteredCards).toHaveCount(2);
await expect(page.locator('[data-testid="module-card-inventory"]')).toBeVisible();
await expect(page.locator('[data-testid="module-card-procurement"]')).toBeVisible();
const searchInput = page.locator('[data-testid="catalog-search-input"]');
await searchInput.fill('закупки');
await expect(filteredCards).toHaveCount(1);
await expect(page.locator('[data-testid="module-card-procurement"]')).toBeVisible();
const resetAllBtn = page.locator('[data-testid="reset-all-filters-btn"]');
await expect(resetAllBtn).toBeVisible();
await resetAllBtn.click();
await expect(page.locator('[data-testid^="module-card-"]')).toHaveCount(18);
});
test('4. Module cards navigate directly to module route', async ({ page }) => {
const crmOpenBtn = page.locator('[data-testid="module-open-btn-crm"]');
await expect(crmOpenBtn).toBeVisible();
await crmOpenBtn.click();
await page.waitForURL('**/crm');
await expect(page.locator('h1, h2').first()).toBeVisible();
await page.goto('/');
await page.waitForLoadState('domcontentloaded');
const procOpenBtn = page.locator('[data-testid="module-open-btn-procurement"]');
await expect(procOpenBtn).toBeVisible();
await procOpenBtn.click();
await page.waitForURL('**/procurement');
await expect(page.locator('h1')).toContainText('Закупки и управление поставщиками');
await expect(page.getByTestId('maturity-badge-planned')).toBeVisible();
});
test('5. "О модуле" opens informative details modal', async ({ page }) => {
const detailsBtn = page.locator('[data-testid="module-details-btn-procurement"]');
await expect(detailsBtn).toBeVisible();
await detailsBtn.click();
const modal = page.locator('[data-testid="module-info-modal"]');
await expect(modal).toBeVisible();
await expect(modal).toContainText('Закупки и поставщики');
await expect(modal).toContainText('Назначение модуля');
await expect(modal).toContainText('Ключевые функциональные возможности');
await expect(modal).toContainText('Статус реализации');
await expect(modal).toContainText('Запланированные интеграции');
const closeBtn = page.locator('[data-testid="close-module-info-modal"]');
await closeBtn.click();
await expect(modal).not.toBeVisible();
});
test('6. Database reset in Settings restores "Все модули" filter mode', async ({ page }) => {
const retailBtn = page.locator('[data-testid="profile-filter-retail"]');
await expect(retailBtn).toBeVisible();
await retailBtn.click();
await expect(page.locator('[data-testid="recommended-section"]')).toBeVisible();
await page.goto('/settings/entities');
await page.waitForLoadState('domcontentloaded');
const resetBtn = page.getByRole('button', { name: /Сбросить базу данных/i });
await expect(resetBtn).toBeVisible({ timeout: 10000 });
await resetBtn.click();
const confirmBtn = page.getByRole('button', { name: /Да, сбросить/i });
if (await confirmBtn.isVisible()) {
await confirmBtn.click();
}
await page.waitForTimeout(1000);
await page.goto('/');
await page.waitForLoadState('domcontentloaded');
const allProfileBtn = page.locator('[data-testid="profile-filter-all"]');
await expect(allProfileBtn).toBeVisible();
await expect(page.locator('[data-testid="all-modules-grid"]')).toBeVisible();
await expect(page.locator('[data-testid^="module-card-"]')).toHaveCount(18);
});
test('7. Catalog renders with zero horizontal overflow across viewport', async ({ page }) => {
const isOverflowing = await page.evaluate(() => {
return document.documentElement.scrollWidth > document.documentElement.clientWidth;
});
expect(isOverflowing).toBe(false);
const kpiTab = page.locator('[data-testid="view-tab-dashboard"]');
await expect(kpiTab).toBeVisible();
await kpiTab.click();
await expect(page.locator('text=Выручка').first()).toBeVisible({ timeout: 10000 });
const catalogTab = page.locator('[data-testid="view-tab-catalog"]');
await catalogTab.click();
await expect(page.locator('[data-testid="modules-catalog-container"]')).toBeVisible();
});
});

189
e2e/12-mobile-first.spec.ts Normal file
View 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');
});
});

View file

@ -2,7 +2,7 @@ import { defineConfig } from '@playwright/test';
export default defineConfig({
testDir: './e2e',
testMatch: ['**/0[1-9]-*.spec.ts', '**/10-*.spec.ts'],
testMatch: ['**/0[1-9]-*.spec.ts', '**/1[0-9]-*.spec.ts'],
testIgnore: ['**/*control-center*'],
timeout: 30000,
fullyParallel: false,
@ -11,33 +11,57 @@ export default defineConfig({
workers: 1,
reporter: 'list',
use: {
baseURL: 'http://localhost:5173',
baseURL: 'http://127.0.0.1:5173',
trace: 'on-first-retry',
},
projects: [
{
name: 'desktop',
name: 'mobile-360',
use: {
viewport: { width: 1440, height: 900 },
viewport: { width: 360, height: 800 },
},
},
{
name: 'tablet',
name: 'mobile-390',
use: {
viewport: { width: 390, height: 844 },
},
},
{
name: 'mobile-430',
use: {
viewport: { width: 430, height: 932 },
},
},
{
name: 'tablet-768',
use: {
viewport: { width: 768, height: 1024 },
},
},
{
name: 'mobile',
name: 'tablet-1024',
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 },
},
},
],
webServer: {
command: 'npm run dev',
url: 'http://localhost:5173',
command: 'npx vite preview --port 5173 --host 127.0.0.1',
url: 'http://127.0.0.1:5173',
reuseExistingServer: !process.env.CI,
timeout: 120000,
timeout: 30000,
},
});

View file

@ -1,4 +1,4 @@
import React, { useEffect } from 'react';
import React, { useEffect } from 'react';
import { Outlet } from 'react-router-dom';
import { useDemoStore } from '../../mock/store';
import { Sidebar } from '../shell/Sidebar';
@ -8,7 +8,8 @@ import { BottomNav } from '../shell/BottomNav';
import { CommandPalette } from '../shell/CommandPalette';
import { QuickActionsModal } from '../shell/QuickActionsModal';
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 = () => {
const {
@ -20,61 +21,80 @@ export const AppShell: React.FC = () => {
activeLocation,
} = useDemoStore();
const { isMobile, isTablet, isDesktop } = useResponsive();
const profile = getActiveProfile();
useEffect(() => {
document.documentElement.setAttribute('data-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 = () => {
if (!isEmulatedOnDesktop) {
return 'w-full min-h-screen';
}
switch (viewportMode) {
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':
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':
return 'max-w-[1180px] h-[800px] my-6 border-8 border-[#3A3D35] rounded-[24px] overflow-hidden shadow-2xl relative';
case 'desktop':
return 'max-w-[1180px] h-[800px] my-6 border-8 border-krona-border rounded-[24px] overflow-hidden shadow-2xl relative';
default:
return 'w-full min-h-screen';
}
};
const isEmulatedDevice = viewportMode !== 'desktop';
const showMobileNav = viewportMode === 'mobile';
return (
<div className={`min-h-screen bg-[#191B18] flex items-center justify-center ${isEmulatedDevice ? 'p-4' : ''}`}>
<div className={`w-full bg-krona-app text-krona-primary flex flex-col transition-all duration-300 ${getViewportContainerStyles()}`}>
{/* App Main Shell Container */}
<div
data-testid="app-shell-root"
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">
{/* Left Sidebar (hidden in mobile mode) */}
{!showMobileNav && <Sidebar />}
{/* Left Sidebar: hidden on mobile, rendered on tablet & desktop */}
{!isEffectiveMobile && <Sidebar isTabletCompact={isEffectiveTablet} />}
{/* Center Work Area */}
<div className="flex-1 flex flex-col min-w-0 overflow-hidden bg-krona-workspace">
<Topbar />
{/* 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 />
</main>
</div>
{/* Right Command Panel (Desktop and Tablet-Landscape) */}
{viewportMode === 'desktop' && <RightCommandPanel />}
{/* Right Command Panel (Only on full Desktop) */}
{!isEffectiveMobile && !isEffectiveTablet && <RightCommandPanel />}
</div>
{/* Mobile Bottom Navigation */}
{showMobileNav && <BottomNav />}
{/* Mobile Bottom Navigation (Always present on mobile) */}
{isEffectiveMobile && <BottomNav />}
{/* Global Modals & Drawers */}
<CommandPalette />
<QuickActionsModal />
<NotificationsDrawer />
{/* Standalone AI Drawer (triggered via Topbar ✦ button on any screen) */}
{/* Standalone AI Drawer */}
<Drawer
isOpen={isAiDrawerOpen}
onClose={() => setIsAiDrawerOpen(false)}
@ -82,47 +102,34 @@ export const AppShell: React.FC = () => {
subtitle={`Контекст: ${profile.name} • ${activeLocation}`}
width="md"
>
<div className="space-y-4">
<div className="p-3 bg-krona-gold/10 border border-krona-gold/30 rounded-lg text-xs leading-relaxed text-krona-gold-text">
<strong>Krona AI Core v1:</strong> Интеллектуальный помощник подключен к локальной модели данных бизнеса, остаткам, расписанию смен и фискальным отчетам.
<div className="space-y-4 text-xs">
<div className="p-3 bg-krona-gold/10 border border-krona-gold/30 rounded-xl leading-relaxed text-krona-gold-text">
<strong>Krona AI:</strong> Интеллектуальный помощник подключен к локальной модели данных бизнеса, остаткам, расписанию смен и фискальным отчетам.
</div>
<div className="space-y-2">
<span className="text-xs font-semibold text-krona-secondary uppercase tracking-wider">
Ролевые агенты команды:
</span>
<div className="grid grid-cols-2 gap-2 text-xs">
<div className="p-2.5 rounded-lg border border-krona-border bg-krona-surface">
<div className="grid grid-cols-2 gap-2">
<div className="p-2.5 rounded-xl border border-krona-border bg-krona-surface">
<div className="font-semibold text-krona-primary">Бизнес-аналитик</div>
<div className="text-[11px] text-krona-secondary">Food cost, выручка, чеки</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="text-[11px] text-krona-secondary">Списания, закупки, пороги</div>
<div className="text-[11px] text-krona-secondary">Остатки, неснижаемый лимит</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="text-[11px] text-krona-secondary">Сегменты, LTV, рассылки</div>
<div className="text-[11px] text-krona-secondary">Сегменты, акции, рассылки</div>
</div>
<div className="p-2.5 rounded-lg border border-krona-border bg-krona-surface">
<div className="font-semibold text-krona-primary">Финансовый аудитор</div>
<div className="text-[11px] text-krona-secondary">Кассовые смены, ДДС, P&L</div>
<div className="p-2.5 rounded-xl border border-krona-border bg-krona-surface">
<div className="font-semibold text-krona-primary">HR-ассистент</div>
<div className="text-[11px] text-krona-secondary">График смен, переработки</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>
</Drawer>
</div>

View file

@ -25,6 +25,7 @@ import {
CardLayoutBuilderPage,
ViewBuilderPage,
CustomEntityRoutePage,
ProcurementPage,
ForbiddenPage,
NotFoundPage,
} from '../../pages';
@ -47,6 +48,7 @@ export const router = createBrowserRouter([
{ path: 'crm/*', element: <CrmPage /> },
{ path: 'sales/*', element: <SalesPage /> },
{ path: 'inventory/*', element: <InventoryPage /> },
{ path: 'procurement/*', element: <ProcurementPage /> },
{ path: 'finance/*', element: <FinancePage /> },
{ path: 'employees/*', element: <EmployeesPage /> },
{ path: 'communications/*', element: <CommunicationsPage /> },

View file

@ -1,114 +1,109 @@
import React, { useState } from 'react';
import { NavLink, useNavigate } from 'react-router-dom';
import { useDemoStore } from '../../mock/store';
import React, { useState } from 'react';
import { NavLink, useLocation, useNavigate } from 'react-router-dom';
import {
LayoutDashboard,
Workflow,
Plus,
MessageSquare,
Grid,
CheckSquare,
MessageSquareText,
Menu,
X,
} from 'lucide-react';
import { SYSTEM_MODULES } from '../../core/modules/registry';
import { MobileMenuDrawer } from './MobileMenuDrawer';
export const BottomNav: React.FC = () => {
const { setIsQuickActionsOpen } = useDemoStore();
const [isMoreMenuOpen, setIsMoreMenuOpen] = useState(false);
const [isDrawerOpen, setIsDrawerOpen] = useState(false);
const location = useLocation();
const navigate = useNavigate();
const isCatalogActive = location.pathname === '/' && !location.search.includes('tab=dashboard');
const isDashboardActive = location.pathname === '/' && location.search.includes('tab=dashboard');
return (
<>
{/* More menu drawer on mobile */}
{isMoreMenuOpen && (
<div className="fixed inset-0 z-50 flex flex-col justify-end">
<div
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">
{m.label}
</span>
</button>
))}
</div>
</div>
</div>
)}
{/* Persistent Bottom Bar */}
<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">
<NavLink
to="/"
end
className={({ isActive }) =>
`flex flex-col items-center gap-1 p-1 text-[11px] ${
isActive ? 'text-[#D4B16D] font-semibold' : 'text-[#858980]'
}`
}
>
<LayoutDashboard className="w-5 h-5" />
<span>Главная</span>
</NavLink>
<NavLink
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 */}
<nav
aria-label="Мобильная навигация"
data-testid="bottom-nav"
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"
>
{/* 1. Главная (KPI Сводка) */}
<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="Быстрое действие"
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"
>
<Plus className="w-6 h-6" />
<LayoutDashboard className="w-5 h-5 shrink-0" />
<span className="text-[10px] leading-none font-medium">Главная</span>
</button>
{/* 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
to="/tasks"
className={({ isActive }) =>
`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-krona-gold font-bold bg-krona-gold/10'
: 'text-krona-secondary hover:text-krona-primary active:bg-krona-workspace'
}`
}
data-testid="bottom-nav-tasks"
>
<CheckSquare className="w-5 h-5 shrink-0" />
<span className="text-[10px] leading-none font-medium">Задачи</span>
</NavLink>
{/* 4. Коммуникации */}
<NavLink
to="/communications"
className={({ isActive }) =>
`flex flex-col items-center gap-1 p-1 text-[11px] ${
isActive ? 'text-[#D4B16D] font-semibold' : 'text-[#858980]'
`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-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" />
<span>Чаты</span>
<MessageSquareText className="w-5 h-5 shrink-0" />
<span className="text-[10px] leading-none font-medium">Диалоги</span>
</NavLink>
{/* 5. Ещё */}
<button
onClick={() => setIsMoreMenuOpen(true)}
className="flex flex-col items-center gap-1 p-1 text-[11px] text-[#858980] hover:text-[#D8D8D0]"
onClick={() => setIsDrawerOpen(true)}
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" />
<span>Ещё</span>
<Menu className="w-5 h-5 shrink-0" />
<span className="text-[10px] leading-none font-medium">Ещё</span>
</button>
</nav>
{/* Slide-out Mobile Menu Drawer */}
<MobileMenuDrawer
isOpen={isDrawerOpen}
onClose={() => setIsDrawerOpen(false)}
/>
</>
);
};

View 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>
);
};

View file

@ -22,6 +22,7 @@ import {
Sparkles,
Grid,
Settings,
Truck,
ChevronLeft,
ChevronRight,
} from 'lucide-react';
@ -32,6 +33,7 @@ const ICON_MAP: Record<string, React.ReactNode> = {
Users: <Users className="w-4 h-4 shrink-0" />,
ShoppingBag: <ShoppingBag className="w-4 h-4 shrink-0" />,
Boxes: <Boxes className="w-4 h-4 shrink-0" />,
Truck: <Truck className="w-4 h-4 shrink-0" />,
BadgeDollarSign: <BadgeDollarSign className="w-4 h-4 shrink-0" />,
UserCheck: <UserCheck className="w-4 h-4 shrink-0" />,
MessageSquareText: <MessageSquareText className="w-4 h-4 shrink-0" />,
@ -46,29 +48,30 @@ const ICON_MAP: Record<string, React.ReactNode> = {
Settings: <Settings className="w-4 h-4 shrink-0" />,
};
export const Sidebar: React.FC = () => {
const [collapsed, setCollapsed] = useState(false);
const { currentRoleId, getActiveCapabilities } = useDemoStore();
export interface SidebarProps {
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 { entities } = useMetadata();
const customEntities = entities.filter((e) => !e.isSystem);
const capabilities = getActiveCapabilities();
const role = SYSTEM_ROLES[currentRoleId];
// Filter modules by role permissions and active capabilities
// Filter modules by role permissions (industry profile does not hide modules)
const visibleModules = SYSTEM_MODULES.filter((m) => {
// Role check
if (!role.allowedModules.includes('*') && !role.allowedModules.includes(m.id)) {
return false;
}
// Capability check
if (m.requiredCapability && !capabilities[m.requiredCapability]) {
return false;
}
return true;
});
return (
<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 ${
collapsed ? 'w-16' : 'w-56'
}`}
@ -88,14 +91,14 @@ export const Sidebar: React.FC = () => {
Крона
</span>
<span className="text-[9px] uppercase tracking-widest text-[#B78335] font-semibold">
Business OS
Бизнес-платформа
</span>
</div>
)}
</div>
<button
onClick={() => setCollapsed(!collapsed)}
onClick={() => setUserCollapsed(!collapsed)}
className="p-1 rounded text-[#858980] hover:text-[#D8D8D0] hover:bg-[#30332C] transition-colors"
title={collapsed ? 'Развернуть меню' : 'Свернуть меню'}
>

View file

@ -1,32 +1,27 @@
import React from 'react';
import React, { useState } from 'react';
import { useLocation, useNavigate } from 'react-router-dom';
import { useDemoStore } from '../../mock/store';
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 { Button } from '../../shared/ui';
import {
Search,
Plus,
Bell,
Sun,
Moon,
Sparkles,
Monitor,
Tablet,
Smartphone,
ChevronDown,
MapPin,
Shield,
Layers,
Menu,
} from 'lucide-react';
import { MobileMenuDrawer } from './MobileMenuDrawer';
export const Topbar: React.FC = () => {
const {
theme,
setTheme,
viewportMode,
setViewportMode,
currentProfileId,
setProfileId,
currentRoleId,
@ -34,7 +29,6 @@ export const Topbar: React.FC = () => {
activeLocation,
setActiveLocation,
setIsCommandPaletteOpen,
setIsQuickActionsOpen,
setIsNotificationsOpen,
setIsAiDrawerOpen,
notifications,
@ -42,13 +36,13 @@ export const Topbar: React.FC = () => {
getActiveCapabilities,
} = useDemoStore();
const [isMobileDrawerOpen, setIsMobileDrawerOpen] = useState(false);
const location = useLocation();
const navigate = useNavigate();
const profile = getActiveProfile();
const capabilities = getActiveCapabilities();
const currentRole = SYSTEM_ROLES[currentRoleId];
const [isRoleDropdownOpen, setIsRoleDropdownOpen] = React.useState(false);
const unreadNotificationsCount = notifications.filter((n) => !n.read).length;
@ -59,7 +53,7 @@ export const Topbar: React.FC = () => {
return currentPath.startsWith(m.route);
}) || 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 = () => {
if (currentModule.id === 'operations') {
return profile.operationsTabs.filter((tab) => {
@ -76,20 +70,79 @@ export const Topbar: React.FC = () => {
const activeSubtabs = getSubtabs();
return (
<header className="sticky top-0 z-30 bg-krona-workspace/95 backdrop-blur-sm border-b border-krona-border">
{/* Upper Control Bar */}
<div className="h-14 px-4 flex items-center justify-between gap-3">
<header className="sticky top-0 z-30 bg-krona-surface/95 backdrop-blur-md border-b border-krona-border text-krona-primary shadow-xs">
{/* Mobile Topbar (< 768px): Minimal, clean, zero overflow */}
<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 */}
<div className="flex items-center gap-2.5">
{/* Profile Switcher */}
<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>
<div className="flex flex-col text-left">
<span className="text-[10px] uppercase font-semibold tracking-wider text-krona-secondary">
Профиль бизнеса
Отрасль
</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}
</span>
</div>
@ -97,285 +150,195 @@ export const Topbar: React.FC = () => {
</div>
{/* 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="px-2.5 py-1.5 text-[11px] font-semibold text-krona-secondary uppercase tracking-wider border-b border-krona-border/60">
Отраслевые профили Кроны
<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-3 py-2 text-[10px] font-bold text-krona-secondary uppercase tracking-wider border-b border-krona-border/60">
Отраслевые профили
</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) => (
<button
key={p.id}
onClick={() => {
setProfileId(p.id);
navigate('/operations');
}}
className={`w-full text-left px-2.5 py-2 rounded-lg text-xs flex items-center justify-between transition-colors ${
p.id === currentProfileId
? 'bg-krona-gold/15 text-krona-gold-text font-medium'
: 'hover:bg-krona-workspace text-krona-primary'
onClick={() => setProfileId(p.id)}
className={`w-full text-left px-3 py-2 rounded-xl text-xs flex items-center justify-between transition-colors ${
currentProfileId === p.id
? 'bg-krona-gold/15 text-krona-primary font-bold'
: 'text-krona-secondary hover:text-krona-primary hover:bg-krona-workspace'
}`}
>
<div>
<div className="font-medium truncate">{p.name}</div>
<div className="text-[10px] text-krona-secondary truncate">{p.companyName}</div>
</div>
<span className="text-[10px] px-1.5 py-0.5 rounded bg-krona-muted text-krona-secondary shrink-0">
{p.category}
</span>
<span>{p.name}</span>
{currentProfileId === p.id && (
<span className="w-1.5 h-1.5 rounded-full bg-krona-gold"></span>
)}
</button>
))}
</div>
</div>
</div>
{/* Location Selector */}
<div className="relative group">
<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">
<MapPin className="w-3.5 h-3.5 text-krona-secondary" />
<span className="max-w-[130px] truncate font-medium">{activeLocation}</span>
<ChevronDown className="w-3 h-3 text-krona-secondary" />
</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="px-2 py-1 text-[10px] font-semibold text-krona-secondary uppercase">
Точки продаж / Локации
{/* Location Switcher */}
{profile.locations.length > 1 && (
<div className="relative group hidden lg:block">
<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">
<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" />
</div>
{profile.locations.map((loc) => (
<button
key={loc}
onClick={() => setActiveLocation(loc)}
className={`w-full text-left px-2.5 py-1.5 rounded text-xs transition-colors ${
loc === activeLocation ? 'bg-krona-green/10 text-krona-green font-medium' : 'hover:bg-krona-workspace text-krona-primary'
}`}
>
{loc}
</button>
))}
</div>
</div>
{/* Role (RBAC) Switcher */}
<div className="relative group">
<div
data-testid="role-switcher-btn"
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="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">
{profile.locations.map((loc) => (
<button
key={loc}
onClick={() => setActiveLocation(loc)}
className={`w-full text-left px-3 py-1.5 rounded-lg text-xs transition-colors ${
activeLocation === loc
? 'bg-emerald-500/15 text-krona-primary font-bold'
: 'text-krona-secondary hover:text-krona-primary hover:bg-krona-workspace'
}`}
>
{loc}
</button>
))}
</div>
</div>
)}
{/* Role Badge (RBAC) */}
<div className="relative group hidden sm:block">
<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">
<Shield className="w-3.5 h-3.5 text-sky-500 shrink-0" />
<div className="flex flex-col text-left">
<span className="text-[9px] uppercase font-semibold text-krona-secondary">Роль</span>
<span className="font-semibold text-krona-primary truncate max-w-[90px]">
<span className="text-[9px] uppercase font-bold text-sky-500">
{currentRole.badge}
</span>
<span className="text-xs font-semibold text-krona-primary max-w-[110px] truncate">
{currentRole.label}
</span>
</div>
<ChevronDown className="w-3 h-3 text-krona-secondary" />
</div>
<div
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 ${
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 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">
<div className="px-2 py-1 text-[10px] font-bold text-krona-secondary uppercase tracking-wider border-b border-krona-border/60">
Переключение роли
</div>
<div className="max-h-72 overflow-y-auto py-1 space-y-0.5">
{Object.values(SYSTEM_ROLES).map((r) => (
<button
key={r.id}
data-testid={`role-option-${r.id}`}
onClick={() => {
setIsRoleDropdownOpen(false);
setRoleId(r.id);
navigate(r.defaultRoute);
}}
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 className="font-medium">{r.label}</span>
<span className="text-[10px] px-1 py-0.5 rounded bg-krona-muted text-krona-secondary">
<div className="space-y-0.5 py-1">
{(['owner', 'administrator', 'manager', 'cashier'] as SystemRoleId[]).map((rId) => {
const r = SYSTEM_ROLES[rId];
return (
<button
key={rId}
onClick={() => setRoleId(rId)}
className={`w-full text-left px-2.5 py-1.5 rounded-lg text-xs flex items-center justify-between transition-colors ${
currentRoleId === rId
? 'bg-sky-500/15 text-krona-primary font-bold'
: 'text-krona-secondary hover:text-krona-primary hover:bg-krona-workspace'
}`}
>
<span>{r.label}</span>
<span className="text-[10px] px-1.5 py-0.5 rounded bg-krona-workspace border border-krona-border text-krona-secondary">
{r.badge}
</span>
</div>
<div className="text-[10px] text-krona-secondary truncate mt-0.5">{r.description}</div>
</button>
))}
</button>
);
})}
</div>
</div>
</div>
</div>
{/* Center: Global Search & Shortcuts */}
<div className="flex-1 max-w-md hidden md:block">
{/* Center: Command Search Palette Trigger */}
<div className="flex-1 max-w-sm mx-2">
<button
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">
<Search className="w-3.5 h-3.5" />
<span>Поиск по Кроне, клиентам, чекам, KDS...</span>
<span>Поиск по платформе...</span>
</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
</kbd>
</button>
</div>
{/* Right: Quick actions, AI, Themes, Viewports, Notifications */}
<div className="flex items-center gap-1.5">
{/* AI trigger */}
{/* Right: AI + Theme + Notifications */}
<div className="flex items-center gap-2">
{/* AI Trigger */}
<Button
variant="gold"
variant="secondary"
size="sm"
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" />
<span>✦ Спросить Крону</span>
<Sparkles className="w-3.5 h-3.5 mr-1" />
<span className="hidden xl:inline">Krona AI</span>
</Button>
{/* Quick Action (+) button */}
<Button
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">
{/* Theme switcher */}
<div className="flex items-center bg-krona-workspace p-0.5 rounded-xl border border-krona-border">
<button
onClick={() => setViewportMode('desktop')}
className={`p-1.5 rounded transition-colors ${
viewportMode === 'desktop' ? 'bg-krona-workspace text-krona-green' : 'text-krona-secondary hover:text-krona-primary'
}`}
title="Desktop View"
onClick={() => setTheme(theme === 'dark' ? 'soft-light' : 'dark')}
className="p-1.5 rounded-lg text-krona-secondary hover:text-krona-primary transition-colors"
title={`Тема: ${theme === 'dark' ? 'Тёмная' : 'Светлая'}`}
>
<Monitor 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" />
{theme === 'dark' ? <Moon className="w-3.5 h-3.5" /> : <Sun className="w-3.5 h-3.5" />}
</button>
</div>
{/* Theme Switcher */}
<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 */}
{/* Notifications */}
<button
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="Уведомления"
>
<Bell className="w-4 h-4" />
{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">
{unreadNotificationsCount}
</span>
<span className="absolute top-1 right-1 w-2 h-2 rounded-full bg-emerald-500 animate-pulse" />
)}
</button>
</div>
</div>
{/* Horizontal Module Subnavigation Tabs */}
{/* Subheader / Subtabs Navigation Bar (Desktop & Tablet) */}
{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">
<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>
<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">
{activeSubtabs.map((tab) => {
const isActive =
currentPath === tab.route ||
(tab.route !== '/operations' && tab.route !== '/' && currentPath.startsWith(tab.route));
location.pathname === tab.route ||
(tab.route !== currentModule.route && location.pathname.startsWith(tab.route));
return (
<button
key={tab.id}
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
? 'bg-[#2F684C] text-[#F8F6F0] shadow-sm'
: 'text-krona-secondary hover:text-krona-primary hover:bg-krona-surface/60'
? 'bg-krona-surface text-krona-primary font-bold shadow-xs border border-krona-border'
: 'text-krona-secondary hover:text-krona-primary hover:bg-krona-workspace'
}`}
>
<span>{tab.label}</span>
{tab.badge && (
<span
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'
}`}
>
<span className="px-1.5 py-0.2 text-[10px] rounded-full bg-krona-gold/20 text-krona-gold-text font-bold">
{tab.badge}
</span>
)}
</button>
);
})}
</nav>
</div>
)}
{/* Mobile Menu Drawer */}
<MobileMenuDrawer
isOpen={isMobileDrawerOpen}
onClose={() => setIsMobileDrawerOpen(false)}
/>
</header>
);
};

View file

@ -66,6 +66,18 @@ export const SYSTEM_MODULES: ModuleDefinition[] = [
{ id: 'audit', label: 'Инвентаризация', route: '/inventory/audit' },
],
},
{
id: 'procurement',
label: 'Закупки',
iconName: 'Truck',
route: '/procurement',
sections: [
{ id: 'orders', label: 'Заказы поставщикам', route: '/procurement' },
{ id: 'vendors', label: 'База поставщиков', route: '/procurement?tab=vendors' },
{ id: 'contracts', label: 'Договоры & Условия', route: '/procurement?tab=contracts' },
{ id: 'invoices', label: 'Входящие накладные', route: '/procurement?tab=invoices' },
],
},
{
id: 'finance',
label: 'Финансы',

View file

@ -1,6 +1,6 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
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 '@testing-library/jest-dom/vitest';
import { DynamicCardRenderer } from '../metadata/DynamicCardRenderer';
@ -107,21 +107,22 @@ describe('Configuration Engine & CRM Advanced Interactions', () => {
/>
);
// Verify column headers exist
expect(screen.getByText('VIN номер')).toBeInTheDocument();
expect(screen.getByText('Пробег (км)')).toBeInTheDocument();
// Verify column headers exist in table
const table = screen.getByRole('table');
expect(within(table).getByText('VIN номер')).toBeInTheDocument();
expect(within(table).getByText('Пробег (км)')).toBeInTheDocument();
// Verify row values exist
expect(screen.getByText('WAUZZZ8V1GA100001')).toBeInTheDocument();
expect(screen.getByText('XW8ZZZ3CZ8G200002')).toBeInTheDocument();
// Verify row values exist in table
expect(within(table).getByText('WAUZZZ8V1GA100001')).toBeInTheDocument();
expect(within(table).getByText('XW8ZZZ3CZ8G200002')).toBeInTheDocument();
// Filter via search input
const searchInput = screen.getByPlaceholderText('Поиск по записям...');
fireEvent.change(searchInput, { target: { value: 'WAUZZZ' } });
// First record stays, second record is filtered out
expect(screen.getByText('WAUZZZ8V1GA100001')).toBeInTheDocument();
expect(screen.queryByText('XW8ZZZ3CZ8G200002')).not.toBeInTheDocument();
expect(within(table).getByText('WAUZZZ8V1GA100001')).toBeInTheDocument();
expect(within(table).queryByText('XW8ZZZ3CZ8G200002')).not.toBeInTheDocument();
});
});

View file

@ -0,0 +1,151 @@
import React from 'react';
import { CatalogModuleItem, READINESS_CONFIG, AVAILABILITY_CONFIG } from './catalogData';
import { MaturityBadge } from '../../shared/ui/MaturityBadge';
import { Badge } from '../../shared/ui/Badge';
import { Button } from '../../shared/ui/Button';
import {
Users,
ShoppingBag,
Boxes,
Truck,
Workflow,
UserCheck,
MessageSquareText,
Headset,
CheckSquare,
BadgeDollarSign,
Megaphone,
BarChart3,
Cpu,
Sparkles,
Globe,
Settings,
Grid,
ArrowRight,
Info,
Check,
} from 'lucide-react';
import { useNavigate } from 'react-router-dom';
const ICON_COMPONENTS: Record<string, React.ReactNode> = {
Users: <Users className="w-5 h-5 text-emerald-500" />,
ShoppingBag: <ShoppingBag className="w-5 h-5 text-sky-500" />,
Boxes: <Boxes className="w-5 h-5 text-amber-500" />,
Truck: <Truck className="w-5 h-5 text-orange-500" />,
Workflow: <Workflow className="w-5 h-5 text-krona-gold" />,
UserCheck: <UserCheck className="w-5 h-5 text-indigo-500" />,
MessageSquareText: <MessageSquareText className="w-5 h-5 text-teal-500" />,
Headset: <Headset className="w-5 h-5 text-rose-500" />,
CheckSquare: <CheckSquare className="w-5 h-5 text-blue-500" />,
BadgeDollarSign: <BadgeDollarSign className="w-5 h-5 text-emerald-600" />,
Megaphone: <Megaphone className="w-5 h-5 text-pink-500" />,
BarChart3: <BarChart3 className="w-5 h-5 text-violet-500" />,
Cpu: <Cpu className="w-5 h-5 text-cyan-500" />,
Sparkles: <Sparkles className="w-5 h-5 text-krona-gold" />,
Globe: <Globe className="w-5 h-5 text-emerald-400" />,
Settings: <Settings className="w-5 h-5 text-slate-400" />,
Grid: <Grid className="w-5 h-5 text-amber-400" />,
};
export interface ModuleCardProps {
module: CatalogModuleItem;
onOpenDetails: (module: CatalogModuleItem) => void;
isRecommended?: boolean;
}
export const ModuleCard: React.FC<ModuleCardProps> = ({
module,
onOpenDetails,
isRecommended = false,
}) => {
const navigate = useNavigate();
const readiness = READINESS_CONFIG[module.readiness];
const availability = AVAILABILITY_CONFIG[module.availability];
const handleOpen = () => {
navigate(module.route);
};
return (
<div
data-testid={`module-card-${module.id}`}
className={`bg-krona-surface rounded-2xl border transition-all duration-200 flex flex-col justify-between hover:shadow-krona-card hover:border-krona-gold/60 p-5 ${
isRecommended
? 'border-krona-gold/50 bg-gradient-to-b from-krona-gold/5 to-transparent'
: 'border-krona-border'
}`}
>
<div className="space-y-3.5">
{/* Top meta bar: category + badges */}
<div className="flex items-center justify-between gap-2 flex-wrap">
<div className="flex items-center gap-2">
<div className="p-2 rounded-xl bg-krona-workspace border border-krona-border/60 shadow-xs">
{ICON_COMPONENTS[module.iconName] || <Grid className="w-5 h-5 text-krona-secondary" />}
</div>
{isRecommended && (
<span className="text-[10px] uppercase font-bold tracking-wider px-2 py-0.5 rounded-full bg-krona-gold/20 text-krona-gold-text border border-krona-gold/40">
★ Рекомендовано
</span>
)}
</div>
<div className="flex items-center gap-1.5 flex-wrap">
<MaturityBadge level={readiness.maturity} size="sm" />
<Badge variant={availability.variant} size="sm">
{availability.label}
</Badge>
</div>
</div>
{/* Title and description */}
<div>
<h3 className="text-base font-bold text-krona-primary leading-tight">
{module.title}
</h3>
<p className="text-xs text-krona-secondary mt-1.5 line-clamp-2 leading-relaxed">
{module.description}
</p>
</div>
{/* Features list */}
<div className="space-y-1.5 pt-1 border-t border-krona-border/50">
<span className="text-[10px] uppercase font-semibold tracking-wider text-krona-secondary block mb-1">
Ключевые возможности:
</span>
{module.features.slice(0, 3).map((f, i) => (
<div key={i} className="flex items-start gap-1.5 text-xs text-krona-primary">
<Check className="w-3.5 h-3.5 text-krona-green shrink-0 mt-0.5" />
<span className="line-clamp-1">{f}</span>
</div>
))}
</div>
</div>
{/* Action buttons */}
<div className="flex items-center gap-2 pt-4 mt-4 border-t border-krona-border/60">
<Button
variant="secondary"
size="sm"
onClick={() => onOpenDetails(module)}
className="text-xs px-2.5"
title="Подробная информация о модуле"
data-testid={`module-details-btn-${module.id}`}
>
<Info className="w-3.5 h-3.5 mr-1" />
<span>О модуле</span>
</Button>
<Button
variant={isRecommended ? 'primary' : 'secondary'}
size="sm"
onClick={handleOpen}
className="flex-1 text-xs justify-center"
data-testid={`module-open-btn-${module.id}`}
>
<span>Открыть</span>
<ArrowRight className="w-3.5 h-3.5 ml-1" />
</Button>
</div>
</div>
);
};

View file

@ -0,0 +1,175 @@
import React, { useEffect } from 'react';
import { CatalogModuleItem, READINESS_CONFIG, AVAILABILITY_CONFIG } from './catalogData';
import { Button } from '../../shared/ui/Button';
import { Badge } from '../../shared/ui/Badge';
import { MaturityBadge } from '../../shared/ui/MaturityBadge';
import {
X,
ArrowRight,
CheckCircle2,
Layers,
Link2,
ShieldCheck,
Target,
} from 'lucide-react';
import { useNavigate } from 'react-router-dom';
export interface ModuleInfoModalProps {
module: CatalogModuleItem | null;
isOpen: boolean;
onClose: () => void;
}
export const ModuleInfoModal: React.FC<ModuleInfoModalProps> = ({ module, isOpen, onClose }) => {
const navigate = useNavigate();
// Escape key handler
useEffect(() => {
if (!isOpen) return;
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [isOpen, onClose]);
if (!isOpen || !module) return null;
const readinessInfo = READINESS_CONFIG[module.readiness];
const availabilityInfo = AVAILABILITY_CONFIG[module.availability];
const handleOpenModule = () => {
onClose();
navigate(module.route);
};
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center p-4"
role="dialog"
aria-modal="true"
aria-labelledby="module-info-title"
data-testid="module-info-modal"
>
{/* Backdrop */}
<div
className="fixed inset-0 bg-black/60 backdrop-blur-xs animate-fade-in"
onClick={onClose}
/>
{/* Modal Card */}
<div className="relative bg-krona-surface border border-krona-border rounded-2xl max-w-2xl w-full max-h-[90vh] overflow-y-auto shadow-krona-modal z-10 animate-scale-in p-6 space-y-6">
{/* Header */}
<div className="flex items-start justify-between gap-4 border-b border-krona-border/70 pb-4">
<div className="space-y-1.5">
<div className="flex items-center gap-2 flex-wrap">
<span className="text-xs uppercase font-bold tracking-wider text-krona-gold">
Информация о модуле
</span>
<MaturityBadge level={readinessInfo.maturity} size="sm" />
<Badge variant={availabilityInfo.variant} size="sm">
{availabilityInfo.label}
</Badge>
</div>
<h2 id="module-info-title" className="text-xl font-bold text-krona-primary">
{module.title}
</h2>
<p className="text-xs text-krona-secondary">
{module.description}
</p>
</div>
<button
onClick={onClose}
className="p-1.5 rounded-lg text-krona-secondary hover:text-krona-primary hover:bg-krona-workspace transition-colors"
title="Закрыть (Esc)"
data-testid="close-module-info-modal"
>
<X className="w-5 h-5" />
</button>
</div>
{/* Section 1: Purpose */}
<div className="space-y-2">
<div className="flex items-center gap-2 text-xs font-bold text-krona-primary uppercase tracking-wider">
<Target className="w-4 h-4 text-krona-green" />
<span>1. Назначение модуля</span>
</div>
<div className="p-3 bg-krona-workspace rounded-xl border border-krona-border text-xs text-krona-primary leading-relaxed">
{module.details.purpose}
</div>
</div>
{/* Section 2: Expected Capabilities */}
<div className="space-y-2">
<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" />
<span>2. Ключевые функциональные возможности</span>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
{module.details.expectedCapabilities.map((cap, idx) => (
<div
key={idx}
className="p-2.5 rounded-xl border border-krona-border bg-krona-surface text-xs text-krona-primary flex items-start gap-2"
>
<CheckCircle2 className="w-4 h-4 text-krona-green shrink-0 mt-0.5" />
<span>{cap}</span>
</div>
))}
</div>
</div>
{/* Section 3: Current Status */}
<div className="space-y-2">
<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" />
<span>3. Статус реализации</span>
</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">
<strong>{readinessInfo.label}:</strong> {module.details.currentStatus}
</div>
</div>
{/* Section 4: Planned Integrations */}
<div className="space-y-2">
<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" />
<span>4. Запланированные интеграции</span>
</div>
<div className="flex flex-wrap gap-2">
{module.details.plannedIntegrations.map((integ, idx) => (
<span
key={idx}
className="px-2.5 py-1 text-xs rounded-lg border border-krona-border bg-krona-workspace text-krona-primary font-medium"
>
{integ}
</span>
))}
</div>
</div>
{/* Footer Actions */}
<div className="flex items-center justify-between pt-4 border-t border-krona-border/70">
<span className="text-[11px] text-krona-secondary">
Маршрут в системе: <code className="font-mono text-krona-primary">{module.route}</code>
</span>
<div className="flex items-center gap-2">
<Button variant="secondary" size="sm" onClick={onClose}>
Закрыть
</Button>
<Button
variant="primary"
size="sm"
onClick={handleOpenModule}
data-testid={`modal-open-module-${module.id}`}
>
<span>Перейти в модуль</span>
<ArrowRight className="w-3.5 h-3.5 ml-1" />
</Button>
</div>
</div>
</div>
</div>
);
};

View file

@ -0,0 +1,485 @@
import React, { useState, useMemo } from 'react';
import { useDemoStore } from '../../mock/store';
import { BUSINESS_PROFILES } from '../../core/profiles/profiles';
import {
CATALOG_MODULES,
CATALOG_CATEGORIES,
CatalogModuleItem,
} from './catalogData';
import { ModuleCard } from './ModuleCard';
import { ModuleInfoModal } from './ModuleInfoModal';
import {
Search,
X,
Sparkles,
Layers,
Filter,
Building,
RotateCcw,
BarChart3,
Grid,
} from 'lucide-react';
import { Button } from '../../shared/ui/Button';
import { DynamicDashboard } from '../dashboard/DynamicDashboard';
export const ModulesCatalog: React.FC = () => {
const {
currentProfileId,
catalogProfileFilter,
setCatalogProfileFilter,
catalogCategoryFilter,
setCatalogCategoryFilter,
catalogStatusFilter,
setCatalogStatusFilter,
catalogSearchQuery,
setCatalogSearchQuery,
resetCatalogFilters,
} = useDemoStore();
const [activeViewMode, setActiveViewMode] = useState<'catalog' | 'dashboard'>('catalog');
const [selectedModuleForModal, setSelectedModuleForModal] = useState<CatalogModuleItem | null>(null);
const activeProfile = BUSINESS_PROFILES[currentProfileId] || BUSINESS_PROFILES.restaurant_hall_delivery;
// Search and Category filtering
const filteredModules = useMemo(() => {
return CATALOG_MODULES.filter((m) => {
// Category filter
if (catalogCategoryFilter !== 'all' && m.category !== catalogCategoryFilter) {
return false;
}
// Status filter
if (catalogStatusFilter !== 'all' && m.readiness !== catalogStatusFilter) {
return false;
}
// Search filter
if (catalogSearchQuery.trim()) {
const q = catalogSearchQuery.toLowerCase();
const matchTitle = m.title.toLowerCase().includes(q);
const matchDesc = m.description.toLowerCase().includes(q);
const matchFeatures = m.features.some((f) => f.toLowerCase().includes(q));
if (!matchTitle && !matchDesc && !matchFeatures) {
return false;
}
}
return true;
});
}, [catalogCategoryFilter, catalogStatusFilter, catalogSearchQuery]);
// Split into recommended vs other when a specific profile or "recommended" is active
const isProfileFilterActive = catalogProfileFilter !== 'all';
const effectiveProfileId =
catalogProfileFilter === 'recommended' ? currentProfileId : catalogProfileFilter;
const { recommendedList, otherList } = useMemo(() => {
if (!isProfileFilterActive) {
return { recommendedList: [], otherList: filteredModules };
}
const rec: CatalogModuleItem[] = [];
const oth: CatalogModuleItem[] = [];
for (const m of filteredModules) {
if (m.recommendedProfiles.includes(effectiveProfileId)) {
rec.push(m);
} else {
oth.push(m);
}
}
return { recommendedList: rec, otherList: oth };
}, [isProfileFilterActive, effectiveProfileId, filteredModules]);
const stats = useMemo(() => {
const ready = CATALOG_MODULES.filter((m) => m.readiness === 'ready').length;
const target = CATALOG_MODULES.filter((m) => m.readiness === 'ui_prepared').length;
const planned = CATALOG_MODULES.filter((m) => m.readiness === 'planned').length;
const service = CATALOG_MODULES.filter((m) => m.readiness === 'service_required').length;
return { total: CATALOG_MODULES.length, ready, target, planned, service };
}, []);
return (
<div className="space-y-6 max-w-7xl mx-auto animate-fade-in pb-12" data-testid="modules-catalog-container">
{/* View Mode Switcher Header: Catalog vs KPI Dashboard */}
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3 bg-krona-surface p-3.5 rounded-2xl border border-krona-border shadow-krona-soft">
<div className="flex items-center gap-2">
<div className="p-2 rounded-xl bg-krona-gold/15 text-krona-gold border border-krona-gold/30">
<Grid className="w-5 h-5" />
</div>
<div>
<span className="text-[10px] uppercase font-bold tracking-wider text-krona-secondary">
Обзор платформы Krona Business OS
</span>
<div className="text-xs text-krona-primary font-semibold">
Единый каталог всех 18 модулей и функциональных блоков
</div>
</div>
</div>
{/* Tab switch between Catalog and Dashboard */}
<div className="flex items-center bg-krona-workspace p-1 rounded-xl border border-krona-border text-xs">
<button
onClick={() => setActiveViewMode('catalog')}
data-testid="view-tab-catalog"
className={`px-3 py-1.5 rounded-lg font-medium transition-colors flex items-center gap-1.5 ${
activeViewMode === 'catalog'
? 'bg-krona-surface text-krona-primary font-bold shadow-xs border border-krona-border'
: 'text-krona-secondary hover:text-krona-primary'
}`}
>
<Grid className="w-3.5 h-3.5" />
<span>Каталог модулей ({stats.total})</span>
</button>
<button
onClick={() => setActiveViewMode('dashboard')}
data-testid="view-tab-dashboard"
className={`px-3 py-1.5 rounded-lg font-medium transition-colors flex items-center gap-1.5 ${
activeViewMode === 'dashboard'
? 'bg-krona-surface text-krona-primary font-bold shadow-xs border border-krona-border'
: 'text-krona-secondary hover:text-krona-primary'
}`}
>
<BarChart3 className="w-3.5 h-3.5" />
<span>Операционная сводка / KPI</span>
</button>
</div>
</div>
{/* If Dashboard mode is active, render DynamicDashboard */}
{activeViewMode === 'dashboard' ? (
<DynamicDashboard />
) : (
/* Catalog Mode */
<div className="space-y-6">
{/* Main Hero Header */}
<div className="p-6 md:p-8 rounded-3xl bg-gradient-to-br from-[#252722] via-krona-surface to-[#1F211C] border border-krona-border shadow-krona-card space-y-4">
<div className="flex flex-col md:flex-row md:items-center justify-between gap-6">
<div className="space-y-2 max-w-2xl">
<div className="inline-flex items-center gap-1.5 px-3 py-1 rounded-full bg-emerald-500/10 border border-emerald-500/30 text-emerald-500 text-xs font-bold uppercase tracking-wider">
<Sparkles className="w-3.5 h-3.5" />
<span>Все возможности бизнес-платформы</span>
</div>
<h1 className="text-2xl md:text-3xl font-bold text-krona-primary">
Единый каталог модулей Krona
</h1>
<p className="text-xs md:text-sm text-krona-secondary leading-relaxed">
Платформа открыта для изучения целиком без предварительного выбора отрасли. Исследуйте Pilot 1 транзакции, интерактивные прототипы и перспективные блоки экосистемы.
</p>
</div>
{/* Stats badges */}
<div className="grid grid-cols-2 sm:grid-cols-4 gap-2 text-center text-xs">
<div className="p-3 rounded-xl bg-krona-workspace/80 border border-krona-border">
<div className="text-lg font-bold text-emerald-500">{stats.ready}</div>
<div className="text-[10px] text-krona-secondary">Работает в демо</div>
</div>
<div className="p-3 rounded-xl bg-krona-workspace/80 border border-krona-border">
<div className="text-lg font-bold text-sky-500">{stats.target}</div>
<div className="text-[10px] text-krona-secondary">Интерфейс готов</div>
</div>
<div className="p-3 rounded-xl bg-krona-workspace/80 border border-krona-border">
<div className="text-lg font-bold text-amber-500">{stats.planned}</div>
<div className="text-[10px] text-krona-secondary">Запланирован</div>
</div>
<div className="p-3 rounded-xl bg-krona-workspace/80 border border-krona-border">
<div className="text-lg font-bold text-purple-500">{stats.service}</div>
<div className="text-[10px] text-krona-secondary">Сервисные</div>
</div>
</div>
</div>
{/* Live Search Input */}
<div className="relative pt-2">
<Search className="w-4 h-4 absolute left-3.5 top-5 text-krona-secondary" />
<input
type="text"
value={catalogSearchQuery}
onChange={(e) => setCatalogSearchQuery(e.target.value)}
placeholder="Быстрый поиск по названию, возможностям или описанию модуля..."
data-testid="catalog-search-input"
className="w-full pl-10 pr-10 py-2.5 text-xs bg-krona-workspace border border-krona-border rounded-xl text-krona-primary placeholder:text-krona-secondary focus:outline-none focus:border-krona-gold shadow-inner"
/>
{catalogSearchQuery && (
<button
onClick={() => setCatalogSearchQuery('')}
className="absolute right-3.5 top-5 text-krona-secondary hover:text-krona-primary p-0.5"
title="Очистить поиск"
>
<X className="w-3.5 h-3.5" />
</button>
)}
</div>
</div>
{/* Industry Profiles Filter Bar */}
<div className="p-4 rounded-2xl bg-krona-surface border border-krona-border shadow-krona-soft space-y-3">
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-2 border-b border-krona-border/60 pb-2.5">
<div className="flex items-center gap-2">
<Building className="w-4 h-4 text-krona-gold" />
<span className="text-xs font-bold text-krona-primary uppercase tracking-wider">
Отраслевой фокус (не скрывает остальные модули):
</span>
</div>
{isProfileFilterActive && (
<button
onClick={() => setCatalogProfileFilter('all')}
className="text-[11px] text-krona-gold hover:underline flex items-center gap-1"
data-testid="reset-profile-filter-btn"
>
<RotateCcw className="w-3 h-3" />
<span>Сбросить в «Все модули»</span>
</button>
)}
</div>
<div className="flex items-center gap-1.5 overflow-x-auto pb-1 text-xs">
{/* Все модули (По умолчанию) */}
<button
data-testid="profile-filter-all"
onClick={() => setCatalogProfileFilter('all')}
className={`px-3 py-1.5 rounded-lg font-medium whitespace-nowrap transition-colors ${
catalogProfileFilter === 'all'
? 'bg-krona-gold text-white font-semibold shadow-xs'
: 'bg-krona-workspace text-krona-secondary hover:text-krona-primary border border-krona-border'
}`}
>
Все модули ({stats.total})
</button>
{/* Рекомендовано для текущей отрасли */}
<button
data-testid="profile-filter-recommended"
onClick={() => setCatalogProfileFilter('recommended')}
className={`px-3 py-1.5 rounded-lg font-medium whitespace-nowrap transition-colors flex items-center gap-1 ${
catalogProfileFilter === 'recommended'
? 'bg-[#2F684C] text-white font-semibold shadow-xs'
: 'bg-krona-workspace text-krona-secondary hover:text-krona-primary border border-krona-border'
}`}
>
<Sparkles className="w-3 h-3 text-krona-gold" />
<span>Рекомендовано для моей отрасли ({activeProfile.name})</span>
</button>
{/* Specific profiles */}
{Object.values(BUSINESS_PROFILES).map((p) => (
<button
key={p.id}
data-testid={`profile-filter-${p.id}`}
onClick={() => setCatalogProfileFilter(p.id)}
className={`px-3 py-1.5 rounded-lg font-medium whitespace-nowrap transition-colors ${
catalogProfileFilter === p.id
? 'bg-[#2F684C] text-white font-semibold shadow-xs'
: 'bg-krona-workspace text-krona-secondary hover:text-krona-primary border border-krona-border'
}`}
>
{p.name}
</button>
))}
</div>
</div>
{/* Categories and Status Filters Bar */}
<div className="space-y-3">
{/* Categories horizontal selector */}
<div className="flex items-center gap-1.5 overflow-x-auto pb-1 text-xs">
{CATALOG_CATEGORIES.map((cat) => {
const isSelected = catalogCategoryFilter === cat.id;
const count =
cat.id === 'all'
? CATALOG_MODULES.length
: CATALOG_MODULES.filter((m) => m.category === cat.id).length;
return (
<button
key={cat.id}
data-testid={`category-filter-${cat.id}`}
onClick={() => setCatalogCategoryFilter(cat.id)}
className={`px-3 py-1.5 rounded-xl font-medium whitespace-nowrap transition-all flex items-center gap-1.5 ${
isSelected
? 'bg-krona-surface text-krona-primary font-bold border-2 border-krona-green shadow-xs'
: 'bg-krona-surface/60 text-krona-secondary hover:text-krona-primary border border-krona-border'
}`}
>
<span>{cat.label}</span>
<span className={`text-[10px] px-1.5 py-0.2 rounded-full ${
isSelected ? 'bg-krona-green/20 text-krona-green' : 'bg-krona-workspace text-krona-secondary'
}`}>
{count}
</span>
</button>
);
})}
</div>
{/* Status & Reset filter row */}
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3 text-xs">
<div className="flex items-center gap-2 flex-wrap">
<div className="flex items-center gap-1 text-krona-secondary">
<Filter className="w-3.5 h-3.5" />
<span>Статус готовности:</span>
</div>
<div className="flex items-center gap-1 flex-wrap">
{[
{ id: 'all', label: 'Все статусы' },
{ id: 'ready', label: 'Работает в демо' },
{ id: 'ui_prepared', label: 'Интерфейс подготовлен' },
{ id: 'planned', label: 'Запланирован' },
{ id: 'service_required', label: 'Требует подключения' },
].map((st) => (
<button
key={st.id}
data-testid={`readiness-filter-${st.id}`}
onClick={() => setCatalogStatusFilter(st.id)}
className={`px-2.5 py-1 rounded-lg transition-colors ${
catalogStatusFilter === st.id
? 'bg-krona-surface text-krona-primary font-bold border border-krona-border shadow-xs'
: 'text-krona-secondary hover:text-krona-primary'
}`}
>
{st.label}
</button>
))}
</div>
</div>
{(catalogCategoryFilter !== 'all' ||
catalogStatusFilter !== 'all' ||
catalogSearchQuery ||
catalogProfileFilter !== 'all') && (
<Button
variant="secondary"
size="sm"
onClick={resetCatalogFilters}
className="text-xs"
data-testid="reset-all-filters-btn"
>
<RotateCcw className="w-3 h-3 mr-1" />
<span>Сбросить все фильтры</span>
</Button>
)}
</div>
</div>
{/* Module Cards Sections */}
{isProfileFilterActive ? (
/* When an industry profile is selected: 2 sections */
<div className="space-y-8">
{/* Section 1: Recommended */}
<div className="space-y-4" data-testid="recommended-section">
<div className="flex items-center justify-between border-b border-krona-border pb-2">
<div className="flex items-center gap-2">
<Sparkles className="w-4 h-4 text-krona-gold" />
<h2 className="text-base font-bold text-krona-primary">
Рекомендовано для вашей отрасли ({
catalogProfileFilter === 'recommended'
? activeProfile.name
: BUSINESS_PROFILES[catalogProfileFilter]?.name || 'Выбранная отрасль'
})
</h2>
<span className="text-xs px-2 py-0.5 rounded-full bg-krona-gold/15 text-krona-gold-text font-bold">
{recommendedList.length}
</span>
</div>
</div>
{recommendedList.length > 0 ? (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{recommendedList.map((mod) => (
<ModuleCard
key={mod.id}
module={mod}
isRecommended={true}
onOpenDetails={setSelectedModuleForModal}
/>
))}
</div>
) : (
<div className="p-6 text-center text-xs text-krona-secondary italic bg-krona-surface/40 rounded-2xl border border-krona-border">
По текущим фильтрам нет рекомендованных модулей
</div>
)}
</div>
{/* Section 2: Other platform capabilities */}
<div className="space-y-4" data-testid="other-modules-section">
<div className="flex items-center justify-between border-b border-krona-border pb-2">
<div className="flex items-center gap-2">
<Layers className="w-4 h-4 text-krona-secondary" />
<h2 className="text-base font-bold text-krona-primary">
Все остальные возможности платформы (доступны для подключения)
</h2>
<span className="text-xs px-2 py-0.5 rounded-full bg-krona-workspace text-krona-secondary font-bold">
{otherList.length}
</span>
</div>
</div>
{otherList.length > 0 ? (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{otherList.map((mod) => (
<ModuleCard
key={mod.id}
module={mod}
isRecommended={false}
onOpenDetails={setSelectedModuleForModal}
/>
))}
</div>
) : (
<div className="p-6 text-center text-xs text-krona-secondary italic bg-krona-surface/40 rounded-2xl border border-krona-border">
По текущим фильтрам нет дополнительных модулей
</div>
)}
</div>
</div>
) : (
/* Unified Grid of All Modules */
<div className="space-y-4" data-testid="all-modules-grid">
<div className="flex items-center justify-between border-b border-krona-border pb-2">
<div className="flex items-center gap-2">
<Grid className="w-4 h-4 text-krona-gold" />
<h2 className="text-base font-bold text-krona-primary">
Все модули платформы Krona
</h2>
<span className="text-xs px-2 py-0.5 rounded-full bg-krona-gold/15 text-krona-gold-text font-bold">
Найдено: {filteredModules.length}
</span>
</div>
</div>
{filteredModules.length > 0 ? (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{filteredModules.map((mod) => (
<ModuleCard
key={mod.id}
module={mod}
onOpenDetails={setSelectedModuleForModal}
/>
))}
</div>
) : (
<div className="p-12 text-center text-xs text-krona-secondary bg-krona-surface/40 rounded-2xl border border-krona-border space-y-3">
<p>По запросу «{catalogSearchQuery}» ничего не найдено.</p>
<Button variant="secondary" size="sm" onClick={resetCatalogFilters}>
Сбросить фильтры поиска
</Button>
</div>
)}
</div>
)}
</div>
)}
{/* Module Info Modal */}
<ModuleInfoModal
module={selectedModuleForModal}
isOpen={Boolean(selectedModuleForModal)}
onClose={() => setSelectedModuleForModal(null)}
/>
</div>
);
};

View file

@ -0,0 +1,747 @@
export type ModuleCategory =
| 'sales' // Клиенты и продажи
| 'operations' // Операционная работа
| 'inventory' // Товары и закупки
| 'employees' // Сотрудники
| 'communications' // Коммуникации
| 'finance' // Финансы
| 'marketing' // Продвижение
| 'analytics' // Управление и аналитика
| 'settings'; // Настройки платформы
export type ReadinessStatus = 'ready' | 'ui_prepared' | 'planned' | 'service_required';
export type AvailabilityStatus = 'connected' | 'demo' | 'in_development' | 'requires_setup';
export interface ModuleInfoDetails {
purpose: string;
expectedCapabilities: string[];
currentStatus: string;
plannedIntegrations: string[];
}
export interface CatalogModuleItem {
id: string;
title: string;
category: ModuleCategory;
description: string;
features: string[];
readiness: ReadinessStatus;
availability: AvailabilityStatus;
route: string;
iconName: string;
recommendedProfiles: string[];
details: ModuleInfoDetails;
}
export const CATALOG_CATEGORIES: { id: ModuleCategory | 'all'; label: string }[] = [
{ id: 'all', label: 'Все категории' },
{ id: 'sales', label: 'Клиенты и продажи' },
{ id: 'operations', label: 'Операционная работа' },
{ id: 'inventory', label: 'Товары и закупки' },
{ id: 'employees', label: 'Сотрудники' },
{ id: 'communications', label: 'Коммуникации' },
{ id: 'finance', label: 'Финансы' },
{ id: 'marketing', label: 'Продвижение' },
{ id: 'analytics', label: 'Управление и аналитика' },
{ id: 'settings', label: 'Настройки платформы' },
];
export const READINESS_CONFIG: Record<ReadinessStatus, {
label: string;
maturity: 'pilot1' | 'target' | 'demo' | 'planned';
color: string;
description: string;
}> = {
ready: {
label: 'Работает в демо',
maturity: 'pilot1',
color: 'emerald',
description: 'Полный интерактивный сценарий с хранением в IndexedDB и транзакционной логикой',
},
ui_prepared: {
label: 'Интерфейс подготовлен',
maturity: 'target',
color: 'sky',
description: 'Интерактивный целевой интерфейс с бизнес-логикой и демонстрационными данными',
},
planned: {
label: 'Запланирован',
maturity: 'planned',
color: 'amber',
description: 'Архитектура спроектирована, разработка в дорожной карте (Q4)',
},
service_required: {
label: 'Требует подключения',
maturity: 'demo',
color: 'purple',
description: 'Работает в режиме эмуляции, требует подключения внешнего провайдера/LLM',
},
};
export const AVAILABILITY_CONFIG: Record<AvailabilityStatus, {
label: string;
variant: 'green' | 'blue' | 'warning' | 'gray';
}> = {
connected: { label: 'Подключён', variant: 'green' },
demo: { label: 'Демо-режим', variant: 'blue' },
in_development: { label: 'В разработке', variant: 'warning' },
requires_setup: { label: 'Требует настройки', variant: 'gray' },
};
export const CATALOG_MODULES: CatalogModuleItem[] = [
// 1. CRM
{
id: 'crm',
title: 'CRM и клиенты',
category: 'sales',
description: 'Управление базой контактов, компаний, лидов и сквозная воронка продаж в Kanban-виде.',
features: [
'Воронка сделок с этапами и вероятностью (Kanban Drag & Drop)',
'Карточка «Клиент 360°» с таймлайном, сделками и заказами',
'Конвертация лидов в контакты и связанные сделки в один клик',
'Мастер импорта клиентских баз из CSV с маппингом полей',
],
readiness: 'ready',
availability: 'connected',
route: '/crm',
iconName: 'Users',
recommendedProfiles: [
'restaurant_hall_delivery',
'restaurant_delivery_only',
'auto_service',
'retail_store',
'beauty_salon',
'wholesale_trade',
'production_workshop',
],
details: {
purpose: 'Централизованное управление отношениями с контрагентами, ведение лидов до победной сделки и полная история взаимодействия.',
expectedCapabilities: [
'Омниканальный сбор входящих лидов из сайтов и мессенджеров',
'Интерактивная канбан-доска с расчетом взвешенной суммы этапов',
'Сквозное создание заказа в Коммерции сразу при переводе в Won',
'Фильтрация, кастомные сегменты и экспорт в Excel/CSV',
],
currentStatus: 'Pilot 1: полностью реализован, хранение в IndexedDB, сквозной транзакционный контур.',
plannedIntegrations: ['Телефония (Asterisk/Mango)', 'WhatsApp Business API', 'Telegram Bot API', 'Сервисы обогащения по ИНН (DaData)'],
},
},
// 2. Sales
{
id: 'sales',
title: 'Продажи и заказы',
category: 'sales',
description: 'Управление коммерческими заказами, счетами, оплатами и статусами отгрузки со склада.',
features: [
'Жизненный цикл заказа: Подтверждение → Оплата → Отгрузка',
'Автоматическое резервирование товара на складе при подтверждении',
'Фиксация оплат и автоформирование входящего денежного потока (ДДС)',
'Обработка возвратов с восстановлением остатка и корректировкой кассы',
],
readiness: 'ready',
availability: 'connected',
route: '/sales',
iconName: 'ShoppingBag',
recommendedProfiles: [
'restaurant_hall_delivery',
'restaurant_delivery_only',
'auto_service',
'retail_store',
'wholesale_trade',
'production_workshop',
],
details: {
purpose: 'Обеспечение безошибочного исполнения сделок, взаиморасчетов с покупателями и контроля складских резервов.',
expectedCapabilities: [
'Создание и редактирование многопозиционных заказов с НДС и скидками',
'Генерация фискальных чеков и печатных форм счетов (УПД/ТОРГ-12)',
'Синхронизация складских резервов в реальном времени',
'Контроль дебиторской задолженности и отсрочек платежей',
],
currentStatus: 'Pilot 1: полностью реализован, связан со складом и финансами.',
plannedIntegrations: ['Банковский эквайринг (Сбер, Тинькофф)', 'Онлайн-кассы (54-ФЗ, Атол, Эвотор)', 'Службы доставки (СДЭК, Яндекс Доставка)'],
},
},
// 3. Inventory
{
id: 'inventory',
title: 'Товары и склад',
category: 'inventory',
description: 'Многоскладской партионный учет, адресное хранение, приемка, перемещения и инвентаризация.',
features: [
'Раздельный учет: физический остаток, резервы и доступно к продаже',
'Оформление доприемки товаров с пересчетом себестоимости',
'Журнал складских перемещений между торговыми точками',
'Акты инвентаризации и списаний с фиксацией в аудит-логе',
],
readiness: 'ready',
availability: 'connected',
route: '/inventory',
iconName: 'Boxes',
recommendedProfiles: [
'restaurant_hall_delivery',
'restaurant_delivery_only',
'auto_service',
'retail_store',
'wholesale_trade',
'production_workshop',
],
details: {
purpose: 'Предотвращение пересортицы и кассовых разрывов за счет прозрачного учета складских запасов в реальном времени.',
expectedCapabilities: [
'Адресное хранение по ячейкам, зонам и стеллажам',
'Партионный учет по срокам годности (FIFO) и серийным номерам',
'Мобильный сканер штрихкодов для быстрой инвентаризации',
'Автоматический расчет средней себестоимости списаний',
],
currentStatus: 'Pilot 1: полностью реализован, хранение в IndexedDB, реактивная синхронизация.',
plannedIntegrations: ['ТСД (Терминалы сбора данных)', 'Национальная система маркировки Честный ЗНАК', 'Весовое оборудование'],
},
},
// 4. Procurement
{
id: 'procurement',
title: 'Закупки и поставщики',
category: 'inventory',
description: 'Управление базой поставщиков, договорами, графиками поставок и автозаказом по неснижаемым остаткам.',
features: [
'Реестр поставщиков с реквизитами, прайс-листами и договорами',
'Заявки на закупку (PO) с контролем согласования бюджетов',
'Автоматическое формирование заказов по точкам неснижаемого остатка',
'Входящие накладные и сверка расхождений цен и объемов',
],
readiness: 'planned',
availability: 'in_development',
route: '/procurement',
iconName: 'Truck',
recommendedProfiles: [
'restaurant_hall_delivery',
'retail_store',
'wholesale_trade',
'production_workshop',
],
details: {
purpose: 'Бесперебойное снабжение бизнеса сырьем и товарами по лучшим ценам с исключением ошибок ручного заказа.',
expectedCapabilities: [
'Автозаказ по алгоритму прогнозирования темпов расхода',
'Контроль задолженности перед поставщиками и графиков оплат',
'Автоматический прием электронных накладных через ЭДО',
'Оценка надежности и рейтинга поставщиков по срокам поставок',
],
currentStatus: 'Запланирован: архитектурная спецификация утверждена, интерфейс прототипируется, релиз Q4.',
plannedIntegrations: ['Диадок / СБИС (ЭДО)', '1С:Предприятие 8.3', 'Банк-Клиент (DirectBank)', 'ГИС Меркурий (ВетИС)'],
},
},
// 5. Operations
{
id: 'operations',
title: 'Операционная работа',
category: 'operations',
description: 'Рабочие места кассиров (POS), экраны кухни (KDS), бронирование столов и заказ-наряды автосервиса.',
features: [
'Сенсорный кассовый терминал с быстрой модификацией позиций и чеками',
'Кухонный монитор (KDS) со станциями готовки и таймерами ожидания',
'Интерактивная карта залов, бронирование столов и предчеки',
'Управление постами, заказ-нарядами и мастерами автосервиса',
],
readiness: 'ui_prepared',
availability: 'demo',
route: '/operations',
iconName: 'Workflow',
recommendedProfiles: [
'restaurant_hall_delivery',
'restaurant_delivery_only',
'auto_service',
'retail_store',
'beauty_salon',
],
details: {
purpose: 'Автоматизация фронт-офиса и операционного персонала на передовой линии обслуживания клиентов.',
expectedCapabilities: [
'Работа в офлайн-режиме с последующей синхронизацией чеков',
'Разделение чеков по гостям и смешанная оплата (нал + карта)',
'Звуковые оповещения KDS о поступлении новых блюд в цех',
'Учет выработки мастеров и нормо-часов в заказ-нарядах',
],
currentStatus: 'Target prototype: интерактивные POS, KDS и карта столов с живой бизнес-логикой.',
plannedIntegrations: ['Фискальные регистраторы (Штрих-М, Атол)', 'Банковские пин-пады', 'Кухонные принтеры чеков (ESC/POS)'],
},
},
// 6. Employees
{
id: 'employees',
title: 'Сотрудники и смены',
category: 'employees',
description: 'Управление персоналом, интерактивный график смен, контроль переработок и учет рабочего времени.',
features: [
'Планировщик смен с удобной недельной и месячной сеткой',
'Автоматическое обнаружение конфликтов графика (>12 ч подряд)',
'Учет ролей, квалификаций, часовых ставок и выходов',
'Табель учета фактически отработанного времени (Т-13)',
],
readiness: 'ui_prepared',
availability: 'demo',
route: '/employees',
iconName: 'UserCheck',
recommendedProfiles: [
'restaurant_hall_delivery',
'auto_service',
'retail_store',
'beauty_salon',
'production_workshop',
],
details: {
purpose: 'Оптимизация фонда оплаты труда, справедливое планирование смен и соблюдение норм трудового распорядка.',
expectedCapabilities: [
'Самозапись сотрудников на открытые смены через чат-бот',
'Фиксация прихода/ухода по пин-коду или биометрии планшета',
'Автоматический расчет авансов и премий от личной выручки',
'Контроль медицинских книжек и допусков к работе',
],
currentStatus: 'Target prototype: интерактивный планировщик смен с защитой от переработок.',
plannedIntegrations: ['1С:ЗУП (Зарплата и управление персоналом)', 'СКУД (Турникеты)', 'Telegram-бот для персонала'],
},
},
// 7. Communications
{
id: 'communications',
title: 'Коммуникации и Inbox',
category: 'communications',
description: 'Единый омниканальный центр диалогов: Telegram, WhatsApp, онлайн-чат сайта и внутренние чаты команды.',
features: [
'Единая лента входящих сообщений (Unified Inbox)',
'Интерактивная симуляция ответа клиенту с моментальной доставкой',
'Фильтрация диалогов по каналам, статусу и ответственным',
'Передача переписки между операторами без потери контекста',
],
readiness: 'ui_prepared',
availability: 'demo',
route: '/communications',
iconName: 'MessageSquareText',
recommendedProfiles: [
'restaurant_hall_delivery',
'auto_service',
'retail_store',
'beauty_salon',
'wholesale_trade',
],
details: {
purpose: 'Ни одно обращение клиента не теряется: объединение всех цифровых каналов в одном удобном окне оператора.',
expectedCapabilities: [
'Быстрые шаблоны ответов с автоподстановкой имени и заказа',
'Автоматическое создание лида в CRM из нового диалога',
'Поддержка медиафайлов: фото чеков, голосовые, накладные',
'Маршрутизация чатов по квалификации дежурных менеджеров',
],
currentStatus: 'Target prototype: омниканальный inbox с тестовой симуляцией отправки сообщений.',
plannedIntegrations: ['Telegram Bot API', 'WhatsApp Cloud API / WABA', 'ВКонтакте Сообщения', 'Web-виджет чата'],
},
},
// 8. HelpDesk
{
id: 'helpdesk',
title: 'HelpDesk и поддержка',
category: 'communications',
description: 'Система тикетов поддержки клиентов, контроль соблюдения SLA, база знаний и внутренние заметки.',
features: [
'Очередь тикетов с таймерами дедлайнов и контролем SLA',
'Разделение публичной переписки с клиентом и приватных заметок',
'Категоризация инцидентов по приоритету (Critical, High, Medium)',
'Аналитика скорости первого ответа и решения проблем',
],
readiness: 'ui_prepared',
availability: 'demo',
route: '/helpdesk',
iconName: 'Headset',
recommendedProfiles: [
'wholesale_trade',
'retail_store',
'auto_service',
'production_workshop',
],
details: {
purpose: 'Стандартизация сервисного обслуживания, устранение жалоб и повышение индекса удовлетворенности клиентов (CSAT).',
expectedCapabilities: [
'Автоэскалация просроченных тикетов старшему администратору',
'База знаний готовых регламентов и инструкций для операторов',
'Оценка качества обслуживания клиентом после закрытия заявки',
'Привязка тикета к конкретному заказу, сделке или отгрузке',
],
currentStatus: 'Target prototype: рабочая очередь обращений с расчетом SLA и заметками.',
plannedIntegrations: ['E-mail (IMAP/SMTP)', 'Клиентский портал самообслуживания', 'Telegram HelpDesk бот'],
},
},
// 9. Tasks
{
id: 'tasks',
title: 'Задачи и проекты',
category: 'analytics',
description: 'Командный таск-трекер с канбан-доской, списками подзадач, дедлайнами и связкой с клиентами.',
features: [
'Канбан-доска задач по стадиям (К выполнению, В работе, Готово)',
'Интерактивные чек-листы подзадач внутри каждой карточки',
'Контроль дедлайнов с подсветкой просроченных поручений',
'Привязка задач к клиентам CRM и коммерческим сделкам',
],
readiness: 'ui_prepared',
availability: 'demo',
route: '/tasks',
iconName: 'CheckSquare',
recommendedProfiles: [
'restaurant_hall_delivery',
'auto_service',
'wholesale_trade',
'production_workshop',
'beauty_salon',
],
details: {
purpose: 'Прозрачное распределение обязанностей в команде и строгий контроль выполнения регулярных регламентов.',
expectedCapabilities: [
'Шаблоны регулярных повторяющихся задач (чек-листы открытия смены)',
'Диаграмма Ганта для крупных проектов запуска новых точек',
'Учет трудозатрат (тайм-трекер) по каждой задаче',
'Push-уведомления исполнителям о назначении и приближении дедлайна',
],
currentStatus: 'Target prototype: функциональная канбан-доска задач с чек-листами подзадач.',
plannedIntegrations: ['Календари (Google, Yandex, Apple iCal)', 'Telegram-уведомления', 'Krona Automation'],
},
},
// 10. Finance
{
id: 'finance',
title: 'Финансы и ДДС',
category: 'finance',
description: 'Управленческий учет движения денег (ДДС), расчет прибылей и убытков (P&L) и контроль кассовых смен.',
features: [
'Отчет о ДДС (Cash Flow) в реальном времени по счетам и статьям',
'Расчет операционной прибыли (P&L) с маржинальностью бизнеса',
'Автоматическое зачисление платежей из оплаченных заказов',
'Контроль кассовых смен, инкассаций и остатков в сейфах',
],
readiness: 'ready',
availability: 'connected',
route: '/finance',
iconName: 'BadgeDollarSign',
recommendedProfiles: [
'restaurant_hall_delivery',
'restaurant_delivery_only',
'auto_service',
'retail_store',
'beauty_salon',
'wholesale_trade',
'production_workshop',
],
details: {
purpose: 'Защита от кассовых разрывов, понимание чистой прибыли и абсолютный финансовый контроль каждого рубля бизнеса.',
expectedCapabilities: [
'Многоуровневый справочник статей доходов и расходов (DDS)',
'Платежный календарь для согласования плановых расходов',
'Учет нескольких юрлиц, расчетных счетов и касс',
'Автоматический импорт банковских выписок (1С/Банк-Клиент)',
],
currentStatus: 'Pilot 1: полностью реализован, связан с заказами продаж и возвратами.',
plannedIntegrations: ['Прямой шлюз Банк-Клиент (DirectBank)', 'Онлайн-бухгалтерия (Контур.Эльба, Моё Дело)', 'Эквайринг'],
},
},
// 11. Marketing
{
id: 'marketing',
title: 'Маркетинг и промо',
category: 'marketing',
description: 'Сегментация клиентов, акции, скидки, промокоды и симуляция запуска промо-кампаний.',
features: [
'Динамические когорты клиентов (Новые, Постоянные, В зоне риска)',
'Конструктор промо-акций: проценты, фиксированные суммы, подарки',
'Симулятор отправки рассылок с метриками доставки и конверсии',
'Аналитика влияния акций на средний чек и повторные визиты',
],
readiness: 'ui_prepared',
availability: 'demo',
route: '/marketing',
iconName: 'Megaphone',
recommendedProfiles: [
'restaurant_hall_delivery',
'retail_store',
'beauty_salon',
'auto_service',
],
details: {
purpose: 'Привлечение новых клиентов и удержание существующей базы с помощью персонализированных предложений.',
expectedCapabilities: [
'RFM-анализ базы клиентов с автоматическим присвоением статусов',
'Триггерные механики: поздравление с днем рождения, «мы скучаем»',
'Интеграция с бонусной программой лояльности (кешбэк баллами)',
'Оценка ROI каждого маркетингового канала и затрат на привлечение',
],
currentStatus: 'Target prototype: сегменты, промокоды и симулятор промо-кампаний.',
plannedIntegrations: ['SMS-шлюзы', 'Telegram / WhatsApp рассылки', 'Программы лояльности', 'Яндекс Метрика'],
},
},
// 12. Analytics
{
id: 'analytics',
title: 'Аналитика и BI',
category: 'analytics',
description: 'Бизнес-аналитика, наглядные дашборды выручки, динамика чеков и ABC/XYZ матрица номенклатуры.',
features: [
'Интерактивные графики выручки и среднего чека по периодам',
'Матрица ABC-анализа: выявление ключевых драйверов прибыли',
'Когортный анализ возвращаемости клиентов (Retention)',
'Сравнение показателей по торговым точкам и сменам',
],
readiness: 'ui_prepared',
availability: 'demo',
route: '/analytics',
iconName: 'BarChart3',
recommendedProfiles: [
'restaurant_hall_delivery',
'restaurant_delivery_only',
'auto_service',
'retail_store',
'beauty_salon',
'wholesale_trade',
'production_workshop',
],
details: {
purpose: 'Принятие управленческих решений на основе точных цифр, графиков и предиктивных трендов.',
expectedCapabilities: [
'Настраиваемый конструктор виджетов и ключевых показателей (KPI)',
'Выявление скрытых потерь, списаний и убыточных позиций меню',
'План-фактный анализ выполнения целей продаж',
'Экспорт управленческих отчетов в PDF и Excel',
],
currentStatus: 'Target prototype: графики Recharts, фильтр периодов и расчет ABC-матрицы.',
plannedIntegrations: ['Внешние BI-системы (PowerBI, Apache Superset)', 'Data Warehouse (ClickHouse, BigQuery)'],
},
},
// 13. Automation
{
id: 'automation',
title: 'Автоматизация Workflows',
category: 'analytics',
description: 'No-Code конструктор сценариев: автоматические цепочки действий при событиях в платформе.',
features: [
'Визуальный конструктор сценариев (Триггер → Условие → Действие)',
'Готовые отраслевые шаблоны (уведомления об остатках, автозадачи)',
'Интерактивный запуск тестового прогона с логом выполнения',
'История срабатываний и аудит автоматических изменений',
],
readiness: 'ui_prepared',
availability: 'demo',
route: '/automation',
iconName: 'Cpu',
recommendedProfiles: [
'restaurant_hall_delivery',
'auto_service',
'retail_store',
'wholesale_trade',
'production_workshop',
],
details: {
purpose: 'Освобождение сотрудников от рутины за счет автоматизации стандартных бизнес-процессов.',
expectedCapabilities: [
'Триггеры по изменению статусов заказов, времени, порогам остатков',
'Действия: отправка SMS/Telegram, смена статуса сделки, создание задачи',
'Ветвление логики IF/ELSE с проверкой любых полей сущностей',
'Webhook-триггеры для интеграции с внешними микросервисами',
],
currentStatus: 'Target prototype: визуальный конструктор цепочек с интерактивным логом шагов.',
plannedIntegrations: ['Webhooks API', 'Zapier / Make совместимость', 'Krona EventBus'],
},
},
// 14. AI Center
{
id: 'ai',
title: 'Krona AI — ИИ-центр',
category: 'analytics',
description: 'Интеллектуальный бизнес-оркестратор и специализированные агенты: Аналитик, Склад, Маркетолог.',
features: [
'Контекстный чат с ИИ, знающим актуальные остатки и выручку',
'Команда агентов: Бизнес-аналитик, Складской агент, Маркетолог',
'Прозрачный лог вызова инструментов (Tool Calls) и расчетов',
'Генерация артефактов, отчетов и рекомендаций в модальном окне',
],
readiness: 'service_required',
availability: 'demo',
route: '/ai',
iconName: 'Sparkles',
recommendedProfiles: [
'restaurant_hall_delivery',
'auto_service',
'retail_store',
'beauty_salon',
'wholesale_trade',
'production_workshop',
],
details: {
purpose: 'Второй пилот для руководителя: мгновенные инсайты по операционным данным без ручного копания в таблицах.',
expectedCapabilities: [
'Автономный мониторинг критических отклонений выручки и запасов',
'Генерация привлекательных текстов промо-рассылок и постов',
'Голосовой ввод поручений и создание задач на естественном языке',
'Строгие границы безопасности без утечки конфиденциальных данных',
],
currentStatus: 'Демо-режим: интерактивная симуляция работы ИИ-агентов и вызова инструментов.',
plannedIntegrations: ['OpenAI / Anthropic API', 'Google Gemini API', 'Локальные модели vLLM / Ollama'],
},
},
// 15. CMS
{
id: 'cms',
title: 'CMS и Конструктор сайтов',
category: 'marketing',
description: 'Визуальный конструктор посадочных страниц, интернет-витрин и лид-форм с адаптивным превью.',
features: [
'6 готовых адаптивных блоков: Hero, Каталог/Меню, Преимущества, Отзывы, Форма, Контакты',
'Переключение экранов предпросмотра (Desktop 1440, Tablet 768, Mobile 390)',
'Интерактивное редактирование текста, заголовков и цен',
'Сохранение структуры страницы в локальное состояние',
],
readiness: 'ui_prepared',
availability: 'demo',
route: '/cms',
iconName: 'Globe',
recommendedProfiles: [
'restaurant_hall_delivery',
'restaurant_delivery_only',
'retail_store',
'beauty_salon',
'auto_service',
],
details: {
purpose: 'Быстрый запуск продающих лендингов и меню доставки без привлечения веб-разработчиков.',
expectedCapabilities: [
'Прямая интеграция каталога товаров и цен из складского модуля',
'Автоматический прием заказов с сайта напрямую в кассу (POS)',
'Привязка собственного доменного имени и бесплатный SSL',
'SEO-оптимизация мета-тегов и OpenGraph для соцсетей',
],
currentStatus: 'Target prototype: 6 типов блоков, переключение адаптивности и экспорт структуры.',
plannedIntegrations: ['DNS-провайдеры', 'CDN для быстрой загрузки медиа', 'Яндекс Метрика'],
},
},
// 16. Entity Builder
{
id: 'entity_builder',
title: 'Конструкторы сущностей и полей',
category: 'settings',
description: 'No-Code студия проектирования данных: создание справочников, 39 типов полей, форм и карточек.',
features: [
'Создание кастомных справочников на лету без изменения программного кода',
'Поддержка 39 специализированных типов полей (включая AST-формулы без eval)',
'Drag & Drop Form Builder с адаптивным превью и 50 шагами Undo/Redo',
'Конструктор карточек и табличных представлений с фильтрами',
],
readiness: 'ready',
availability: 'connected',
route: '/settings/entities',
iconName: 'Settings',
recommendedProfiles: [
'restaurant_hall_delivery',
'auto_service',
'retail_store',
'beauty_salon',
'wholesale_trade',
'production_workshop',
],
details: {
purpose: 'Полная адаптация платформы под специфику любого бизнеса без ожидания доработок от программистов.',
expectedCapabilities: [
'Мгновенная регистрация новых сущностей в навигации и роутинге',
'Защита системных инвариантов ядра (нельзя сломать базовые поля)',
'Условная видимость и доступность полей по ролям сотрудников',
'Экспорт и импорт метамоделей в JSON-формате',
],
currentStatus: 'Pilot 1: полностью реализован, динамические рендереры форм, карточек и таблиц.',
plannedIntegrations: ['Krona Metadata Registry', 'IndexedDB schema engine', 'REST API Adapter'],
},
},
// 17. Settings & Profiles
{
id: 'settings',
title: 'Настройки и профили бизнеса',
category: 'settings',
description: 'Параметры компании, отраслевые профили (Ресторан, Авто, Розница), роли (RBAC) и бэкапы.',
features: [
'Переключение отраслевых конфигураций с автонастройкой терминологии',
'Ролевая модель (RBAC): Владелец, Управляющий, Кассир, Кладовщик',
'Резервное копирование: экспорт, сброс и импорт снимков IndexedDB',
'Управление торговыми точками, локациями и темами оформления',
],
readiness: 'ready',
availability: 'connected',
route: '/settings',
iconName: 'Settings',
recommendedProfiles: [
'restaurant_hall_delivery',
'restaurant_delivery_only',
'auto_service',
'retail_store',
'beauty_salon',
'wholesale_trade',
'production_workshop',
],
details: {
purpose: 'Центральный пульт администрирования компании, управление доступом персонала и безопасность данных.',
expectedCapabilities: [
'Настройка прав доступа вплоть до отдельных кнопок и полей',
'Аудит действий пользователей с фиксацией IP и времени',
'Персонализация интерфейса под корпоративный брендбук',
'Экспорт полных резервных копий базы в защищенный архив',
],
currentStatus: 'Pilot 1: полностью реализован, ролевой контроль доступа (403), снэпшоты IndexedDB.',
plannedIntegrations: ['SSO (Single Sign-On / SAML / OIDC)', 'Active Directory / LDAP', 'Cloud Backup'],
},
},
// 18. Apps
{
id: 'apps',
title: 'Каталог приложений и API',
category: 'settings',
description: 'Каталог расширений, готовые коннекторы к кассам, платежным шлюзам, доставке и внешним сервисам.',
features: [
'Каталог готовых отраслевых расширений и интеграций',
'Управление статусом установленных плагинов организации',
'Настройка API-ключей, токенов доступа и вебхуков',
'Модульная архитектура расширения функционала платформы',
],
readiness: 'service_required',
availability: 'demo',
route: '/apps',
iconName: 'Grid',
recommendedProfiles: [
'restaurant_hall_delivery',
'retail_store',
'auto_service',
'wholesale_trade',
],
details: {
purpose: 'Открытая экосистема: легкое подключение внешних IT-сервисов и сервисов партнеров к бизнес-платформе.',
expectedCapabilities: [
'Маркетплейс проверенных плагинов от сторонних разработчиков',
'Песочница для безопасного тестирования API-запросов',
'Журнал доставки событий Webhook с авто-повтором при сбоях',
'Контроль разрешений каждого установленного приложения',
],
currentStatus: 'Демо-каталог: витрина расширений с симуляцией подключения сервисов.',
plannedIntegrations: ['Krona App Store SDK', 'OpenAPI 3.1 Gateway', 'OAuth 2.0 Authorization Server'],
},
},
];

View file

@ -1067,15 +1067,15 @@ export const DynamicFormRenderer: React.FC<DynamicFormRendererProps> = ({
);
})}
{/* Action buttons */}
{/* Action buttons (sticky on mobile for easy reachability) */}
{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 && (
<Button variant="outline" size="md" type="button" onClick={onCancel}>
<Button variant="outline" size="md" type="button" onClick={onCancel} className="min-h-[44px]">
Отмена
</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' ? 'Создать запись' : 'Сохранить изменения'}
</Button>
</div>

View file

@ -142,8 +142,95 @@ export const DynamicTable: React.FC<DynamicTableProps> = ({
</div>
</div>
{/* Table Element */}
<div className="bg-krona-surface border border-krona-border rounded-xl shadow-krona-soft overflow-hidden">
{/* Mobile Card List View (< 768px) */}
<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">
<table className="w-full text-left text-xs border-collapse">
<thead className="bg-krona-workspace border-b border-krona-border select-none">

View file

@ -73,6 +73,7 @@ export const SettingsModule: React.FC = () => {
try {
setIsResetting(true);
await resetDatabase();
useDemoStore.getState().resetCatalogFilters();
setSnapshotStats('База данных сброшена к начальному состоянию');
showToast('База данных успешно сброшена к начальному состоянию!', 'info');
} catch (err) {
@ -310,7 +311,7 @@ export const SettingsModule: React.FC = () => {
disabled={isResetting}
>
<RotateCcw className="w-3.5 h-3.5 mr-1.5" />
{isResetting ? 'Сброс...' : 'Сбросить демо-данные'}
{isResetting ? 'Сброс...' : 'Сбросить базу данных'}
</Button>
<div>

View file

@ -8,6 +8,8 @@ import { Modal } from '../../../shared/ui/Modal';
import { Badge } from '../../../shared/ui/Badge';
import { MaturityBadge } from '../../../shared/ui/MaturityBadge';
import { useToast } from '../../../shared/ui/useToast';
import { resetDatabase } from '../../../services/storage/indexedDb';
import { useDemoStore } from '../../../mock/store';
import {
Layers,
Plus,
@ -20,6 +22,7 @@ import {
Sparkles,
CreditCard,
AlertTriangle,
RotateCcw,
} from 'lucide-react';
export const EntityCatalog: React.FC = () => {
@ -46,6 +49,7 @@ export const EntityCatalog: React.FC = () => {
// Delete modal state
const [entityToDelete, setEntityToDelete] = useState<EntityMetadata | null>(null);
const [isResetting, setIsResetting] = useState(false);
// Filtered entities
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 (
<div className="space-y-5 animate-fade-in">
{/* Top Banner */}
@ -223,16 +248,27 @@ export const EntityCatalog: React.FC = () => {
</p>
</div>
<Button
variant="primary"
size="md"
data-testid="create-entity-btn"
onClick={() => setIsNewEntityModalOpen(true)}
className="shrink-0"
>
<Plus className="w-4 h-4 mr-1.5" />
Создать сущность
</Button>
<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
variant="primary"
size="md"
data-testid="create-entity-btn"
onClick={() => setIsNewEntityModalOpen(true)}
>
<Plus className="w-4 h-4 mr-1.5" />
Создать сущность
</Button>
</div>
</div>
{/* Stats row */}

View file

@ -634,18 +634,21 @@ export const FormBuilder: React.FC = () => {
<div className="flex items-center gap-1">
<button
type="button"
data-testid={`move-field-up-${fieldKey}`}
onClick={(e) => {
e.stopPropagation();
handleMoveField(section.id, fIdx, Math.max(0, fIdx - 1));
}}
disabled={fIdx === 0}
className="p-0.5 hover:text-krona-primary disabled:opacity-20"
title="Переместить влево/вверх"
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="Переместить вверх / влево"
aria-label="Переместить поле выше"
>
←
<MoveUp className="w-3.5 h-3.5" />
</button>
<button
type="button"
data-testid={`move-field-down-${fieldKey}`}
onClick={(e) => {
e.stopPropagation();
handleMoveField(
@ -655,10 +658,11 @@ export const FormBuilder: React.FC = () => {
);
}}
disabled={fIdx === section.fieldKeys.length - 1}
className="p-0.5 hover:text-krona-primary disabled:opacity-20"
title="Переместить вправо/вниз"
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="Переместить вниз / вправо"
aria-label="Переместить поле ниже"
>
→
<MoveDown className="w-3.5 h-3.5" />
</button>
</div>

View file

@ -155,6 +155,17 @@ interface DemoStore {
dismissNotification: (id: string) => void;
markAllNotificationsRead: () => void;
// Catalog filters (Default: 'all' - Все модули)
catalogProfileFilter: string;
setCatalogProfileFilter: (filter: string) => void;
catalogCategoryFilter: string;
setCatalogCategoryFilter: (category: string) => void;
catalogStatusFilter: string;
setCatalogStatusFilter: (status: string) => void;
catalogSearchQuery: string;
setCatalogSearchQuery: (query: string) => void;
resetCatalogFilters: () => void;
// Helper getters
getActiveProfile: () => BusinessProfile;
getActiveCapabilities: () => Record<CapabilityId, boolean>;
@ -544,6 +555,23 @@ export const useDemoStore = create<DemoStore>((set, get) => ({
notifications: state.notifications.map((n) => ({ ...n, read: true })),
})),
// Catalog filters (Default: 'all' - Все модули)
catalogProfileFilter: 'all',
setCatalogProfileFilter: (catalogProfileFilter) => set({ catalogProfileFilter }),
catalogCategoryFilter: 'all',
setCatalogCategoryFilter: (catalogCategoryFilter) => set({ catalogCategoryFilter }),
catalogStatusFilter: 'all',
setCatalogStatusFilter: (catalogStatusFilter) => set({ catalogStatusFilter }),
catalogSearchQuery: '',
setCatalogSearchQuery: (catalogSearchQuery) => set({ catalogSearchQuery }),
resetCatalogFilters: () =>
set({
catalogProfileFilter: 'all',
catalogCategoryFilter: 'all',
catalogStatusFilter: 'all',
catalogSearchQuery: '',
}),
// Getters
getActiveProfile: () => {
const s = get();

View file

@ -3,16 +3,16 @@ import { PageSkeleton } from '../shared/ui/Skeleton';
import { ErrorBoundary } from '../shared/ui/ErrorBoundary';
import { RouteGuard } from '../app/guards/RouteGuard';
const DynamicDashboard = lazy(() =>
import('../features/dashboard/DynamicDashboard').then((m) => ({ default: m.DynamicDashboard }))
const ModulesCatalog = lazy(() =>
import('../features/catalog/ModulesCatalog').then((m) => ({ default: m.ModulesCatalog }))
);
export const DashboardPage: React.FC = () => {
return (
<RouteGuard module="dashboard" moduleName="Дашборд">
<ErrorBoundary scope="Модуль Дашборд">
<Suspense fallback={<PageSkeleton title="Дашборд" />}>
<DynamicDashboard />
<RouteGuard module="dashboard" moduleName="Каталог модулей">
<ErrorBoundary scope="Каталог модулей">
<Suspense fallback={<PageSkeleton title="Каталог модулей" />}>
<ModulesCatalog />
</Suspense>
</ErrorBoundary>
</RouteGuard>

View file

@ -0,0 +1,240 @@
import React, { useState } from 'react';
import { RouteGuard } from '../app/guards/RouteGuard';
import { ErrorBoundary } from '../shared/ui/ErrorBoundary';
import { MaturityBadge } from '../shared/ui/MaturityBadge';
import { Badge } from '../shared/ui/Badge';
import { Button } from '../shared/ui/Button';
import { useToast } from '../shared/ui/useToast';
import {
Truck,
ArrowLeft,
CheckCircle2,
FileText,
Building2,
Clock,
Link2,
Calculator,
} from 'lucide-react';
import { useNavigate } from 'react-router-dom';
export const ProcurementPage: React.FC = () => {
const navigate = useNavigate();
const { showToast } = useToast();
const [requestedEmail, setRequestedEmail] = useState('');
const [monthlySpend, setMonthlySpend] = useState<number>(350000);
const estimatedSavings = Math.round(monthlySpend * 0.085); // ~8.5% procurement optimization
const handleRequestEarlyAccess = (e: React.FormEvent) => {
e.preventDefault();
if (!requestedEmail.trim()) return;
showToast(`Заявка на ранний доступ для ${requestedEmail} успешно зарегистрирована!`, 'success');
setRequestedEmail('');
};
return (
<RouteGuard module="procurement" moduleName="Закупки и поставщики">
<ErrorBoundary scope="Модуль Закупки">
<div className="space-y-6 max-w-6xl mx-auto animate-fade-in pb-12" data-testid="procurement-demo-page">
{/* Top navigation back to catalog */}
<div className="flex items-center justify-between">
<button
onClick={() => navigate('/')}
className="inline-flex items-center text-xs font-medium text-krona-secondary hover:text-krona-primary transition-colors gap-1.5"
>
<ArrowLeft className="w-4 h-4" />
<span>Вернуться в общий каталог модулей</span>
</button>
<div className="flex items-center gap-2">
<MaturityBadge level="planned" size="md" />
<Badge variant="warning" size="md">В разработке (Q4)</Badge>
</div>
</div>
{/* Hero Banner */}
<div className="p-6 md:p-8 rounded-3xl bg-gradient-to-br from-[#252722] via-krona-surface to-[#1F211C] border border-krona-border shadow-krona-card space-y-4">
<div className="inline-flex items-center gap-2 px-3 py-1 rounded-full bg-amber-500/10 border border-amber-500/30 text-amber-500 text-xs font-bold uppercase tracking-wider">
<Clock className="w-3.5 h-3.5" />
<span>Дорожная карта платформы • Релиз Q4</span>
</div>
<div className="flex flex-col md:flex-row md:items-center justify-between gap-6">
<div className="space-y-2 max-w-2xl">
<h1 className="text-2xl md:text-3xl font-bold text-krona-primary flex items-center gap-3">
<div className="p-3 rounded-2xl bg-amber-500/15 border border-amber-500/30 text-amber-500">
<Truck className="w-7 h-7" />
</div>
<span>Закупки и управление поставщиками</span>
</h1>
<p className="text-xs md:text-sm text-krona-secondary leading-relaxed">
Централизованная система снабжения для ресторанов, ритейла, автосервисов и производств. Автозаказ по точкам неснижаемого остатка, сверка электронных накладных через ЭДО и прозрачный контроль цен контрагентов.
</p>
</div>
<div className="p-4 rounded-2xl bg-krona-workspace/80 border border-krona-border text-center space-y-1 shrink-0">
<div className="text-[10px] uppercase font-bold text-krona-secondary">Плановый срок запуска</div>
<div className="text-xl font-bold text-krona-gold">IV квартал 2026</div>
<div className="text-[11px] text-krona-green">Интегрирован со «Складом»</div>
</div>
</div>
</div>
{/* 4 Architectural Columns */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{/* 1. Назначение */}
<div className="p-5 rounded-2xl bg-krona-surface border border-krona-border space-y-3">
<div className="flex items-center gap-2 text-xs font-bold text-krona-primary uppercase tracking-wider">
<Building2 className="w-4 h-4 text-krona-green" />
<span>1. Бизнес-назначение модуля</span>
</div>
<p className="text-xs text-krona-secondary leading-relaxed">
Исключение перебоев в поставках ключевого сырья, устранение человеческого фактора при расчете объема закупок и защита бюджета компании от несанкционированного завышения цен поставщиками.
</p>
<ul className="text-xs text-krona-primary space-y-1.5 pt-1">
<li className="flex items-center gap-2">
<CheckCircle2 className="w-3.5 h-3.5 text-krona-green" />
<span>Единый реестр контрагентов, договоров и спецификаций</span>
</li>
<li className="flex items-center gap-2">
<CheckCircle2 className="w-3.5 h-3.5 text-krona-green" />
<span>Контроль минимальных квантов отгрузки и графиков доставки</span>
</li>
<li className="flex items-center gap-2">
<CheckCircle2 className="w-3.5 h-3.5 text-krona-green" />
<span>Рейтинг поставщиков по показателю точности сроков (OTIF)</span>
</li>
</ul>
</div>
{/* 2. Возможности */}
<div className="p-5 rounded-2xl bg-krona-surface border border-krona-border space-y-3">
<div className="flex items-center gap-2 text-xs font-bold text-krona-primary uppercase tracking-wider">
<FileText className="w-4 h-4 text-krona-gold" />
<span>2. Запланированные возможности</span>
</div>
<ul className="text-xs text-krona-primary space-y-1.5">
<li className="flex items-start gap-2">
<CheckCircle2 className="w-3.5 h-3.5 text-krona-gold mt-0.5" />
<span><strong>Автозаказ (MRP lite):</strong> автоматическое формирование заявок поставщикам при падении складского остатка ниже страхового минимума.</span>
</li>
<li className="flex items-start gap-2">
<CheckCircle2 className="w-3.5 h-3.5 text-krona-gold mt-0.5" />
<span><strong>Согласование бюджетов:</strong> многоуровневый маршрут утверждения заявок руководителями до отправки вендору.</span>
</li>
<li className="flex items-start gap-2">
<CheckCircle2 className="w-3.5 h-3.5 text-krona-gold mt-0.5" />
<span><strong>Сверка накладных:</strong> сравнение цен в заказе с ценами во входящем УПД с фиксацией расхождений.</span>
</li>
</ul>
</div>
{/* 3. Статус */}
<div className="p-5 rounded-2xl bg-krona-surface border border-krona-border space-y-3">
<div className="flex items-center gap-2 text-xs font-bold text-krona-primary uppercase tracking-wider">
<Clock className="w-4 h-4 text-sky-500" />
<span>3. Текущий статус разработки</span>
</div>
<div className="p-3 bg-sky-500/10 border border-sky-500/30 rounded-xl text-xs text-sky-900 dark:text-sky-200 leading-relaxed">
Спецификация API взаимодействия с ядром «Склад» и «Финансы» успешно утверждена. Подготовлены макеты экранных форм и схемы базы данных для партионного сопоставления.
</div>
<p className="text-[11px] text-krona-secondary">
В демо-версии платформы данные закупок эмулируются через приходные накладные в модуле «Склад» и расходные транзакции в «Финансах».
</p>
</div>
{/* 4. Интеграции */}
<div className="p-5 rounded-2xl bg-krona-surface border border-krona-border space-y-3">
<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" />
<span>4. Планируемые внешние интеграции</span>
</div>
<div className="grid grid-cols-2 gap-2 text-xs">
<div className="p-2 rounded-xl bg-krona-workspace border border-krona-border">
<div className="font-semibold text-krona-primary">ЭДО (Диадок / СБИС)</div>
<div className="text-[10px] text-krona-secondary">Прием входящих УПД</div>
</div>
<div className="p-2 rounded-xl bg-krona-workspace border border-krona-border">
<div className="font-semibold text-krona-primary">1С:Предприятие 8.3</div>
<div className="text-[10px] text-krona-secondary">Синхронизация счетов</div>
</div>
<div className="p-2 rounded-xl bg-krona-workspace border border-krona-border">
<div className="font-semibold text-krona-primary">Честный ЗНАК</div>
<div className="text-[10px] text-krona-secondary">Приемка кодов DataMatrix</div>
</div>
<div className="p-2 rounded-xl bg-krona-workspace border border-krona-border">
<div className="font-semibold text-krona-primary">ГИС Меркурий (ВетИС)</div>
<div className="text-[10px] text-krona-secondary">Гашение ВСД общепита</div>
</div>
</div>
</div>
</div>
{/* Interactive ROI Calculator & Early Access Box */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 p-6 rounded-3xl bg-krona-surface border border-krona-border shadow-krona-card">
{/* ROI Calculator */}
<div className="md:col-span-2 space-y-3">
<div className="flex items-center gap-2 text-sm font-bold text-krona-primary">
<Calculator className="w-4 h-4 text-krona-green" />
<span>Интерактивный калькулятор экономии снабжения</span>
</div>
<p className="text-xs text-krona-secondary">
Оцените потенциальную экономию за счет устранения завышения цен поставщиков и оптимизации страховых запасов:
</p>
<div className="space-y-2 pt-2">
<div className="flex items-center justify-between text-xs">
<span className="text-krona-secondary">Среднемесячный объем закупок:</span>
<span className="font-bold text-krona-primary font-mono">{monthlySpend.toLocaleString('ru-RU')} ₽ / мес</span>
</div>
<input
type="range"
min="50000"
max="3000000"
step="50000"
value={monthlySpend}
onChange={(e) => setMonthlySpend(Number(e.target.value))}
className="w-full accent-krona-green"
/>
</div>
<div className="p-3 bg-krona-green/10 border border-krona-green/30 rounded-xl flex items-center justify-between">
<div className="text-xs text-krona-green">
<strong>Прогнозируемая экономия в год:</strong> (~8.5% оптимизации)
</div>
<div className="text-base font-bold text-krona-green font-mono">
+{(estimatedSavings * 12).toLocaleString('ru-RU')} ₽ / год
</div>
</div>
</div>
{/* Early Access Form */}
<div className="p-4 rounded-2xl bg-krona-workspace border border-krona-border space-y-3 flex flex-col justify-between">
<div>
<div className="text-xs font-bold text-krona-primary">Заявка на ранний доступ</div>
<p className="text-[11px] text-krona-secondary mt-1">
Оставьте контакт для участия в закрытом бета-тестировании модуля Закупок:
</p>
</div>
<form onSubmit={handleRequestEarlyAccess} className="space-y-2">
<input
type="email"
placeholder="name@company.ru"
value={requestedEmail}
onChange={(e) => setRequestedEmail(e.target.value)}
className="w-full px-3 py-1.5 text-xs bg-krona-surface border border-krona-border rounded-lg text-krona-primary focus:outline-none focus:border-krona-green"
/>
<Button type="submit" variant="primary" size="sm" className="w-full text-xs">
Запросить ранний доступ
</Button>
</form>
</div>
</div>
</div>
</ErrorBoundary>
</RouteGuard>
);
};
export default ProcurementPage;

View file

@ -21,5 +21,6 @@ export * from './FormBuilderPage';
export * from './CardLayoutBuilderPage';
export * from './ViewBuilderPage';
export * from './CustomEntityRoutePage';
export * from './ProcurementPage';
export * from './error/ForbiddenPage';
export * from './error/NotFoundPage';

View 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;
}

View file

@ -114,10 +114,10 @@ export const Drawer: React.FC<DrawerProps> = ({
/>
{/* 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
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'
} border-krona-border shadow-krona-modal flex flex-col animate-fade-in`}
aria-describedby={subtitle ? subtitleId : undefined}

View file

@ -1,9 +1,9 @@
import React from 'react';
import { clsx } from 'clsx';
import { twMerge } from 'tailwind-merge';
import { CheckCircle2, Layers, FlaskConical } from 'lucide-react';
import { CheckCircle2, Layers, FlaskConical, Clock } from 'lucide-react';
export type MaturityLevel = 'pilot1' | 'target' | 'demo';
export type MaturityLevel = 'pilot1' | 'target' | 'demo' | 'planned';
export interface MaturityBadgeProps extends React.HTMLAttributes<HTMLSpanElement> {
level: MaturityLevel;
@ -40,6 +40,13 @@ export const MaturityBadge: React.FC<MaturityBadgeProps> = ({
icon: FlaskConical,
iconClass: 'text-purple-600 dark:text-purple-400',
},
planned: {
label: 'Запланирован',
description: 'В разработке (дорожная карта Q4)',
badgeClass: 'bg-amber-50 text-amber-800 border-amber-300 dark:bg-amber-950/40 dark:text-amber-300 dark:border-amber-700',
icon: Clock,
iconClass: 'text-amber-600 dark:text-amber-400',
},
}[level];
const Icon = config.icon;

View file

@ -111,7 +111,7 @@ export const Modal: React.FC<ModalProps> = ({
};
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 */}
<div
className="fixed inset-0 krona-overlay transition-opacity duration-200 cursor-pointer"
@ -122,14 +122,14 @@ export const Modal: React.FC<ModalProps> = ({
{/* Modal Dialog */}
<div
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"
aria-modal="true"
aria-labelledby={titleId}
aria-describedby={subtitle ? subtitleId : undefined}
>
{/* 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>
<h3 id={titleId} className="text-base font-semibold text-krona-primary">
{title}
@ -151,13 +151,13 @@ export const Modal: React.FC<ModalProps> = ({
</div>
{/* 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}
</div>
{/* 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}
</div>
)}

File diff suppressed because one or more lines are too long