Compare commits
2 commits
59d767e549
...
42d48618ec
| Author | SHA1 | Date | |
|---|---|---|---|
| 42d48618ec | |||
| a0d995c1f1 |
30 changed files with 3079 additions and 417 deletions
|
|
@ -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"]');
|
||||
|
|
|
|||
162
e2e/11-modules-catalog.spec.ts
Normal file
162
e2e/11-modules-catalog.spec.ts
Normal 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
189
e2e/12-mobile-first.spec.ts
Normal file
|
|
@ -0,0 +1,189 @@
|
|||
import { test, expect } from '@playwright/test';
|
||||
|
||||
test.describe('Scenario 12: Mobile-First and Tablet Architecture Overhaul', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await page.waitForLoadState('domcontentloaded');
|
||||
});
|
||||
|
||||
test('1. Mobile viewport (< 768px) automatically displays bottom navigation', async ({ page, viewport }) => {
|
||||
const isMobileViewport = viewport && viewport.width < 768;
|
||||
|
||||
if (isMobileViewport) {
|
||||
// Bottom navigation must be visible
|
||||
const bottomNav = page.locator('[data-testid="bottom-nav"]');
|
||||
await expect(bottomNav).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// Sidebar must NOT be displayed
|
||||
const sidebar = page.locator('aside');
|
||||
await expect(sidebar).not.toBeVisible();
|
||||
|
||||
// Topbar must have mobile controls (logo + section title + menu button)
|
||||
await expect(page.locator('[data-testid="mobile-menu-btn"]')).toBeVisible();
|
||||
await expect(page.locator('[data-testid="mobile-current-title"]')).toBeVisible();
|
||||
} else {
|
||||
// Desktop / tablet view: sidebar visible, bottom nav not visible
|
||||
const sidebar = page.locator('[data-testid="sidebar"]');
|
||||
await expect(sidebar).toBeVisible({ timeout: 10000 });
|
||||
const bottomNav = page.locator('[data-testid="bottom-nav"]');
|
||||
await expect(bottomNav).not.toBeVisible();
|
||||
}
|
||||
});
|
||||
|
||||
test('2. Mobile bottom nav tabs are functional with >= 44x44px touch targets', async ({ page, viewport }) => {
|
||||
const isMobileViewport = viewport && viewport.width < 768;
|
||||
if (!isMobileViewport) return;
|
||||
|
||||
// Check touch target heights
|
||||
const catalogTab = page.locator('[data-testid="bottom-nav-catalog"]');
|
||||
await expect(catalogTab).toBeVisible();
|
||||
const box = await catalogTab.boundingBox();
|
||||
expect(box).not.toBeNull();
|
||||
if (box) {
|
||||
expect(box.height).toBeGreaterThanOrEqual(44);
|
||||
expect(box.width).toBeGreaterThanOrEqual(44);
|
||||
}
|
||||
|
||||
// Navigate to Tasks via bottom nav
|
||||
const tasksTab = page.locator('[data-testid="bottom-nav-tasks"]');
|
||||
await tasksTab.click();
|
||||
await page.waitForURL('**/tasks');
|
||||
await expect(page.locator('h1, h2').first()).toBeVisible();
|
||||
|
||||
// Navigate to Communications via bottom nav
|
||||
const commsTab = page.locator('[data-testid="bottom-nav-communications"]');
|
||||
await commsTab.click();
|
||||
await page.waitForURL('**/communications');
|
||||
await expect(page.locator('h1, h2').first()).toBeVisible();
|
||||
|
||||
// Return to Catalog
|
||||
await catalogTab.click();
|
||||
await page.waitForURL('**/');
|
||||
await expect(page.locator('[data-testid="modules-catalog-container"]')).toBeVisible();
|
||||
});
|
||||
|
||||
test('3. Mobile menu drawer opens and allows switching themes and profiles', async ({ page, viewport }) => {
|
||||
const isMobileViewport = viewport && viewport.width < 768;
|
||||
if (!isMobileViewport) return;
|
||||
|
||||
// Open via "Ещё" tab in bottom nav
|
||||
const moreTab = page.locator('[data-testid="bottom-nav-more"]');
|
||||
await moreTab.click();
|
||||
|
||||
// Drawer must be visible
|
||||
const drawer = page.locator('[data-testid="mobile-menu-drawer"]');
|
||||
await expect(drawer).toBeVisible();
|
||||
|
||||
// Check drawer content: profiles, roles, themes, reset
|
||||
await expect(drawer).toContainText('Управление платформой');
|
||||
await expect(drawer).toContainText('Отрасль и профиль организации');
|
||||
await expect(drawer).toContainText('Тема оформления');
|
||||
await expect(page.locator('[data-testid="mobile-reset-db-btn"]')).toBeVisible();
|
||||
|
||||
// Switch theme to dark
|
||||
const darkThemeBtn = drawer.getByRole('button', { name: /Тёмная/i });
|
||||
await darkThemeBtn.click();
|
||||
const themeAttr = await page.evaluate(() => document.documentElement.getAttribute('data-theme'));
|
||||
expect(themeAttr).toBe('dark');
|
||||
|
||||
// Close drawer
|
||||
const closeBtn = page.locator('[data-testid="close-mobile-menu"]');
|
||||
await closeBtn.click();
|
||||
await expect(drawer).not.toBeVisible();
|
||||
});
|
||||
|
||||
test('4. Zero horizontal overflow across all viewports', async ({ page }) => {
|
||||
// Check main document scroll width vs client width
|
||||
const isOverflowing = await page.evaluate(() => {
|
||||
return document.documentElement.scrollWidth > document.documentElement.clientWidth;
|
||||
});
|
||||
expect(isOverflowing).toBe(false);
|
||||
|
||||
// Verify on /crm
|
||||
await page.goto('/crm');
|
||||
await page.waitForLoadState('domcontentloaded');
|
||||
const isCrmOverflowing = await page.evaluate(() => {
|
||||
return document.documentElement.scrollWidth > document.documentElement.clientWidth;
|
||||
});
|
||||
expect(isCrmOverflowing).toBe(false);
|
||||
|
||||
// Verify on /settings/entities
|
||||
await page.goto('/settings/entities');
|
||||
await page.waitForLoadState('domcontentloaded');
|
||||
const isSettingsOverflowing = await page.evaluate(() => {
|
||||
return document.documentElement.scrollWidth > document.documentElement.clientWidth;
|
||||
});
|
||||
expect(isSettingsOverflowing).toBe(false);
|
||||
});
|
||||
|
||||
test('5. DynamicTable renders responsive card list on mobile screens', async ({ page, viewport }) => {
|
||||
await page.goto('/crm');
|
||||
await page.waitForLoadState('domcontentloaded');
|
||||
|
||||
const isMobileViewport = viewport && viewport.width < 768;
|
||||
if (isMobileViewport) {
|
||||
// Mobile card list must be visible
|
||||
const mobileCards = page.locator('[data-testid="mobile-table-cards"]');
|
||||
if (await mobileCards.isVisible()) {
|
||||
await expect(mobileCards).toBeVisible();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('6. FormBuilder supports touch-friendly up and down field reordering', async ({ page }) => {
|
||||
await page.goto('/settings/entities/contact/form-builder');
|
||||
await page.waitForLoadState('domcontentloaded');
|
||||
|
||||
// Locate field move buttons
|
||||
const moveDownBtn = page.locator('[data-testid^="move-field-down-"]').first();
|
||||
|
||||
if (await moveDownBtn.isVisible()) {
|
||||
await expect(moveDownBtn).toBeVisible();
|
||||
// Verify button has accessible touch target
|
||||
const box = await moveDownBtn.boundingBox();
|
||||
expect(box).not.toBeNull();
|
||||
if (box) {
|
||||
expect(box.height).toBeGreaterThanOrEqual(28);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('7. Full Russian localization check (zero untranslated system labels)', async ({ page }) => {
|
||||
// Check home page headings and buttons
|
||||
const bodyText = await page.locator('body').innerText();
|
||||
|
||||
// Forbidden English words in visible platform headers
|
||||
expect(bodyText).not.toContain('Business OS');
|
||||
expect(bodyText).not.toContain('Role: Owner');
|
||||
expect(bodyText).not.toContain('Quick Action');
|
||||
});
|
||||
|
||||
test('8. All 3 brand themes (soft-light, light, dark) apply and persist', async ({ page }) => {
|
||||
// Set light theme
|
||||
await page.evaluate(() => {
|
||||
window.localStorage.setItem('krona_theme_mode', 'light');
|
||||
document.documentElement.setAttribute('data-theme', 'light');
|
||||
});
|
||||
await page.reload();
|
||||
let currentTheme = await page.evaluate(() => document.documentElement.getAttribute('data-theme'));
|
||||
expect(currentTheme).toBe('light');
|
||||
|
||||
// Set dark theme
|
||||
await page.evaluate(() => {
|
||||
window.localStorage.setItem('krona_theme_mode', 'dark');
|
||||
document.documentElement.setAttribute('data-theme', 'dark');
|
||||
});
|
||||
await page.reload();
|
||||
currentTheme = await page.evaluate(() => document.documentElement.getAttribute('data-theme'));
|
||||
expect(currentTheme).toBe('dark');
|
||||
|
||||
// Restore soft-light
|
||||
await page.evaluate(() => {
|
||||
window.localStorage.setItem('krona_theme_mode', 'soft-light');
|
||||
document.documentElement.setAttribute('data-theme', 'soft-light');
|
||||
});
|
||||
await page.reload();
|
||||
currentTheme = await page.evaluate(() => document.documentElement.getAttribute('data-theme'));
|
||||
expect(currentTheme).toBe('soft-light');
|
||||
});
|
||||
});
|
||||
|
|
@ -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,
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 /> },
|
||||
|
|
|
|||
|
|
@ -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)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
279
src/app/shell/MobileMenuDrawer.tsx
Normal file
279
src/app/shell/MobileMenuDrawer.tsx
Normal file
|
|
@ -0,0 +1,279 @@
|
|||
import React from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useDemoStore } from '../../mock/store';
|
||||
import { BUSINESS_PROFILES } from '../../core/profiles/profiles';
|
||||
import { SYSTEM_ROLES, SystemRoleId } from '../../core/permissions/types';
|
||||
import { SYSTEM_MODULES } from '../../core/modules/registry';
|
||||
import {
|
||||
X,
|
||||
Building,
|
||||
MapPin,
|
||||
Shield,
|
||||
Palette,
|
||||
RotateCcw,
|
||||
ExternalLink,
|
||||
ChevronRight,
|
||||
Sun,
|
||||
Moon,
|
||||
Sparkles,
|
||||
Grid,
|
||||
} from 'lucide-react';
|
||||
import { Button } from '../../shared/ui/Button';
|
||||
import { resetDatabase } from '../../services/storage/indexedDb';
|
||||
import { useToast } from '../../shared/ui/useToast';
|
||||
|
||||
export interface MobileMenuDrawerProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export const MobileMenuDrawer: React.FC<MobileMenuDrawerProps> = ({ isOpen, onClose }) => {
|
||||
const navigate = useNavigate();
|
||||
const { showToast } = useToast();
|
||||
const {
|
||||
currentProfileId,
|
||||
setProfileId,
|
||||
activeLocation,
|
||||
setActiveLocation,
|
||||
currentRoleId,
|
||||
setRoleId,
|
||||
theme,
|
||||
setTheme,
|
||||
getActiveProfile,
|
||||
resetCatalogFilters,
|
||||
} = useDemoStore();
|
||||
|
||||
const profile = getActiveProfile();
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const handleResetDb = async () => {
|
||||
try {
|
||||
await resetDatabase();
|
||||
resetCatalogFilters();
|
||||
showToast('База данных успешно сброшена к начальному состоянию', 'info');
|
||||
onClose();
|
||||
} catch {
|
||||
showToast('Ошибка при сбросе базы данных', 'error');
|
||||
}
|
||||
};
|
||||
|
||||
const handleNavigate = (route: string) => {
|
||||
onClose();
|
||||
navigate(route);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex flex-col justify-end"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="Мобильное меню управления"
|
||||
data-testid="mobile-menu-drawer"
|
||||
>
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
className="fixed inset-0 bg-black/60 backdrop-blur-xs animate-fade-in"
|
||||
onClick={onClose}
|
||||
/>
|
||||
|
||||
{/* Sheet Content */}
|
||||
<div className="relative bg-krona-surface border-t border-krona-border rounded-t-3xl p-5 z-10 max-h-[90vh] overflow-y-auto shadow-krona-modal animate-slide-up flex flex-col gap-5 text-krona-primary pb-[max(1.5rem,env(safe-area-inset-bottom))]">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between border-b border-krona-border/70 pb-3">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div className="w-8 h-8 rounded-xl bg-krona-gold/15 text-krona-gold border border-krona-gold/30 flex items-center justify-center font-bold text-sm">
|
||||
К
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-base font-bold text-krona-primary leading-tight">
|
||||
Управление платформой
|
||||
</h2>
|
||||
<p className="text-xs text-krona-secondary">
|
||||
{profile.name} • {activeLocation}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="w-10 h-10 rounded-xl bg-krona-workspace border border-krona-border text-krona-secondary hover:text-krona-primary flex items-center justify-center active:scale-95 transition-all"
|
||||
aria-label="Закрыть меню"
|
||||
data-testid="close-mobile-menu"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Section 1: Business Profiles */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-1.5 text-xs font-bold uppercase tracking-wider text-krona-secondary">
|
||||
<Building className="w-3.5 h-3.5 text-krona-gold" />
|
||||
<span>Отрасль и профиль организации</span>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-1.5">
|
||||
{Object.values(BUSINESS_PROFILES).slice(0, 6).map((p) => (
|
||||
<button
|
||||
key={p.id}
|
||||
onClick={() => setProfileId(p.id)}
|
||||
className={`w-full min-h-[44px] px-3.5 py-2 rounded-xl text-left text-xs font-medium flex items-center justify-between transition-colors border ${
|
||||
currentProfileId === p.id
|
||||
? 'bg-krona-gold/15 text-krona-primary border-krona-gold/60 font-semibold'
|
||||
: 'bg-krona-workspace text-krona-secondary hover:text-krona-primary border-krona-border'
|
||||
}`}
|
||||
>
|
||||
<span>{p.name}</span>
|
||||
{currentProfileId === p.id && (
|
||||
<span className="text-[10px] px-2 py-0.5 rounded-full bg-krona-gold text-white font-bold">
|
||||
Активен
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Section 2: Location Switcher */}
|
||||
{profile.locations.length > 1 && (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-1.5 text-xs font-bold uppercase tracking-wider text-krona-secondary">
|
||||
<MapPin className="w-3.5 h-3.5 text-emerald-500" />
|
||||
<span>Торговая точка / Локация</span>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{profile.locations.map((loc) => (
|
||||
<button
|
||||
key={loc}
|
||||
onClick={() => setActiveLocation(loc)}
|
||||
className={`min-h-[44px] px-3 py-2 rounded-xl text-left text-xs font-medium border truncate transition-colors ${
|
||||
activeLocation === loc
|
||||
? 'bg-emerald-500/15 text-krona-primary border-emerald-500/50 font-semibold'
|
||||
: 'bg-krona-workspace text-krona-secondary hover:text-krona-primary border-krona-border'
|
||||
}`}
|
||||
>
|
||||
{loc}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Section 3: Role Switcher (RBAC) */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-1.5 text-xs font-bold uppercase tracking-wider text-krona-secondary">
|
||||
<Shield className="w-3.5 h-3.5 text-sky-500" />
|
||||
<span>Роль пользователя (RBAC)</span>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{(['owner', 'administrator', 'manager', 'cashier'] as SystemRoleId[]).map((rId) => {
|
||||
const r = SYSTEM_ROLES[rId];
|
||||
return (
|
||||
<button
|
||||
key={rId}
|
||||
onClick={() => setRoleId(rId)}
|
||||
className={`min-h-[44px] p-2.5 rounded-xl text-left border flex flex-col justify-center transition-colors ${
|
||||
currentRoleId === rId
|
||||
? 'bg-sky-500/15 text-krona-primary border-sky-500/50 font-semibold'
|
||||
: 'bg-krona-workspace text-krona-secondary hover:text-krona-primary border-krona-border'
|
||||
}`}
|
||||
>
|
||||
<span className="text-xs font-bold truncate">{r.label}</span>
|
||||
<span className="text-[10px] text-krona-secondary truncate">{r.badge}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Section 4: Brandbook Theme Switcher */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-1.5 text-xs font-bold uppercase tracking-wider text-krona-secondary">
|
||||
<Palette className="w-3.5 h-3.5 text-purple-500" />
|
||||
<span>Тема оформления</span>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{[
|
||||
{ id: 'soft-light', label: 'Мягкая', icon: Sparkles },
|
||||
{ id: 'light', label: 'Светлая', icon: Sun },
|
||||
{ id: 'dark', label: 'Тёмная', icon: Moon },
|
||||
].map((th) => {
|
||||
const Icon = th.icon;
|
||||
return (
|
||||
<button
|
||||
key={th.id}
|
||||
onClick={() => setTheme(th.id as any)}
|
||||
className={`min-h-[44px] px-2 py-2.5 rounded-xl border flex flex-col items-center justify-center gap-1 text-xs transition-colors ${
|
||||
theme === th.id
|
||||
? 'bg-krona-gold/20 text-krona-primary border-krona-gold/60 font-bold'
|
||||
: 'bg-krona-workspace text-krona-secondary hover:text-krona-primary border-krona-border'
|
||||
}`}
|
||||
>
|
||||
<Icon className="w-4 h-4" />
|
||||
<span>{th.label}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Section 5: All Modules Direct Links */}
|
||||
<div className="space-y-2 pt-2 border-t border-krona-border/70">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-1.5 text-xs font-bold uppercase tracking-wider text-krona-secondary">
|
||||
<Grid className="w-3.5 h-3.5 text-krona-gold" />
|
||||
<span>Все разделы платформы ({SYSTEM_MODULES.length})</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleNavigate('/')}
|
||||
className="text-xs text-krona-gold hover:underline flex items-center gap-0.5"
|
||||
>
|
||||
<span>Каталог</span>
|
||||
<ChevronRight className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-1.5 max-h-48 overflow-y-auto pr-1">
|
||||
{SYSTEM_MODULES.map((m) => (
|
||||
<button
|
||||
key={m.id}
|
||||
onClick={() => handleNavigate(m.route)}
|
||||
className="min-h-[44px] px-3 py-2 rounded-xl text-left text-xs bg-krona-workspace border border-krona-border hover:border-krona-gold/40 text-krona-primary flex items-center justify-between"
|
||||
>
|
||||
<span className="truncate">{m.label}</span>
|
||||
<ChevronRight className="w-3.5 h-3.5 text-krona-secondary shrink-0" />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer: Database Reset & Settings */}
|
||||
<div className="pt-3 border-t border-krona-border/70 flex items-center gap-2">
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={handleResetDb}
|
||||
className="flex-1 min-h-[44px] text-xs justify-center"
|
||||
data-testid="mobile-reset-db-btn"
|
||||
>
|
||||
<RotateCcw className="w-4 h-4 mr-1.5" />
|
||||
<span>Сбросить демо-базу</span>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
onClick={() => handleNavigate('/settings/entities')}
|
||||
className="flex-1 min-h-[44px] text-xs justify-center"
|
||||
>
|
||||
<span>Настройки</span>
|
||||
<ExternalLink className="w-4 h-4 ml-1.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -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 ? 'Развернуть меню' : 'Свернуть меню'}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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: 'Финансы',
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
151
src/features/catalog/ModuleCard.tsx
Normal file
151
src/features/catalog/ModuleCard.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
175
src/features/catalog/ModuleInfoModal.tsx
Normal file
175
src/features/catalog/ModuleInfoModal.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
485
src/features/catalog/ModulesCatalog.tsx
Normal file
485
src/features/catalog/ModulesCatalog.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
747
src/features/catalog/catalogData.ts
Normal file
747
src/features/catalog/catalogData.ts
Normal 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'],
|
||||
},
|
||||
},
|
||||
];
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 */}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
240
src/pages/ProcurementPage.tsx
Normal file
240
src/pages/ProcurementPage.tsx
Normal 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;
|
||||
|
|
@ -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';
|
||||
|
|
|
|||
63
src/shared/lib/useResponsive.ts
Normal file
63
src/shared/lib/useResponsive.ts
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
import { useState, useEffect } from 'react';
|
||||
|
||||
export interface ResponsiveState {
|
||||
width: number;
|
||||
height: number;
|
||||
isMobile: boolean;
|
||||
isTablet: boolean;
|
||||
isDesktop: boolean;
|
||||
isPortrait: boolean;
|
||||
isLandscape: boolean;
|
||||
}
|
||||
|
||||
export function useResponsive(): ResponsiveState {
|
||||
const [state, setState] = useState<ResponsiveState>(() => {
|
||||
const width = typeof window !== 'undefined' ? window.innerWidth : 1440;
|
||||
const height = typeof window !== 'undefined' ? window.innerHeight : 900;
|
||||
return {
|
||||
width,
|
||||
height,
|
||||
isMobile: width < 768,
|
||||
isTablet: width >= 768 && width < 1200,
|
||||
isDesktop: width >= 1200,
|
||||
isPortrait: height >= width,
|
||||
isLandscape: width > height,
|
||||
};
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined') return;
|
||||
|
||||
let timeoutId: any;
|
||||
const handleResize = () => {
|
||||
clearTimeout(timeoutId);
|
||||
timeoutId = setTimeout(() => {
|
||||
const width = window.innerWidth;
|
||||
const height = window.innerHeight;
|
||||
setState({
|
||||
width,
|
||||
height,
|
||||
isMobile: width < 768,
|
||||
isTablet: width >= 768 && width < 1200,
|
||||
isDesktop: width >= 1200,
|
||||
isPortrait: height >= width,
|
||||
isLandscape: width > height,
|
||||
});
|
||||
}, 50);
|
||||
};
|
||||
|
||||
window.addEventListener('resize', handleResize);
|
||||
window.addEventListener('orientationchange', handleResize);
|
||||
|
||||
// Initial check
|
||||
handleResize();
|
||||
|
||||
return () => {
|
||||
clearTimeout(timeoutId);
|
||||
window.removeEventListener('resize', handleResize);
|
||||
window.removeEventListener('orientationchange', handleResize);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return state;
|
||||
}
|
||||
|
|
@ -114,10 +114,10 @@ export const Drawer: React.FC<DrawerProps> = ({
|
|||
/>
|
||||
|
||||
{/* Drawer panel */}
|
||||
<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}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
Loading…
Reference in a new issue