feat: initial commit of Krona Universal Business OS interactive prototype

This commit is contained in:
ochenstarik 2026-09-10 20:01:31 +07:00
commit 5ae2d41f39
75 changed files with 14076 additions and 0 deletions

24
.gitignore vendored Normal file
View file

@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

8
.oxlintrc.json Normal file
View file

@ -0,0 +1,8 @@
{
"$schema": "./node_modules/oxlint/configuration_schema.json",
"plugins": ["react", "typescript", "oxc"],
"rules": {
"react/rules-of-hooks": "error",
"react/only-export-components": ["warn", { "allowConstantExport": true }]
}
}

32
README.md Normal file
View file

@ -0,0 +1,32 @@
# React + TypeScript + Vite
This template provides a minimal setup to get React working in Vite with HMR and some Oxlint rules.
Currently, two official plugins are available:
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs)
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/)
## React Compiler
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
## Expanding the Oxlint configuration
If you are developing a production application, we recommend enabling type-aware lint rules by installing `oxlint-tsgolint` and editing `.oxlintrc.json`:
```json
{
"$schema": "./node_modules/oxlint/configuration_schema.json",
"plugins": ["react", "typescript", "oxc"],
"options": {
"typeAware": true
},
"rules": {
"react/rules-of-hooks": "error",
"react/only-export-components": ["warn", { "allowConstantExport": true }]
}
}
```
See the [Oxlint rules documentation](https://oxc.rs/docs/guide/usage/linter/rules) for the full list of rules and categories.

16
index.html Normal file
View file

@ -0,0 +1,16 @@
<!doctype html>
<html lang="ru">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/png" href="/branding/krona-symbol.png" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Крона — Universal Business OS</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
</head>
<body class="bg-krona-app text-krona-primary antialiased selection:bg-krona-gold/20 selection:text-krona-gold-text">
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

3001
package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

35
package.json Normal file
View file

@ -0,0 +1,35 @@
{
"name": "krona-front",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "oxlint",
"preview": "vite preview"
},
"dependencies": {
"@tanstack/react-table": "^9.2.4",
"clsx": "^2.1.1",
"lucide-react": "^1.43.0",
"react": "^19.2.8",
"react-dom": "^19.2.8",
"react-router-dom": "^7.18.3",
"recharts": "^3.10.1",
"tailwind-merge": "^3.6.0",
"zustand": "^5.0.15"
},
"devDependencies": {
"@types/node": "^24.13.4",
"@types/react": "^19.2.18",
"@types/react-dom": "^19.2.4",
"@vitejs/plugin-react": "^6.1.0",
"autoprefixer": "^10.5.5",
"oxlint": "^1.79.0",
"postcss": "^8.5.28",
"tailwindcss": "^3.4.19",
"typescript": "~6.0.2",
"vite": "^8.2.2"
}
}

6
postcss.config.js Normal file
View file

@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

1
public/favicon.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 9.3 KiB

24
public/icons.svg Normal file
View file

@ -0,0 +1,24 @@
<svg xmlns="http://www.w3.org/2000/svg">
<symbol id="bluesky-icon" viewBox="0 0 16 17">
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
</symbol>
<symbol id="discord-icon" viewBox="0 0 20 19">
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
</symbol>
<symbol id="documentation-icon" viewBox="0 0 21 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
</symbol>
<symbol id="github-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
</symbol>
<symbol id="social-icon" viewBox="0 0 20 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
</symbol>
<symbol id="x-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
</symbol>
</svg>

After

Width:  |  Height:  |  Size: 4.9 KiB

9
src/App.tsx Normal file
View file

@ -0,0 +1,9 @@
import React from 'react';
import { RouterProvider } from 'react-router-dom';
import { router } from './app/router';
export function App() {
return <RouterProvider router={router} />;
}
export default App;

View file

@ -0,0 +1,133 @@
import React, { useEffect } from 'react';
import { Outlet } from 'react-router-dom';
import { useDemoStore } from '../../mock/store';
import { Sidebar } from '../shell/Sidebar';
import { Topbar } from '../shell/Topbar';
import { RightCommandPanel } from '../shell/RightCommandPanel';
import { BottomNav } from '../shell/BottomNav';
import { CommandPalette } from '../shell/CommandPalette';
import { QuickActionsModal } from '../shell/QuickActionsModal';
import { NotificationsDrawer } from '../shell/NotificationsDrawer';
import { Drawer } from '../../shared/components/Drawer';
import { Sparkles, Bot, Send } from 'lucide-react';
import { Button } from '../../shared/components/Button';
export const AppShell: React.FC = () => {
const {
theme,
viewportMode,
isAiDrawerOpen,
setIsAiDrawerOpen,
getActiveProfile,
activeLocation,
} = useDemoStore();
const profile = getActiveProfile();
useEffect(() => {
document.documentElement.setAttribute('data-theme', theme);
}, [theme]);
// Viewport mode constraints
const getViewportContainerStyles = () => {
switch (viewportMode) {
case 'mobile':
return 'max-w-[420px] h-[880px] my-6 border-8 border-[#3A3D35] 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';
case 'tablet-landscape':
return 'max-w-[1180px] h-[800px] my-6 border-8 border-[#3A3D35] rounded-[24px] overflow-hidden shadow-2xl relative';
case 'desktop':
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 className="flex-1 flex overflow-hidden w-full h-full relative">
{/* Left Sidebar (hidden in mobile mode) */}
{!showMobileNav && <Sidebar />}
{/* 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`}>
<Outlet />
</main>
</div>
{/* Right Command Panel (Desktop and Tablet-Landscape) */}
{viewportMode === 'desktop' && <RightCommandPanel />}
</div>
{/* Mobile Bottom Navigation */}
{showMobileNav && <BottomNav />}
{/* Global Modals & Drawers */}
<CommandPalette />
<QuickActionsModal />
<NotificationsDrawer />
{/* Standalone AI Drawer (triggered via Topbar ✦ button on any screen) */}
<Drawer
isOpen={isAiDrawerOpen}
onClose={() => setIsAiDrawerOpen(false)}
title="✦ Krona AI — Бизнес-оркестратор"
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>
<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="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="font-semibold text-krona-primary">Складской агент</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">Сегменты, LTV, рассылки</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>
</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>
</div>
);
};

47
src/app/router/index.tsx Normal file
View file

@ -0,0 +1,47 @@
import React from 'react';
import { createBrowserRouter, Navigate } from 'react-router-dom';
import { AppShell } from '../layouts/AppShell';
import { DynamicDashboard } from '../../features/dashboard/DynamicDashboard';
import { OperationsModule } from '../../features/operations/OperationsModule';
import { CrmModule } from '../../features/crm/CrmModule';
import { SalesModule } from '../../features/sales/SalesModule';
import { InventoryModule } from '../../features/inventory/InventoryModule';
import { FinanceModule } from '../../features/finance/FinanceModule';
import { EmployeesModule } from '../../features/employees/EmployeesModule';
import { CommunicationsModule } from '../../features/communications/CommunicationsModule';
import { HelpDeskModule } from '../../features/helpdesk/HelpDeskModule';
import { TasksModule } from '../../features/tasks/TasksModule';
import { CmsModule } from '../../features/cms/CmsModule';
import { MarketingModule } from '../../features/marketing/MarketingModule';
import { AnalyticsModule } from '../../features/analytics/AnalyticsModule';
import { AutomationModule } from '../../features/automation/AutomationModule';
import { AiCenterModule } from '../../features/ai/AiCenterModule';
import { AppsCatalogModule } from '../../features/apps/AppsCatalogModule';
import { SettingsModule } from '../../features/settings/SettingsModule';
export const router = createBrowserRouter([
{
path: '/',
element: <AppShell />,
children: [
{ index: true, element: <DynamicDashboard /> },
{ path: 'operations/*', element: <OperationsModule /> },
{ path: 'crm/*', element: <CrmModule /> },
{ path: 'sales/*', element: <SalesModule /> },
{ path: 'inventory/*', element: <InventoryModule /> },
{ path: 'finance/*', element: <FinanceModule /> },
{ path: 'employees/*', element: <EmployeesModule /> },
{ path: 'communications/*', element: <CommunicationsModule /> },
{ path: 'helpdesk/*', element: <HelpDeskModule /> },
{ path: 'tasks/*', element: <TasksModule /> },
{ path: 'cms/*', element: <CmsModule /> },
{ path: 'marketing/*', element: <MarketingModule /> },
{ path: 'analytics/*', element: <AnalyticsModule /> },
{ path: 'automation/*', element: <AutomationModule /> },
{ path: 'ai/*', element: <AiCenterModule /> },
{ path: 'apps/*', element: <AppsCatalogModule /> },
{ path: 'settings/*', element: <SettingsModule /> },
{ path: '*', element: <Navigate to="/" replace /> },
],
},
]);

119
src/app/shell/BottomNav.tsx Normal file
View file

@ -0,0 +1,119 @@
import React, { useState } from 'react';
import { NavLink, useNavigate } from 'react-router-dom';
import { useDemoStore } from '../../mock/store';
import {
LayoutDashboard,
Workflow,
Plus,
MessageSquare,
Menu,
X,
Boxes,
BadgeDollarSign,
Users,
Settings,
Sparkles,
} from 'lucide-react';
import { SYSTEM_MODULES } from '../../core/modules/registry';
export const BottomNav: React.FC = () => {
const { setIsQuickActionsOpen, setIsAiDrawerOpen } = useDemoStore();
const [isMoreMenuOpen, setIsMoreMenuOpen] = useState(false);
const navigate = useNavigate();
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 */}
<button
onClick={() => setIsQuickActionsOpen(true)}
className="w-11 h-11 rounded-full bg-[#2F684C] hover:bg-[#285A42] text-white flex items-center justify-center shadow-lg -mt-5 transition-transform active:scale-95"
title="Быстрое действие"
>
<Plus className="w-6 h-6" />
</button>
<NavLink
to="/communications"
className={({ isActive }) =>
`flex flex-col items-center gap-1 p-1 text-[11px] ${
isActive ? 'text-[#D4B16D] font-semibold' : 'text-[#858980]'
}`
}
>
<MessageSquare className="w-5 h-5" />
<span>Чаты</span>
</NavLink>
<button
onClick={() => setIsMoreMenuOpen(true)}
className="flex flex-col items-center gap-1 p-1 text-[11px] text-[#858980] hover:text-[#D8D8D0]"
>
<Menu className="w-5 h-5" />
<span>Ещё</span>
</button>
</nav>
</>
);
};

View file

@ -0,0 +1,202 @@
import React, { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { useDemoStore } from '../../mock/store';
import { SYSTEM_MODULES } from '../../core/modules/registry';
import { BUSINESS_PROFILES } from '../../core/profiles/profiles';
import { SYSTEM_ROLES, SystemRoleId } from '../../core/permissions/types';
import { Search, ArrowRight, Sun, Moon, Sparkles, Plus, X } from 'lucide-react';
export const CommandPalette: React.FC = () => {
const {
isCommandPaletteOpen,
setIsCommandPaletteOpen,
setProfileId,
setRoleId,
setTheme,
setIsQuickActionsOpen,
theme,
} = useDemoStore();
const [query, setQuery] = useState('');
const navigate = useNavigate();
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'k') {
e.preventDefault();
setIsCommandPaletteOpen(!isCommandPaletteOpen);
}
if (e.key === 'Escape' && isCommandPaletteOpen) {
setIsCommandPaletteOpen(false);
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [isCommandPaletteOpen, setIsCommandPaletteOpen]);
if (!isCommandPaletteOpen) return null;
const filteredModules = SYSTEM_MODULES.filter(
(m) => m.label.toLowerCase().includes(query.toLowerCase()) || m.route.toLowerCase().includes(query.toLowerCase())
);
const filteredProfiles = Object.values(BUSINESS_PROFILES).filter((p) =>
p.name.toLowerCase().includes(query.toLowerCase()) || p.label.toLowerCase().includes(query.toLowerCase())
);
const filteredRoles = Object.values(SYSTEM_ROLES).filter((r) =>
r.label.toLowerCase().includes(query.toLowerCase()) || r.badge.toLowerCase().includes(query.toLowerCase())
);
return (
<div className="fixed inset-0 z-50 flex items-start justify-center pt-20 p-4">
<div
className="fixed inset-0 krona-overlay"
onClick={() => setIsCommandPaletteOpen(false)}
/>
<div className="relative w-full max-w-2xl bg-krona-raised border border-krona-border rounded-xl shadow-krona-modal z-10 overflow-hidden flex flex-col max-h-[75vh] animate-fade-in">
{/* Search header */}
<div className="p-3.5 border-b border-krona-border flex items-center gap-3 bg-krona-surface/70">
<Search className="w-5 h-5 text-krona-secondary shrink-0" />
<input
autoFocus
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Поиск по Кроне, модулям, профилям, ролям или действиям (Ctrl+K)..."
className="w-full bg-transparent text-krona-primary placeholder:text-krona-secondary text-sm focus:outline-none"
/>
<button
onClick={() => setIsCommandPaletteOpen(false)}
className="text-krona-secondary hover:text-krona-primary p-1"
>
<X className="w-4 h-4" />
</button>
</div>
{/* Results */}
<div className="overflow-y-auto p-3 space-y-4 text-xs">
{/* Quick Actions */}
<div>
<div className="text-[11px] font-semibold uppercase tracking-wider text-krona-secondary px-2 mb-1.5">
Быстрые действия
</div>
<div className="space-y-0.5">
<button
onClick={() => {
setIsCommandPaletteOpen(false);
setIsQuickActionsOpen(true);
}}
className="w-full text-left px-3 py-2 rounded-md hover:bg-krona-workspace flex items-center justify-between group transition-colors"
>
<div className="flex items-center gap-2.5">
<Plus className="w-4 h-4 text-krona-green" />
<span className="font-medium text-krona-primary">Создать новую сущность (быстрое меню)</span>
</div>
<ArrowRight className="w-3.5 h-3.5 text-krona-secondary group-hover:text-krona-primary" />
</button>
<button
onClick={() => {
setTheme(theme === 'dark' ? 'soft-light' : 'dark');
setIsCommandPaletteOpen(false);
}}
className="w-full text-left px-3 py-2 rounded-md hover:bg-krona-workspace flex items-center justify-between group transition-colors"
>
<div className="flex items-center gap-2.5">
{theme === 'dark' ? (
<Sun className="w-4 h-4 text-krona-gold" />
) : (
<Moon className="w-4 h-4 text-krona-secondary" />
)}
<span className="font-medium text-krona-primary">
Переключить тему ({theme === 'dark' ? 'на Светлую' : 'на Темную'})
</span>
</div>
<ArrowRight className="w-3.5 h-3.5 text-krona-secondary group-hover:text-krona-primary" />
</button>
</div>
</div>
{/* Modules */}
{filteredModules.length > 0 && (
<div>
<div className="text-[11px] font-semibold uppercase tracking-wider text-krona-secondary px-2 mb-1.5">
Модули платформы
</div>
<div className="space-y-0.5">
{filteredModules.slice(0, 8).map((mod) => (
<button
key={mod.id}
onClick={() => {
navigate(mod.route);
setIsCommandPaletteOpen(false);
}}
className="w-full text-left px-3 py-2 rounded-md hover:bg-krona-workspace flex items-center justify-between group transition-colors"
>
<span className="font-medium text-krona-primary">{mod.label}</span>
<span className="text-krona-secondary text-[11px] font-mono">{mod.route}</span>
</button>
))}
</div>
</div>
)}
{/* Business Profiles Switcher */}
<div>
<div className="text-[11px] font-semibold uppercase tracking-wider text-krona-secondary px-2 mb-1.5 flex items-center gap-1.5">
<Sparkles className="w-3.5 h-3.5 text-krona-gold" />
Сменить бизнес-профиль
</div>
<div className="grid grid-cols-2 gap-1.5">
{filteredProfiles.map((p) => (
<button
key={p.id}
onClick={() => {
setProfileId(p.id);
setIsCommandPaletteOpen(false);
navigate('/operations');
}}
className="text-left p-2 rounded-md border border-krona-border hover:border-krona-green/50 bg-krona-surface hover:bg-krona-workspace transition-all"
>
<div className="font-medium text-krona-primary text-xs truncate">{p.name}</div>
<div className="text-[10px] text-krona-secondary truncate">{p.category}</div>
</button>
))}
</div>
</div>
{/* Roles Switcher */}
<div>
<div className="text-[11px] font-semibold uppercase tracking-wider text-krona-secondary px-2 mb-1.5">
Сменить роль (RBAC)
</div>
<div className="grid grid-cols-2 gap-1.5">
{filteredRoles.slice(0, 6).map((role) => (
<button
key={role.id}
onClick={() => {
setRoleId(role.id as SystemRoleId);
setIsCommandPaletteOpen(false);
navigate(role.defaultRoute);
}}
className="text-left p-2 rounded-md border border-krona-border hover:border-krona-gold/50 bg-krona-surface hover:bg-krona-workspace transition-all"
>
<div className="font-medium text-krona-primary text-xs">{role.label}</div>
<div className="text-[10px] text-krona-secondary truncate">{role.badge}</div>
</button>
))}
</div>
</div>
</div>
{/* Footer */}
<div className="px-4 py-2 border-t border-krona-border bg-krona-surface/50 text-[11px] text-krona-secondary flex items-center justify-between">
<span>Навигация: ↑ ↓ &nbsp;|&nbsp; Выбор: Enter &nbsp;|&nbsp; Закрыть: Esc</span>
<span className="font-medium text-krona-gold">Крона AGI Universal OS</span>
</div>
</div>
</div>
);
};

View file

@ -0,0 +1,108 @@
import React from 'react';
import { useDemoStore } from '../../mock/store';
import { Drawer } from '../../shared/components/Drawer';
import { Button } from '../../shared/components/Button';
import { Badge } from '../../shared/components/Badge';
import { Bell, AlertTriangle, AlertCircle, CheckCircle2, Info, Check, Trash2 } from 'lucide-react';
import { useNavigate } from 'react-router-dom';
export const NotificationsDrawer: React.FC = () => {
const {
isNotificationsOpen,
setIsNotificationsOpen,
notifications,
dismissNotification,
markAllNotificationsRead,
} = useDemoStore();
const navigate = useNavigate();
const getIcon = (type: string) => {
switch (type) {
case 'danger':
return <AlertCircle className="w-4 h-4 text-krona-danger" />;
case 'warning':
return <AlertTriangle className="w-4 h-4 text-krona-warning" />;
case 'success':
return <CheckCircle2 className="w-4 h-4 text-krona-green" />;
default:
return <Info className="w-4 h-4 text-krona-info" />;
}
};
return (
<Drawer
isOpen={isNotificationsOpen}
onClose={() => setIsNotificationsOpen(false)}
title="Центр внимания и уведомлений"
subtitle="Срочные алерты, статус кассы, стоп-листы и опоздания"
width="md"
footer={
<div className="w-full flex items-center justify-between">
<Button variant="ghost" size="sm" onClick={markAllNotificationsRead}>
<Check className="w-3.5 h-3.5 mr-1.5" />
Прочитать все
</Button>
<Button variant="secondary" size="sm" onClick={() => setIsNotificationsOpen(false)}>
Закрыть
</Button>
</div>
}
>
<div className="space-y-3">
{notifications.length === 0 ? (
<div className="py-12 text-center text-krona-secondary text-xs">
<Bell className="w-8 h-8 mx-auto mb-2 opacity-30" />
Нет активных уведомлений. Все системы в норме!
</div>
) : (
notifications.map((n) => (
<div
key={n.id}
className={`p-3.5 rounded-lg border transition-all ${
n.read
? 'bg-krona-surface/40 border-krona-border/60 opacity-80'
: 'bg-krona-surface border-krona-border shadow-krona-soft'
}`}
>
<div className="flex items-start gap-2.5">
<div className="mt-0.5 shrink-0">{getIcon(n.type)}</div>
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between gap-2 mb-1">
<h5 className="text-xs font-semibold text-krona-primary truncate">{n.title}</h5>
<span className="text-[10px] text-krona-secondary whitespace-nowrap">{n.timestamp}</span>
</div>
<p className="text-xs text-krona-secondary leading-relaxed">{n.message}</p>
<div className="mt-2.5 flex items-center justify-between pt-1 border-t border-krona-border/40">
{n.actionUrl ? (
<button
onClick={() => {
setIsNotificationsOpen(false);
navigate(n.actionUrl!);
}}
className="text-xs text-krona-green font-medium hover:underline"
>
Перейти к решению →
</button>
) : (
<span />
)}
<button
onClick={() => dismissNotification(n.id)}
className="text-krona-secondary hover:text-krona-danger p-1 rounded"
title="Удалить"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
</div>
</div>
</div>
</div>
))
)}
</div>
</Drawer>
);
};

View file

@ -0,0 +1,220 @@
import React, { useState } from 'react';
import { useDemoStore } from '../../mock/store';
import { Modal } from '../../shared/components/Modal';
import { Button } from '../../shared/components/Button';
import { Input } from '../../shared/components/Input';
import {
ShoppingBag,
Calendar,
UserPlus,
Car,
Wrench,
PackagePlus,
Receipt,
FileText,
CheckCircle2,
} from 'lucide-react';
export const QuickActionsModal: React.FC = () => {
const {
isQuickActionsOpen,
setIsQuickActionsOpen,
getActiveProfile,
addBooking,
addWorkOrder,
activeLocation,
} = useDemoStore();
const profile = getActiveProfile();
const [activeTab, setActiveTab] = useState<string>('order');
const [successMessage, setSuccessMessage] = useState<string | null>(null);
// Form states
const [clientName, setClientName] = useState('');
const [clientPhone, setClientPhone] = useState('');
const [bookingTime, setBookingTime] = useState('19:00');
const [resource, setResource] = useState('Стол 3');
const [vehicle, setVehicle] = useState('Toyota Camry (А123АА 154)');
const [amount, setAmount] = useState('1500');
const handleCreate = (e: React.FormEvent) => {
e.preventDefault();
if (activeTab === 'booking') {
addBooking({
clientName: clientName || 'Новый клиент',
clientPhone: clientPhone || '+7 (900) 000-00-00',
resourceName: resource,
time: bookingTime,
status: 'confirmed',
deposit: Number(amount) || 0,
});
setSuccessMessage(`Бронирование ресурса "${resource}" успешно создано!`);
} else if (activeTab === 'work_order') {
addWorkOrder({
vehicle: vehicle || 'Автомобиль клиента',
ownerName: clientName || 'Владелец',
phone: clientPhone || '+7 (900) 000-00-00',
bay: 'Пост 1',
masterName: 'Иван Кузнецов',
services: ['Комплексная диагностика', 'Мойка / ТО'],
partsAmount: 1200,
laborAmount: Number(amount) || 2500,
status: 'in_progress',
});
setSuccessMessage(`Заказ-наряд на ${vehicle} открыт!`);
} else {
setSuccessMessage(`Действие успешно выполнено для точки "${activeLocation}"!`);
}
setTimeout(() => {
setSuccessMessage(null);
setIsQuickActionsOpen(false);
}, 1200);
};
return (
<Modal
isOpen={isQuickActionsOpen}
onClose={() => setIsQuickActionsOpen(false)}
title="Быстрое создание"
subtitle={`Профиль: ${profile.name} • ${activeLocation}`}
maxWidth="lg"
>
{successMessage ? (
<div className="py-10 flex flex-col items-center justify-center text-center">
<CheckCircle2 className="w-12 h-12 text-krona-green mb-3" />
<h4 className="text-base font-semibold text-krona-primary">{successMessage}</h4>
<p className="text-xs text-krona-secondary mt-1">Данные синхронизированы в локальном mock API</p>
</div>
) : (
<div className="space-y-4">
{/* Action Types selector */}
<div className="grid grid-cols-3 gap-2 pb-2">
<button
type="button"
onClick={() => setActiveTab('order')}
className={`p-2.5 rounded-lg border text-left flex items-center gap-2.5 transition-all ${
activeTab === 'order'
? 'border-krona-green bg-krona-green/10 text-krona-green-text font-medium'
: 'border-krona-border bg-krona-surface text-krona-secondary hover:bg-krona-workspace'
}`}
>
<ShoppingBag className="w-4 h-4 shrink-0" />
<div className="text-xs truncate">Новый заказ / чек</div>
</button>
<button
type="button"
onClick={() => setActiveTab('booking')}
className={`p-2.5 rounded-lg border text-left flex items-center gap-2.5 transition-all ${
activeTab === 'booking'
? 'border-krona-green bg-krona-green/10 text-krona-green-text font-medium'
: 'border-krona-border bg-krona-surface text-krona-secondary hover:bg-krona-workspace'
}`}
>
<Calendar className="w-4 h-4 shrink-0" />
<div className="text-xs truncate">Запись / Бронь</div>
</button>
<button
type="button"
onClick={() => setActiveTab(profile.capabilities.work_orders ? 'work_order' : 'client')}
className={`p-2.5 rounded-lg border text-left flex items-center gap-2.5 transition-all ${
activeTab === 'work_order' || activeTab === 'client'
? 'border-krona-green bg-krona-green/10 text-krona-green-text font-medium'
: 'border-krona-border bg-krona-surface text-krona-secondary hover:bg-krona-workspace'
}`}
>
{profile.capabilities.work_orders ? (
<>
<Wrench className="w-4 h-4 shrink-0" />
<div className="text-xs truncate">Заказ-наряд</div>
</>
) : (
<>
<UserPlus className="w-4 h-4 shrink-0" />
<div className="text-xs truncate">Новый клиент</div>
</>
)}
</button>
</div>
<form onSubmit={handleCreate} className="space-y-3 pt-2">
<div className="grid grid-cols-2 gap-3">
<Input
label="Имя клиента"
value={clientName}
onChange={(e) => setClientName(e.target.value)}
placeholder="Иван Петров"
required
/>
<Input
label="Номер телефона"
value={clientPhone}
onChange={(e) => setClientPhone(e.target.value)}
placeholder="+7 (913) 000-00-00"
required
/>
</div>
{activeTab === 'booking' && (
<div className="grid grid-cols-2 gap-3">
<Input
label={`Ресурс (${profile.terminology.bookingResourceLabel})`}
value={resource}
onChange={(e) => setResource(e.target.value)}
placeholder="Стол 4 / Пост 2 / Мастер"
/>
<Input
label="Время записи"
type="time"
value={bookingTime}
onChange={(e) => setBookingTime(e.target.value)}
/>
</div>
)}
{activeTab === 'work_order' && (
<div className="space-y-3">
<Input
label="Автомобиль и госномер"
value={vehicle}
onChange={(e) => setVehicle(e.target.value)}
placeholder="BMW X5 (А001АА 154)"
icon={<Car className="w-4 h-4" />}
/>
</div>
)}
<div className="grid grid-cols-2 gap-3">
<Input
label="Сумма / Депозит (₽)"
type="number"
value={amount}
onChange={(e) => setAmount(e.target.value)}
placeholder="2500"
/>
<div className="flex flex-col gap-1.5 text-left">
<label className="text-xs font-medium text-krona-secondary">Точка бизнеса</label>
<input
disabled
value={activeLocation}
className="w-full bg-krona-workspace text-krona-secondary border border-krona-border rounded-md px-3 py-2 text-sm"
/>
</div>
</div>
<div className="pt-3 flex justify-end gap-2">
<Button type="button" variant="secondary" onClick={() => setIsQuickActionsOpen(false)}>
Отмена
</Button>
<Button type="submit" variant="primary">
Создать сущность
</Button>
</div>
</form>
</div>
)}
</Modal>
);
};

View file

@ -0,0 +1,411 @@
import React, { useState } from 'react';
import { useDemoStore } from '../../mock/store';
import { Button } from '../../shared/components/Button';
import { Input } from '../../shared/components/Input';
import { Badge } from '../../shared/components/Badge';
import {
Sparkles,
Send,
MessageSquare,
Zap,
ChevronRight,
ChevronLeft,
ShoppingBag,
Calendar,
CheckSquare,
UserPlus,
Car,
Receipt,
RotateCcw,
Bot,
User,
} from 'lucide-react';
import { useNavigate } from 'react-router-dom';
export const RightCommandPanel: React.FC = () => {
const {
isRightPanelCollapsed,
setIsRightPanelCollapsed,
getActiveProfile,
setIsQuickActionsOpen,
activeLocation,
} = useDemoStore();
const profile = getActiveProfile();
const navigate = useNavigate();
const [activeTab, setActiveTab] = useState<'ai' | 'chat' | 'actions'>('ai');
// AI Mock Chat state
const [messages, setMessages] = useState<{ id: string; sender: 'user' | 'ai'; text: string; time: string }[]>([
{
id: '1',
sender: 'ai',
text: `Приветствую! Я Krona AI. Анализирую точку «${activeLocation}» (${profile.name}). Выручка в норме, но обратите внимание на запас охлажденного лосося — осталось 2.4 кг. Чем помочь?`,
time: '12:30',
},
]);
const [prompt, setPrompt] = useState('');
const [isAiTyping, setIsAiTyping] = useState(false);
// Communications mini state
const [chatCategory, setChatCategory] = useState<'all' | 'clients' | 'team'>('all');
const QUICK_QUESTIONS = [
'Что требует внимания сегодня?',
'Какие позиции скоро закончатся?',
'Самые прибыльные позиции?',
'Каких клиентов стоит вернуть?',
];
const handleSendPrompt = (textToSend?: string) => {
const q = textToSend || prompt;
if (!q.trim()) return;
const userMsg = {
id: `usr-${Date.now()}`,
sender: 'user' as const,
text: q,
time: new Date().toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit' }),
};
setMessages((prev) => [...prev, userMsg]);
setPrompt('');
setIsAiTyping(true);
setTimeout(() => {
let aiReply = 'Анализирую операционные данные Кроны...';
if (q.includes('внимания')) {
aiReply = '⚠️ 3 критических алерта: 1) Запас лосося ниже минимума (2.4 кг из 6 кг); 2) Заказ #104 готовится уже 18 минут; 3) Курьер Алексей завершил смену с 24 500 ₽ наличных к инкассации.';
} else if (q.includes('закончатся') || q.includes('склад')) {
aiReply = '📦 Скоро закончатся: Лосось атлантический (2.4 кг), Угорь копченый (0.0 кг — в стоп-листе!), Тигровые креветки (4.8 кг). Рекомендую сформировать авто-заявку в СибРыбТорг.';
} else if (q.includes('прибыльные') || q.includes('блюда')) {
aiReply = '💰 Топ по маржинальности: 1) Гёдза свинина (маржа 76.1%, food cost 23.9%); 2) Рамен Тонкоцу (маржа 72.1%); 3) Филадельфия Премиум (выручка 58 650 ₽/день).';
} else if (q.includes('клиент')) {
aiReply = '👥 В зоне риска 14 постоянных клиентов (не было визитов более 30 дней). Например: Дмитрий Мельников (LTV 18 500 ₽). Предлагаю запустить триггерную рассылку со скидкой 15% в Telegram-боте.';
} else {
aiReply = `Ответ AI по профилю «${profile.name}»: Задача зафиксирована в оркестраторе Кроны. Контекст передан специализированным агентам.`;
}
setMessages((prev) => [
...prev,
{
id: `ai-${Date.now()}`,
sender: 'ai',
text: aiReply,
time: new Date().toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit' }),
},
]);
setIsAiTyping(false);
}, 650);
};
if (isRightPanelCollapsed) {
return (
<button
onClick={() => setIsRightPanelCollapsed(false)}
className="fixed right-0 top-20 bg-krona-surface border-y border-l border-krona-border rounded-l-lg p-2 text-krona-secondary hover:text-krona-primary shadow-krona-soft z-30 flex flex-col items-center gap-2 transition-all"
title="Развернуть панель управления"
>
<ChevronLeft className="w-4 h-4 text-krona-gold" />
<span className="text-[10px] font-semibold uppercase [writing-mode:vertical-rl] tracking-wider text-krona-secondary">
Command Panel
</span>
</button>
);
}
return (
<aside className="w-80 h-[calc(100vh-3.5rem)] bg-krona-workspace/95 border-l border-krona-border flex flex-col shrink-0 z-20">
{/* Top Header & Tab switcher */}
<div className="p-3 border-b border-krona-border flex items-center justify-between bg-krona-surface/50">
<div className="flex items-center gap-1 bg-krona-workspace p-0.5 rounded-lg border border-krona-border text-xs">
<button
onClick={() => setActiveTab('ai')}
className={`px-2.5 py-1 rounded-md font-medium flex items-center gap-1 transition-colors ${
activeTab === 'ai'
? 'bg-krona-gold text-white font-semibold shadow-sm'
: 'text-krona-secondary hover:text-krona-primary'
}`}
>
<Sparkles className="w-3 h-3" />
<span>Krona AI</span>
</button>
<button
onClick={() => setActiveTab('actions')}
className={`px-2.5 py-1 rounded-md font-medium flex items-center gap-1 transition-colors ${
activeTab === 'actions'
? 'bg-[#2F684C] text-white font-semibold shadow-sm'
: 'text-krona-secondary hover:text-krona-primary'
}`}
>
<Zap className="w-3 h-3" />
<span>Действия</span>
</button>
<button
onClick={() => setActiveTab('chat')}
className={`px-2.5 py-1 rounded-md font-medium flex items-center gap-1 transition-colors ${
activeTab === 'chat'
? 'bg-krona-surface text-krona-primary shadow-sm'
: 'text-krona-secondary hover:text-krona-primary'
}`}
>
<MessageSquare className="w-3 h-3" />
<span>Чаты</span>
</button>
</div>
<button
onClick={() => setIsRightPanelCollapsed(true)}
className="p-1 rounded text-krona-secondary hover:text-krona-primary"
title="Свернуть панель"
>
<ChevronRight className="w-4 h-4" />
</button>
</div>
{/* Content based on Active Tab */}
{activeTab === 'ai' && (
<div className="flex-1 flex flex-col overflow-hidden">
{/* Quick prompts chips */}
<div className="p-2.5 border-b border-krona-border/60 bg-krona-surface/40 flex items-center gap-1.5 overflow-x-auto text-[11px]">
{QUICK_QUESTIONS.map((q, idx) => (
<button
key={idx}
onClick={() => handleSendPrompt(q)}
className="whitespace-nowrap px-2 py-1 rounded-full bg-krona-gold/10 hover:bg-krona-gold/20 text-krona-gold-text border border-krona-gold/30 transition-colors"
>
{q}
</button>
))}
</div>
{/* Conversation stream */}
<div className="flex-1 overflow-y-auto p-3 space-y-3 text-xs">
{messages.map((m) => (
<div
key={m.id}
className={`flex gap-2 ${m.sender === 'user' ? 'justify-end' : 'justify-start'}`}
>
{m.sender === 'ai' && (
<div className="w-6 h-6 rounded-full bg-krona-gold/20 border border-krona-gold/40 flex items-center justify-center text-krona-gold shrink-0">
<Sparkles className="w-3.5 h-3.5" />
</div>
)}
<div
className={`p-2.5 rounded-lg max-w-[85%] leading-relaxed ${
m.sender === 'user'
? 'bg-[#2F684C] text-[#F8F6F0] rounded-br-none'
: 'bg-krona-surface border border-krona-border text-krona-primary rounded-bl-none shadow-krona-soft'
}`}
>
<p>{m.text}</p>
<span
className={`block text-[9px] mt-1 text-right ${
m.sender === 'user' ? 'text-white/70' : 'text-krona-secondary'
}`}
>
{m.time}
</span>
</div>
</div>
))}
{isAiTyping && (
<div className="flex items-center gap-1 text-krona-gold text-xs italic">
<Sparkles className="w-3.5 h-3.5 animate-spin" />
<span>Krona AI думает...</span>
</div>
)}
</div>
{/* Prompt input */}
<div className="p-2.5 border-t border-krona-border bg-krona-surface/60">
<form
onSubmit={(e) => {
e.preventDefault();
handleSendPrompt();
}}
className="flex items-center gap-1.5"
>
<input
type="text"
value={prompt}
onChange={(e) => setPrompt(e.target.value)}
placeholder="Спросите AI об остатках, выручке..."
className="flex-1 text-xs bg-krona-surface border border-krona-border rounded-md px-3 py-2 text-krona-primary placeholder:text-krona-secondary focus:outline-none focus:border-krona-gold"
/>
<Button type="submit" variant="gold" size="sm" className="px-2.5">
<Send className="w-3.5 h-3.5" />
</Button>
</form>
</div>
</div>
)}
{activeTab === 'actions' && (
<div className="flex-1 overflow-y-auto p-3 space-y-2 text-xs">
<div className="text-[11px] font-semibold text-krona-secondary uppercase tracking-wider mb-2">
Быстрые операции: {profile.name}
</div>
<Button
variant="outline"
className="w-full justify-start text-xs"
onClick={() => setIsQuickActionsOpen(true)}
>
<ShoppingBag className="w-4 h-4 mr-2 text-krona-green" />
<span>Новый чек / заказ</span>
</Button>
{profile.capabilities.booking && (
<Button
variant="outline"
className="w-full justify-start text-xs"
onClick={() => setIsQuickActionsOpen(true)}
>
<Calendar className="w-4 h-4 mr-2 text-krona-gold" />
<span>Забронировать {profile.terminology.bookingResourceLabel.toLowerCase()}</span>
</Button>
)}
{profile.capabilities.work_orders && (
<Button
variant="outline"
className="w-full justify-start text-xs"
onClick={() => setIsQuickActionsOpen(true)}
>
<Car className="w-4 h-4 mr-2 text-krona-info" />
<span>Открыть заказ-наряд на пост</span>
</Button>
)}
<Button
variant="outline"
className="w-full justify-start text-xs"
onClick={() => navigate('/operations/pos')}
>
<Receipt className="w-4 h-4 mr-2 text-krona-green" />
<span>Открыть кассовый терминал (POS)</span>
</Button>
<Button
variant="outline"
className="w-full justify-start text-xs"
onClick={() => navigate('/tasks')}
>
<CheckSquare className="w-4 h-4 mr-2 text-krona-secondary" />
<span>Поставить задачу сотруднику</span>
</Button>
<Button
variant="outline"
className="w-full justify-start text-xs"
onClick={() => navigate('/inventory')}
>
<RotateCcw className="w-4 h-4 mr-2 text-krona-warning" />
<span>Провести списание / инвентаризацию</span>
</Button>
</div>
)}
{activeTab === 'chat' && (
<div className="flex-1 flex flex-col overflow-hidden">
<div className="p-2 border-b border-krona-border flex gap-1 text-[11px]">
<button
onClick={() => setChatCategory('all')}
className={`px-2 py-0.5 rounded ${
chatCategory === 'all' ? 'bg-krona-surface font-semibold text-krona-primary' : 'text-krona-secondary'
}`}
>
Все (3)
</button>
<button
onClick={() => setChatCategory('clients')}
className={`px-2 py-0.5 rounded ${
chatCategory === 'clients' ? 'bg-krona-surface font-semibold text-krona-primary' : 'text-krona-secondary'
}`}
>
Клиенты (2)
</button>
<button
onClick={() => setChatCategory('team')}
className={`px-2 py-0.5 rounded ${
chatCategory === 'team' ? 'bg-krona-surface font-semibold text-krona-primary' : 'text-krona-secondary'
}`}
>
Команда (1)
</button>
</div>
<div className="flex-1 overflow-y-auto divide-y divide-krona-border/50 text-xs">
<div
onClick={() => navigate('/communications')}
className="p-3 hover:bg-krona-surface cursor-pointer transition-colors"
>
<div className="flex items-center justify-between mb-1">
<div className="flex items-center gap-1.5 font-semibold text-krona-primary">
<span className="w-2 h-2 rounded-full bg-blue-500"></span>
<span>Александр Сорокин</span>
</div>
<span className="text-[10px] text-krona-secondary">13:42</span>
</div>
<p className="text-xs text-krona-secondary truncate">
Добрый день! Подскажите, стол 4 у окна сегодня свободен?
</p>
<div className="mt-1 flex items-center gap-1.5">
<Badge variant="gold" size="sm">Telegram</Badge>
<Badge variant="green" size="sm">Новый</Badge>
</div>
</div>
<div
onClick={() => navigate('/communications')}
className="p-3 hover:bg-krona-surface cursor-pointer transition-colors"
>
<div className="flex items-center justify-between mb-1">
<div className="flex items-center gap-1.5 font-semibold text-krona-primary">
<span className="w-2 h-2 rounded-full bg-green-500"></span>
<span>Екатерина Морозова</span>
</div>
<span className="text-[10px] text-krona-secondary">12:15</span>
</div>
<p className="text-xs text-krona-secondary truncate">
Спасибо за быструю доставку! Курьер был очень вежливым.
</p>
<Badge variant="info" size="sm">WhatsApp</Badge>
</div>
<div
onClick={() => navigate('/communications')}
className="p-3 hover:bg-krona-surface cursor-pointer transition-colors"
>
<div className="flex items-center justify-between mb-1">
<div className="flex items-center gap-1.5 font-semibold text-krona-primary">
<span className="w-2 h-2 rounded-full bg-krona-gold"></span>
<span>Кухня & Шеф (Внутренний)</span>
</div>
<span className="text-[10px] text-krona-secondary">11:10</span>
</div>
<p className="text-xs text-krona-secondary truncate">
Артур: Заканчивается лосось, переключите позиции на контроль.
</p>
<Badge variant="warning" size="sm">Командный чат</Badge>
</div>
</div>
<div className="p-2 border-t border-krona-border bg-krona-surface/50 text-center">
<button
onClick={() => navigate('/communications')}
className="text-xs text-krona-green font-medium hover:underline"
>
Открыть единый Inbox →
</button>
</div>
</div>
)}
</aside>
);
};

159
src/app/shell/Sidebar.tsx Normal file
View file

@ -0,0 +1,159 @@
import React, { useState } from 'react';
import { NavLink } from 'react-router-dom';
import { useDemoStore } from '../../mock/store';
import { SYSTEM_MODULES } from '../../core/modules/registry';
import { SYSTEM_ROLES } from '../../core/permissions/types';
import {
LayoutDashboard,
Workflow,
Users,
ShoppingBag,
Boxes,
BadgeDollarSign,
UserCheck,
MessageSquareText,
Headset,
CheckSquare,
Globe,
Megaphone,
BarChart3,
Cpu,
Sparkles,
Grid,
Settings,
ChevronLeft,
ChevronRight,
ShieldAlert,
} from 'lucide-react';
const ICON_MAP: Record<string, React.ReactNode> = {
LayoutDashboard: <LayoutDashboard className="w-4 h-4 shrink-0" />,
Workflow: <Workflow className="w-4 h-4 shrink-0 text-krona-gold" />,
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" />,
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" />,
Headset: <Headset className="w-4 h-4 shrink-0" />,
CheckSquare: <CheckSquare className="w-4 h-4 shrink-0" />,
Globe: <Globe className="w-4 h-4 shrink-0" />,
Megaphone: <Megaphone className="w-4 h-4 shrink-0" />,
BarChart3: <BarChart3 className="w-4 h-4 shrink-0" />,
Cpu: <Cpu className="w-4 h-4 shrink-0" />,
Sparkles: <Sparkles className="w-4 h-4 shrink-0 text-krona-gold" />,
Grid: <Grid className="w-4 h-4 shrink-0" />,
Settings: <Settings className="w-4 h-4 shrink-0" />,
};
export const Sidebar: React.FC = () => {
const [collapsed, setCollapsed] = useState(false);
const { currentRoleId, getActiveCapabilities } = useDemoStore();
const capabilities = getActiveCapabilities();
const role = SYSTEM_ROLES[currentRoleId];
// Filter modules by role permissions and active capabilities
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
className={`relative h-screen bg-[#252722] text-[#D8D8D0] flex flex-col border-r border-[#3A3D35] transition-all duration-200 z-40 select-none ${
collapsed ? 'w-16' : 'w-56'
}`}
>
{/* Brand Header */}
<div className="h-14 px-3.5 flex items-center justify-between border-b border-[#3A3D35] bg-[#20221E]">
<div className="flex items-center gap-2.5 overflow-hidden">
{/* Logo asset from public/branding/krona-logo.png */}
<img
src={collapsed ? '/branding/krona-symbol.png' : '/branding/krona-logo.png'}
alt="Крона"
className={`object-contain transition-all duration-200 ${
collapsed ? 'w-8 h-8' : 'h-8 max-w-[150px]'
}`}
/>
</div>
<button
onClick={() => setCollapsed(!collapsed)}
className="p-1 rounded text-[#858980] hover:text-[#D8D8D0] hover:bg-[#30332C] transition-colors"
title={collapsed ? 'Развернуть меню' : 'Свернуть меню'}
>
{collapsed ? <ChevronRight className="w-4 h-4" /> : <ChevronLeft className="w-4 h-4" />}
</button>
</div>
{/* Navigation List */}
<div className="flex-1 overflow-y-auto py-2.5 px-2 space-y-0.5">
{visibleModules.map((item) => (
<NavLink
key={item.id}
to={item.route}
end={item.route === '/'}
className={({ isActive }) =>
`group relative flex items-center gap-2.5 px-2.5 py-2 rounded-md text-xs font-medium transition-all ${
isActive
? 'bg-[#34382F] text-[#F5F0E5] font-semibold'
: 'text-[#BFC1B9] hover:text-[#F5F0E5] hover:bg-[#30332C]'
}`
}
title={collapsed ? item.label : undefined}
>
{({ isActive }) => (
<>
{/* Active indicator bar */}
{isActive && (
<div className="absolute left-0 top-1.5 bottom-1.5 w-1 bg-[#B78335] rounded-r" />
)}
<div className={`shrink-0 ${isActive ? 'text-[#D4B16D]' : 'text-[#BFC1B9]'}`}>
{ICON_MAP[item.iconName] || <LayoutDashboard className="w-4 h-4 shrink-0" />}
</div>
{!collapsed && (
<div className="flex-1 flex items-center justify-between truncate">
<span className="truncate">{item.label}</span>
{item.badge && (
<span className="text-[10px] px-1.5 py-0.5 rounded-full bg-[#3A3D35] text-[#D8D8D0] font-mono">
{item.badge}
</span>
)}
</div>
)}
</>
)}
</NavLink>
))}
</div>
{/* Footer / Role indicator */}
<div className="p-2.5 border-t border-[#3A3D35] bg-[#20221E]/60 text-xs">
{!collapsed ? (
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<div className="w-2 h-2 rounded-full bg-krona-green animate-pulse" />
<div className="truncate">
<div className="text-[11px] font-semibold text-[#F5F0E5] truncate">{role.label}</div>
<div className="text-[9px] text-[#858980]">RBAC: {role.badge}</div>
</div>
</div>
</div>
) : (
<div className="flex justify-center" title={`Текущая роль: ${role.label}`}>
<div className="w-2.5 h-2.5 rounded-full bg-krona-green" />
</div>
)}
</div>
</aside>
);
};

368
src/app/shell/Topbar.tsx Normal file
View file

@ -0,0 +1,368 @@
import React, { useState } from 'react';
import { useLocation, useNavigate } from 'react-router-dom';
import { useDemoStore, ThemeMode, ViewportMode } 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 { Button } from '../../shared/components/Button';
import { Badge } from '../../shared/components/Badge';
import {
Search,
Plus,
Bell,
Sun,
Moon,
Sparkles,
Monitor,
Tablet,
Smartphone,
ChevronDown,
MapPin,
Shield,
Layers,
} from 'lucide-react';
export const Topbar: React.FC = () => {
const {
theme,
setTheme,
viewportMode,
setViewportMode,
currentProfileId,
setProfileId,
currentRoleId,
setRoleId,
activeLocation,
setActiveLocation,
setIsCommandPaletteOpen,
setIsQuickActionsOpen,
setIsNotificationsOpen,
setIsAiDrawerOpen,
notifications,
getActiveProfile,
getActiveCapabilities,
} = useDemoStore();
const location = useLocation();
const navigate = useNavigate();
const profile = getActiveProfile();
const capabilities = getActiveCapabilities();
const currentRole = SYSTEM_ROLES[currentRoleId];
const unreadNotificationsCount = notifications.filter((n) => !n.read).length;
// Determine current active module from route
const currentPath = location.pathname;
const currentModule = SYSTEM_MODULES.find((m) => {
if (m.route === '/') return currentPath === '/';
return currentPath.startsWith(m.route);
}) || SYSTEM_MODULES[0];
// For operations, tabs are dynamic based on active profile & capabilities!
const getSubtabs = () => {
if (currentModule.id === 'operations') {
return profile.operationsTabs.filter((tab) => {
if (!tab.requiredCapability) return true;
return capabilities[tab.requiredCapability];
});
}
return currentModule.sections.filter((sec) => {
if (!sec.requiredCapability) return true;
return capabilities[sec.requiredCapability];
});
};
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">
{/* 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">
<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">
{profile.name}
</span>
</div>
<ChevronDown className="w-3.5 h-3.5 text-krona-secondary" />
</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>
<div className="max-h-80 overflow-y-auto py-1 space-y-0.5">
{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'
}`}
>
<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>
</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">
Точки продаж / Локации
</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 className="flex items-center gap-1.5 bg-krona-surface border border-krona-border rounded-lg px-2.5 py-1.5 cursor-pointer hover:border-krona-border-strong text-xs shadow-krona-soft">
<Shield className="w-3.5 h-3.5 text-krona-green" />
<div className="flex flex-col text-left">
<span className="text-[9px] uppercase font-semibold text-krona-secondary">Роль</span>
<span className="font-semibold text-krona-primary truncate max-w-[90px]">
{currentRole.badge}
</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 hidden group-hover:block z-50">
<div className="px-2 py-1 text-[10px] font-semibold text-krona-secondary uppercase border-b border-krona-border/60">
Переключение роли (RBAC)
</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}
onClick={() => {
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">
{r.badge}
</span>
</div>
<div className="text-[10px] text-krona-secondary truncate mt-0.5">{r.description}</div>
</button>
))}
</div>
</div>
</div>
</div>
{/* Center: Global Search & Shortcuts */}
<div className="flex-1 max-w-md hidden md:block">
<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"
>
<div className="flex items-center gap-2">
<Search className="w-3.5 h-3.5" />
<span>Поиск по Кроне, клиентам, чекам, KDS...</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">
Ctrl+K
</kbd>
</button>
</div>
{/* Right: Quick actions, AI, Themes, Viewports, Notifications */}
<div className="flex items-center gap-1.5">
{/* AI trigger */}
<Button
variant="gold"
size="sm"
onClick={() => setIsAiDrawerOpen(true)}
className="hidden sm:inline-flex text-xs font-medium gap-1.5"
>
<Sparkles className="w-3.5 h-3.5" />
<span>✦ Спросить Крону</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">
<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"
>
<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" />
</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
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
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
onClick={() => setTheme('dark')}
className={`p-1 rounded transition-colors ${
theme === 'dark' ? 'bg-krona-workspace text-krona-gold' : 'text-krona-secondary'
}`}
title="Dark"
>
<Moon className="w-3.5 h-3.5" />
</button>
</div>
{/* Notifications bell */}
<button
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"
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>
)}
</button>
</div>
</div>
{/* Horizontal Module Subnavigation Tabs */}
{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>
{activeSubtabs.map((tab) => {
const isActive =
currentPath === tab.route ||
(tab.route !== '/operations' && tab.route !== '/' && currentPath.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 ${
isActive
? 'bg-[#2F684C] text-[#F8F6F0] shadow-sm'
: 'text-krona-secondary hover:text-krona-primary hover:bg-krona-surface/60'
}`}
>
<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'
}`}
>
{tab.badge}
</span>
)}
</button>
);
})}
</nav>
)}
</header>
);
};

BIN
src/assets/hero.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

1
src/assets/react.svg Normal file
View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

After

Width:  |  Height:  |  Size: 4 KiB

1
src/assets/vite.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 8.5 KiB

View file

@ -0,0 +1,53 @@
export type CapabilityId =
| 'crm'
| 'sales'
| 'communications'
| 'helpdesk'
| 'tasks'
| 'cms'
| 'marketing'
| 'analytics'
| 'finance'
| 'employees'
| 'inventory'
| 'purchasing'
| 'delivery'
| 'booking'
| 'pos'
| 'kds'
| 'dining'
| 'work_orders'
| 'production'
| 'automation'
| 'ai';
export interface CapabilityMeta {
id: CapabilityId;
name: string;
description: string;
category: 'core' | 'operations' | 'growth' | 'management';
}
export const CAPABILITIES_LIST: CapabilityMeta[] = [
{ id: 'crm', name: 'CRM & Клиенты', description: 'Управление клиентами, лидами, сделками и воронками', category: 'core' },
{ id: 'sales', name: 'Продажи & Заказы', description: 'Обработка заказов, счетов и оплат', category: 'core' },
{ id: 'communications', name: 'Омниканальные коммуникации', description: 'Единый inbox: Telegram, WhatsApp, MAX, VK, почта', category: 'core' },
{ id: 'helpdesk', name: 'HelpDesk & Поддержка', description: 'Тикеты, SLA, база знаний и решение инцидентов', category: 'core' },
{ id: 'tasks', name: 'Задачи & Проекты', description: 'Канбан, списки задач, спринты и контроль сроков', category: 'management' },
{ id: 'cms', name: 'Сайты & Krona CMS', description: 'Конструктор страниц, блог, формы, SEO и витрины', category: 'growth' },
{ id: 'marketing', name: 'Маркетинг & Рассылки', description: 'Контент-план, акции, рассылки, AI-маркетолог', category: 'growth' },
{ id: 'analytics', name: 'Аналитика & BI', description: 'Сквозная аналитика, финансовые отчеты и дашборды', category: 'management' },
{ id: 'finance', name: 'Финансы & ДДС', description: 'Доходы, расходы, кассовые смены, P&L и казначейство', category: 'management' },
{ id: 'employees', name: 'Сотрудники & Смены', description: 'Графики, учет рабочего времени, роли и KPI', category: 'management' },
{ id: 'inventory', name: 'Склад & Остатки', description: 'Остатки, партии, списания, перемещения, инвентаризация', category: 'operations' },
{ id: 'purchasing', name: 'Закупки & Поставщики', description: 'Заказы поставщикам, приходные накладные, сверки', category: 'operations' },
{ id: 'delivery', name: 'Доставка & Курьеры', description: 'Маршруты, статусы курьеров, расчет зон и стоимости', category: 'operations' },
{ id: 'booking', name: 'Бронирование & Слоты', description: 'Запись клиентов, онлайн-слоты, бронь ресурсов', category: 'operations' },
{ id: 'pos', name: 'Кассовый терминал (POS)', description: 'Быстрое оформление чеков, скидки, фискализация', category: 'operations' },
{ id: 'kds', name: 'Кухонный экран (KDS)', description: 'Дисплей кухни/сборки с таймерами и статусами', category: 'operations' },
{ id: 'dining', name: 'Зал & Столы', description: 'Схема залов, рассадка, открытые чеки и официанты', category: 'operations' },
{ id: 'work_orders', name: 'Заказ-наряды', description: 'Ремонтные работы, материалы, посты и нормо-часы', category: 'operations' },
{ id: 'production', name: 'Производство & Техкарты', description: 'Рецептуры, списание сырья, калькуляция себестоимости', category: 'operations' },
{ id: 'automation', name: 'Автоматизация & Workflows', description: 'Триггеры, цепочки действий, сценарии без кода', category: 'growth' },
{ id: 'ai', name: 'Krona AI Center', description: 'AI-команда: оркестратор, бизнес-аналитик, маркетолог', category: 'growth' },
];

View file

@ -0,0 +1,237 @@
import { ModuleDefinition } from './types';
export const SYSTEM_MODULES: ModuleDefinition[] = [
{
id: 'dashboard',
label: 'Главная',
iconName: 'LayoutDashboard',
route: '/',
sections: [
{ id: 'overview', label: 'Сводка дня', route: '/' },
{ id: 'alerts', label: 'Требует внимания', route: '/?tab=alerts', badge: '3' },
{ id: 'live', label: 'Операционный статус', route: '/?tab=live' },
],
},
{
id: 'operations',
label: 'Операции',
iconName: 'Workflow',
route: '/operations',
sections: [], // Populated dynamically from active Business Profile operationsTabs
},
{
id: 'crm',
label: 'CRM',
iconName: 'Users',
route: '/crm',
requiredCapability: 'crm',
sections: [
{ id: 'overview', label: 'Обзор CRM', route: '/crm' },
{ id: 'clients', label: 'Клиенты', route: '/crm/clients', badge: '1,420' },
{ id: 'deals', label: 'Сделки & Воронка', route: '/crm/deals', badge: '18' },
{ id: 'leads', label: 'Лиды', route: '/crm/leads', badge: '9' },
{ id: 'companies', label: 'Компании', route: '/crm/companies' },
{ id: 'segments', label: 'Сегменты', route: '/crm/segments' },
{ id: 'activities', label: 'Активности', route: '/crm/activities' },
],
},
{
id: 'sales',
label: 'Продажи',
iconName: 'ShoppingBag',
route: '/sales',
requiredCapability: 'sales',
sections: [
{ id: 'overview', label: 'Обзор', route: '/sales' },
{ id: 'orders', label: 'Все заказы', route: '/sales/orders', badge: '42' },
{ id: 'invoices', label: 'Счета', route: '/sales/invoices' },
{ id: 'payments', label: 'Оплаты', route: '/sales/payments' },
{ id: 'refunds', label: 'Возвраты', route: '/sales/refunds' },
{ id: 'funnels', label: 'Конверсия', route: '/sales/funnels' },
],
},
{
id: 'inventory',
label: 'Склад',
iconName: 'Boxes',
route: '/inventory',
requiredCapability: 'inventory',
sections: [
{ id: 'overview', label: 'Обзор склада', route: '/inventory' },
{ id: 'items', label: 'Остатки товаров', route: '/inventory/items' },
{ id: 'warehouses', label: 'Склады & Локации', route: '/inventory/warehouses' },
{ id: 'batches', label: 'Партии & Сроки', route: '/inventory/batches' },
{ id: 'movements', label: 'Перемещения', route: '/inventory/movements' },
{ id: 'writeoffs', label: 'Списания', route: '/inventory/writeoffs' },
{ id: 'audit', label: 'Инвентаризация', route: '/inventory/audit' },
],
},
{
id: 'finance',
label: 'Финансы',
iconName: 'BadgeDollarSign',
route: '/finance',
requiredCapability: 'finance',
sections: [
{ id: 'overview', label: 'Обзор & Сальдо', route: '/finance' },
{ id: 'cashflow', label: 'ДДС (Cash Flow)', route: '/finance/cashflow' },
{ id: 'pnl', label: 'P&L (Прибыли и убытки)', route: '/finance/pnl' },
{ id: 'shifts', label: 'Кассовые смены', route: '/finance/shifts', badge: '2 открыты' },
{ id: 'transactions', label: 'Платежи', route: '/finance/transactions' },
{ id: 'debt', label: 'Задолженности', route: '/finance/debt' },
],
},
{
id: 'employees',
label: 'Сотрудники',
iconName: 'UserCheck',
route: '/employees',
requiredCapability: 'employees',
sections: [
{ id: 'list', label: 'Команда', route: '/employees' },
{ id: 'schedule', label: 'Графики & Смены', route: '/employees/schedule' },
{ id: 'timetrack', label: 'Рабочее время & Табель', route: '/employees/timetrack' },
{ id: 'roles', label: 'Должности & Ставки', route: '/employees/roles' },
{ id: 'kpi', label: 'KPI & Мотивация', route: '/employees/kpi' },
],
},
{
id: 'communications',
label: 'Коммуникации',
iconName: 'MessageSquareText',
route: '/communications',
requiredCapability: 'communications',
badge: '3',
sections: [
{ id: 'inbox', label: 'Единый Inbox', route: '/communications' },
{ id: 'telegram', label: 'Telegram Bot & Чат', route: '/communications?channel=telegram' },
{ id: 'whatsapp', label: 'WhatsApp', route: '/communications?channel=whatsapp' },
{ id: 'team', label: 'Командный чат', route: '/communications?channel=team' },
{ id: 'channels', label: 'Подключенные каналы', route: '/communications/channels' },
],
},
{
id: 'helpdesk',
label: 'HelpDesk',
iconName: 'Headset',
route: '/helpdesk',
requiredCapability: 'helpdesk',
sections: [
{ id: 'overview', label: 'Обзор поддержки', route: '/helpdesk' },
{ id: 'tickets', label: 'Очередь тикетов', route: '/helpdesk/tickets', badge: '5' },
{ id: 'sla', label: 'SLA & Эскалации', route: '/helpdesk/sla' },
{ id: 'kb', label: 'База знаний', route: '/helpdesk/kb' },
{ id: 'analytics', label: 'Метрики поддержки', route: '/helpdesk/analytics' },
],
},
{
id: 'tasks',
label: 'Задачи и проекты',
iconName: 'CheckSquare',
route: '/tasks',
requiredCapability: 'tasks',
sections: [
{ id: 'my', label: 'Мои задачи', route: '/tasks' },
{ id: 'kanban', label: 'Канбан-доска', route: '/tasks/kanban' },
{ id: 'projects', label: 'Проекты', route: '/tasks/projects' },
{ id: 'calendar', label: 'Календарь дедлайнов', route: '/tasks/calendar' },
{ id: 'overdue', label: 'Просроченные', route: '/tasks/overdue', badge: '2' },
],
},
{
id: 'cms',
label: 'Сайты',
iconName: 'Globe',
route: '/cms',
requiredCapability: 'cms',
sections: [
{ id: 'sites', label: 'Мои сайты', route: '/cms' },
{ id: 'pages', label: 'Страницы & Конструктор', route: '/cms/pages' },
{ id: 'forms', label: 'Лид-формы', route: '/cms/forms' },
{ id: 'media', label: 'Медиабиблиотека', route: '/cms/media' },
{ id: 'seo', label: 'SEO & Аналитика', route: '/cms/seo' },
{ id: 'domains', label: 'Домены', route: '/cms/domains' },
],
},
{
id: 'marketing',
label: 'Маркетинг',
iconName: 'Megaphone',
route: '/marketing',
requiredCapability: 'marketing',
sections: [
{ id: 'overview', label: 'Обзор маркетинга', route: '/marketing' },
{ id: 'promos', label: 'Акции & Промокоды', route: '/marketing/promos' },
{ id: 'broadcasts', label: 'Рассылки', route: '/marketing/broadcasts' },
{ id: 'content', label: 'Контент-план', route: '/marketing/content' },
{ id: 'ai_marketer', label: 'AI Маркетолог', route: '/marketing/ai' },
],
},
{
id: 'analytics',
label: 'Аналитика',
iconName: 'BarChart3',
route: '/analytics',
requiredCapability: 'analytics',
sections: [
{ id: 'overview', label: 'Общий дашборд', route: '/analytics' },
{ id: 'sales_bi', label: 'Продажи & Выручка', route: '/analytics/sales' },
{ id: 'abc', label: 'ABC/XYZ анализ', route: '/analytics/abc' },
{ id: 'retention', label: 'Когорты & LTV', route: '/analytics/retention' },
{ id: 'forecasts', label: 'ИИ-прогнозы', route: '/analytics/forecasts' },
],
},
{
id: 'automation',
label: 'Автоматизация',
iconName: 'Cpu',
route: '/automation',
requiredCapability: 'automation',
sections: [
{ id: 'scenarios', label: 'Сценарии & Workflows', route: '/automation' },
{ id: 'builder', label: 'Конструктор цепочек', route: '/automation/builder' },
{ id: 'triggers', label: 'Триггеры событий', route: '/automation/triggers' },
{ id: 'history', label: 'Лог выполнения', route: '/automation/history' },
],
},
{
id: 'ai',
label: 'ИИ-центр',
iconName: 'Sparkles',
route: '/ai',
requiredCapability: 'ai',
badge: '✦',
sections: [
{ id: 'chat', label: 'Диалог с AI', route: '/ai' },
{ id: 'team', label: 'AI Команда', route: '/ai/team' },
{ id: 'tasks', label: 'ИИ-задачи в фоне', route: '/ai/tasks' },
{ id: 'history', label: 'История & Контекст', route: '/ai/history' },
{ id: 'usage', label: 'Лимиты & Токены', route: '/ai/usage' },
],
},
{
id: 'apps',
label: 'Приложения',
iconName: 'Grid',
route: '/apps',
sections: [
{ id: 'catalog', label: 'Каталог расширений', route: '/apps' },
{ id: 'installed', label: 'Установленные', route: '/apps/installed' },
{ id: 'integrations', label: 'Интеграции', route: '/apps/integrations' },
],
},
{
id: 'settings',
label: 'Настройки',
iconName: 'Settings',
route: '/settings',
sections: [
{ id: 'company', label: 'Организация & Точки', route: '/settings' },
{ id: 'profile', label: 'Бизнес-профиль', route: '/settings/profile' },
{ id: 'capabilities', label: 'Configuration Studio', route: '/settings/capabilities' },
{ id: 'users', label: 'Пользователи & Роли', route: '/settings/users' },
{ id: 'integrations', label: 'API & Webhooks', route: '/settings/api' },
{ id: 'appearance', label: 'Тема & Брендинг', route: '/settings/appearance' },
],
},
];

19
src/core/modules/types.ts Normal file
View file

@ -0,0 +1,19 @@
import { CapabilityId } from '../capabilities/types';
export interface ModuleSection {
id: string;
label: string;
route: string;
badge?: string;
requiredCapability?: CapabilityId;
}
export interface ModuleDefinition {
id: string;
label: string;
iconName: string;
route: string;
badge?: string;
requiredCapability?: CapabilityId;
sections: ModuleSection[];
}

View file

@ -0,0 +1,103 @@
export type SystemRoleId =
| 'owner'
| 'administrator'
| 'manager'
| 'cashier'
| 'cook'
| 'courier'
| 'specialist'
| 'marketer'
| 'warehouse'
| 'support';
export interface RoleInfo {
id: SystemRoleId;
label: string;
badge: string;
description: string;
allowedModules: string[];
defaultRoute: string;
}
export const SYSTEM_ROLES: Record<SystemRoleId, RoleInfo> = {
owner: {
id: 'owner',
label: 'Владелец бизнеса',
badge: 'Owner',
description: 'Полный доступ ко всем модулям, финансовой аналитике, настройкам и AI-центру',
allowedModules: ['*'],
defaultRoute: '/',
},
administrator: {
id: 'administrator',
label: 'Администратор',
badge: 'Admin',
description: 'Управление процессами, персоналом, точками, правами и операционной деятельностью',
allowedModules: ['*'],
defaultRoute: '/',
},
manager: {
id: 'manager',
label: 'Управляющий',
badge: 'Manager',
description: 'Операции, смены, заказы, доставка, склад, контроль персонала и операционная аналитика',
allowedModules: ['dashboard', 'operations', 'crm', 'sales', 'inventory', 'employees', 'tasks', 'communications', 'analytics'],
defaultRoute: '/operations',
},
cashier: {
id: 'cashier',
label: 'Кассир / Оператор',
badge: 'POS',
description: 'Кассовый терминал, оформление заказов, прием оплаты, клиентская база',
allowedModules: ['operations', 'sales', 'crm'],
defaultRoute: '/operations/pos',
},
cook: {
id: 'cook',
label: 'Повар / Шеф-повар',
badge: 'KDS',
description: 'Кухонный дисплей (KDS), отметка готовности блюд, стоп-лист',
allowedModules: ['operations'],
defaultRoute: '/operations/kds',
},
courier: {
id: 'courier',
label: 'Курьер / Доставка',
badge: 'Courier',
description: 'Мобильный экран доставки: маршруты, заказы, адрес клиента, отметка вручения',
allowedModules: ['operations'],
defaultRoute: '/operations/courier',
},
specialist: {
id: 'specialist',
label: 'Специалист / Мастер',
badge: 'Service',
description: 'Журнал записи, заказ-наряды постов, расписание смен, услуги',
allowedModules: ['operations', 'crm', 'tasks'],
defaultRoute: '/operations/work-orders',
},
marketer: {
id: 'marketer',
label: 'Маркетолог',
badge: 'Marketing',
description: 'Маркетинговые кампании, CRM сегменты, сайты/CMS, рассылки, сквозная аналитика, AI',
allowedModules: ['dashboard', 'crm', 'marketing', 'cms', 'communications', 'analytics', 'ai'],
defaultRoute: '/marketing',
},
warehouse: {
id: 'warehouse',
label: 'Складской оператор',
badge: 'Warehouse',
description: 'Приходные накладные, списания, партии, инвентаризация и поставщики',
allowedModules: ['operations', 'inventory', 'tasks'],
defaultRoute: '/operations/inventory',
},
support: {
id: 'support',
label: 'Специалист поддержки',
badge: 'Support',
description: 'Очередь тикетов, омниканальные чаты, база знаний и SLA',
allowedModules: ['helpdesk', 'communications', 'crm'],
defaultRoute: '/helpdesk',
},
};

View file

@ -0,0 +1,635 @@
import { BusinessProfile } from './types';
export const BUSINESS_PROFILES: Record<string, BusinessProfile> = {
restaurant_hall_delivery: {
id: 'restaurant_hall_delivery',
name: 'Ресторан: Зал + Доставка',
label: 'Суши & Гёдза (Зал + Доставка)',
category: 'Общепит',
companyName: 'Суши & Гёдза',
locations: ['Горский 1/1', 'Красный пр-т 186а'],
capabilities: {
crm: true,
sales: true,
communications: true,
helpdesk: true,
tasks: true,
cms: true,
marketing: true,
analytics: true,
finance: true,
employees: true,
inventory: true,
purchasing: true,
delivery: true,
booking: true,
pos: true,
kds: true,
dining: true,
work_orders: false,
production: true,
automation: true,
ai: true,
},
operationsTabs: [
{ id: 'overview', label: 'Обзор', route: '/operations' },
{ id: 'orders', label: 'Заказы', route: '/operations/orders', badge: '14' },
{ id: 'pos', label: 'Касса (POS)', route: '/operations/pos', requiredCapability: 'pos' },
{ id: 'dining', label: 'Зал', route: '/operations/dining', requiredCapability: 'dining', badge: '82%' },
{ id: 'kds', label: 'Кухня (KDS)', route: '/operations/kds', requiredCapability: 'kds', badge: '6' },
{ id: 'catalog', label: 'Меню', route: '/operations/catalog' },
{ id: 'recipes', label: 'Техкарты', route: '/operations/recipes', requiredCapability: 'production' },
{ id: 'inventory', label: 'Склад', route: '/operations/inventory', requiredCapability: 'inventory' },
{ id: 'purchasing', label: 'Закупки', route: '/operations/purchasing', requiredCapability: 'purchasing' },
{ id: 'delivery', label: 'Доставка', route: '/operations/delivery', requiredCapability: 'delivery', badge: '5' },
{ id: 'booking', label: 'Бронирования', route: '/operations/booking', requiredCapability: 'booking' },
],
terminology: {
itemLabel: 'Блюдо',
itemsLabel: 'Меню и напитки',
bookingResourceLabel: 'Стол',
orderSourceLabel: 'Зал / Доставка / Навынос',
clientIdentifierLabel: 'Гость ресторана',
},
dashboardPreset: {
primaryKpis: ['revenue_today', 'orders_count', 'avg_check', 'hall_occupancy', 'food_cost'],
quickActionIds: ['new_order', 'book_table', 'create_task', 'new_client', 'new_expense', 'write_off'],
},
},
restaurant_delivery_only: {
id: 'restaurant_delivery_only',
name: 'Ресторан: Только Доставка / Dark Kitchen',
label: 'Суши & Гёдза Express (Dark Kitchen)',
category: 'Общепит / Dark Kitchen',
companyName: 'Суши & Гёдза Express',
locations: ['Красный пр-т 186а (Кухня 1)'],
capabilities: {
crm: true,
sales: true,
communications: true,
helpdesk: true,
tasks: true,
cms: true,
marketing: true,
analytics: true,
finance: true,
employees: true,
inventory: true,
purchasing: true,
delivery: true,
booking: false, // OFF for Delivery Only
pos: true,
kds: true,
dining: false, // OFF for Delivery Only
work_orders: false,
production: true,
automation: true,
ai: true,
},
operationsTabs: [
{ id: 'overview', label: 'Обзор', route: '/operations' },
{ id: 'orders', label: 'Заказы', route: '/operations/orders', badge: '19' },
{ id: 'pos', label: 'Касса (POS)', route: '/operations/pos', requiredCapability: 'pos' },
{ id: 'kds', label: 'Кухня (KDS)', route: '/operations/kds', requiredCapability: 'kds', badge: '9' },
{ id: 'catalog', label: 'Меню', route: '/operations/catalog' },
{ id: 'recipes', label: 'Техкарты', route: '/operations/recipes', requiredCapability: 'production' },
{ id: 'inventory', label: 'Склад', route: '/operations/inventory', requiredCapability: 'inventory' },
{ id: 'purchasing', label: 'Закупки', route: '/operations/purchasing', requiredCapability: 'purchasing' },
{ id: 'delivery', label: 'Доставка', route: '/operations/delivery', requiredCapability: 'delivery', badge: '12' },
],
terminology: {
itemLabel: 'Позиция доставки',
itemsLabel: 'Ассортимент Dark Kitchen',
bookingResourceLabel: 'Линия сборки',
orderSourceLabel: 'Доставка / Агрегаторы',
clientIdentifierLabel: 'Получатель заказа',
},
dashboardPreset: {
primaryKpis: ['revenue_today', 'orders_count', 'avg_check', 'delivery_avg_time', 'food_cost'],
quickActionIds: ['new_order', 'assign_courier', 'create_task', 'new_client', 'inventory_check'],
},
},
retail: {
id: 'retail',
name: 'Розничная торговля / Retail',
label: 'Krona Retail & Store',
category: 'Торговля',
companyName: 'Krona Concept Store',
locations: ['Флагманский магазин — Ленина 12', 'Островок в ТЦ'],
capabilities: {
crm: true,
sales: true,
communications: true,
helpdesk: false,
tasks: true,
cms: true,
marketing: true,
analytics: true,
finance: true,
employees: true,
inventory: true,
purchasing: true,
delivery: false,
booking: false,
pos: true,
kds: false,
dining: false,
work_orders: false,
production: false,
automation: true,
ai: true,
},
operationsTabs: [
{ id: 'overview', label: 'Обзор', route: '/operations' },
{ id: 'orders', label: 'Продажи', route: '/operations/orders', badge: '31' },
{ id: 'pos', label: 'Касса', route: '/operations/pos', requiredCapability: 'pos' },
{ id: 'catalog', label: 'Товары & Штрихкоды', route: '/operations/catalog' },
{ id: 'inventory', label: 'Склад & Остатки', route: '/operations/inventory', requiredCapability: 'inventory' },
{ id: 'purchasing', label: 'Закупки', route: '/operations/purchasing', requiredCapability: 'purchasing' },
{ id: 'suppliers', label: 'Поставщики', route: '/operations/purchasing?tab=suppliers' },
{ id: 'audit', label: 'Инвентаризация', route: '/operations/inventory?tab=audit' },
{ id: 'pricing', label: 'Цены & Скидки', route: '/operations/catalog?tab=pricing' },
],
terminology: {
itemLabel: 'Товар',
itemsLabel: 'Товары и SKU',
bookingResourceLabel: 'Торговая точка',
orderSourceLabel: 'Касса / Интернет-магазин',
clientIdentifierLabel: 'Покупатель',
},
dashboardPreset: {
primaryKpis: ['revenue_today', 'orders_count', 'avg_check', 'stock_turnover', 'margin_rate'],
quickActionIds: ['pos_sale', 'stock_intake', 'new_client', 'inventory_scan', 'create_task'],
},
},
car_wash: {
id: 'car_wash',
name: 'Автомойка',
label: 'Автомойка Крона',
category: 'Автоуслуги',
companyName: 'Автомойка Крона',
locations: ['Бокс 1-6 — ул. Станционная 42'],
capabilities: {
crm: true,
sales: true,
communications: true,
helpdesk: false,
tasks: true,
cms: false,
marketing: true,
analytics: true,
finance: true,
employees: true,
inventory: true,
purchasing: true,
delivery: false,
booking: true,
pos: true,
kds: false,
dining: false,
work_orders: true,
production: false,
automation: true,
ai: true,
},
operationsTabs: [
{ id: 'overview', label: 'Обзор', route: '/operations' },
{ id: 'booking', label: 'Запись & Очередь', route: '/operations/booking', requiredCapability: 'booking', badge: '7' },
{ id: 'work_orders', label: 'Заказ-наряды', route: '/operations/work-orders', requiredCapability: 'work_orders', badge: '3' },
{ id: 'vehicles', label: 'Автомобили', route: '/crm?tab=vehicles' },
{ id: 'pos', label: 'Касса & Оплата', route: '/operations/pos', requiredCapability: 'pos' },
{ id: 'bays', label: 'Боксы', route: '/operations/bays' },
{ id: 'catalog', label: 'Услуги & Прайс', route: '/operations/catalog' },
{ id: 'inventory', label: 'Материалы & Склад', route: '/operations/inventory', requiredCapability: 'inventory' },
],
terminology: {
itemLabel: 'Услуга',
itemsLabel: 'Услуги',
bookingResourceLabel: 'Бокс',
orderSourceLabel: 'Прямой заезд / Запись',
clientIdentifierLabel: 'Владелец (Госномер)',
},
dashboardPreset: {
primaryKpis: ['revenue_today', 'bay_load', 'avg_ticket', 'repeat_rate'],
quickActionIds: ['new_booking', 'new_vehicle', 'new_payment', 'create_task'],
},
},
beauty_services: {
id: 'beauty_services',
name: 'Салон красоты / Бьюти',
label: 'Студия Красоты Крона Эстетик',
category: 'Сфера услуг',
companyName: 'Студия Красоты Крона',
locations: ['Центр — Советская 25'],
capabilities: {
crm: true,
sales: true,
communications: true,
helpdesk: false,
tasks: true,
cms: true,
marketing: true,
analytics: true,
finance: true,
employees: true,
inventory: true,
purchasing: true,
delivery: false,
booking: true,
pos: true,
kds: false,
dining: false,
work_orders: false,
production: false,
automation: true,
ai: true,
},
operationsTabs: [
{ id: 'overview', label: 'Обзор', route: '/operations' },
{ id: 'booking', label: 'Запись клиентов', route: '/operations/booking', requiredCapability: 'booking', badge: '11' },
{ id: 'schedule', label: 'Расписание мастеров', route: '/employees?tab=schedule' },
{ id: 'pos', label: 'Касса & Визиты', route: '/operations/pos', requiredCapability: 'pos' },
{ id: 'masters', label: 'Специалисты', route: '/employees' },
{ id: 'catalog', label: 'Услуги салона', route: '/operations/catalog' },
{ id: 'resources', label: 'Рабочие места', route: '/operations/bays' },
{ id: 'inventory', label: 'Расходники', route: '/operations/inventory', requiredCapability: 'inventory' },
],
terminology: {
itemLabel: 'Услуга мастера',
itemsLabel: 'Услуги и уходовая косметика',
bookingResourceLabel: 'Мастер / Кресло',
orderSourceLabel: 'Онлайн-запись / Стойка',
clientIdentifierLabel: 'Клиент салона',
},
dashboardPreset: {
primaryKpis: ['revenue_today', 'appointments_today', 'master_load', 'avg_check', 'retention_rate'],
quickActionIds: ['new_booking', 'new_client', 'pos_sale', 'chat_message', 'create_task'],
},
},
wholesale_b2b: {
id: 'wholesale_b2b',
name: 'Оптовая торговля / B2B',
label: 'Торговый Дом Крона Опт',
category: 'Опт & Дистрибуция',
companyName: 'Торговый Дом Крона Опт',
locations: ['Центральный распределительный склад — Северный проезд 8'],
capabilities: {
crm: true,
sales: true,
communications: true,
helpdesk: true,
tasks: true,
cms: false,
marketing: true,
analytics: true,
finance: true,
employees: true,
inventory: true,
purchasing: true,
delivery: true,
booking: false,
pos: false,
kds: false,
dining: false,
work_orders: false,
production: false,
automation: true,
ai: true,
},
operationsTabs: [
{ id: 'overview', label: 'Обзор', route: '/operations' },
{ id: 'orders', label: 'B2B Заказы & Спецификации', route: '/operations/orders', badge: '18' },
{ id: 'companies', label: 'Компании & Договоры', route: '/crm?tab=companies' },
{ id: 'inventory', label: 'Оптовый склад & Партии', route: '/operations/inventory', requiredCapability: 'inventory' },
{ id: 'purchasing', label: 'Контракты & Закупки', route: '/operations/purchasing', requiredCapability: 'purchasing' },
{ id: 'suppliers', label: 'Поставщики', route: '/operations/purchasing?tab=suppliers' },
{ id: 'documents', label: 'УПД & Документы', route: '/finance?tab=docs' },
{ id: 'delivery', label: 'Логистика & Отгрузки', route: '/operations/delivery', requiredCapability: 'delivery' },
],
terminology: {
itemLabel: 'Товарная позиция',
itemsLabel: 'Номенклатурный каталог',
bookingResourceLabel: 'Складская зона',
orderSourceLabel: 'B2B Договор / EDI',
clientIdentifierLabel: 'Контрагент / ИНН',
},
dashboardPreset: {
primaryKpis: ['revenue_month', 'active_contracts', 'receivables', 'warehouse_capacity', 'b2b_avg_deal'],
quickActionIds: ['new_deal', 'new_contract', 'stock_receipt', 'create_invoice', 'create_task'],
},
},
auto_service: {
id: 'auto_service',
name: 'Автосервис / СТО',
label: 'СТО Крона',
category: 'Автоуслуги',
companyName: 'СТО Крона',
locations: ['Главный сервис'],
capabilities: {
crm: true,
sales: true,
communications: true,
helpdesk: false,
tasks: true,
cms: false,
marketing: true,
analytics: true,
finance: true,
employees: true,
inventory: true,
purchasing: true,
delivery: false,
booking: true,
pos: true,
kds: false,
dining: false,
work_orders: true,
production: false,
automation: true,
ai: true,
},
operationsTabs: [
{ id: 'overview', label: 'Обзор', route: '/operations' },
{ id: 'booking', label: 'Запись & Очередь', route: '/operations/booking', requiredCapability: 'booking', badge: '7' },
{ id: 'vehicles', label: 'Автомобили', route: '/crm?tab=vehicles' },
{ id: 'work_orders', label: 'Заказ-наряды', route: '/operations/work-orders', requiredCapability: 'work_orders', badge: '4' },
{ id: 'pos', label: 'Касса & Оплата', route: '/operations/pos', requiredCapability: 'pos' },
{ id: 'bays', label: 'Посты & Подъёмники', route: '/operations/bays' },
{ id: 'catalog', label: 'Услуги & Прайс', route: '/operations/catalog' },
{ id: 'inventory', label: 'Запчасти & Склад', route: '/operations/inventory', requiredCapability: 'inventory' },
],
terminology: {
itemLabel: 'Услуга / Деталь',
itemsLabel: 'Работы и запчасти',
bookingResourceLabel: 'Подъёмник',
orderSourceLabel: 'Заказ-наряд',
clientIdentifierLabel: 'Владелец ТС (Госномер)',
},
dashboardPreset: {
primaryKpis: ['revenue_today', 'work_orders_active', 'bay_load', 'avg_ticket', 'repeat_rate'],
quickActionIds: ['new_booking', 'new_work_order', 'new_vehicle', 'new_payment', 'create_task'],
},
},
fitness: {
id: 'fitness',
name: 'Фитнес-клуб',
label: 'Крона Фитнес',
category: 'Спорт и здоровье',
companyName: 'Крона Фитнес',
locations: ['Клуб на Ленина'],
capabilities: {
crm: true,
sales: true,
communications: true,
helpdesk: true,
tasks: true,
cms: true,
marketing: true,
analytics: true,
finance: true,
employees: true,
inventory: true,
purchasing: true,
delivery: false,
booking: true,
pos: true,
kds: false,
dining: false,
work_orders: false,
production: false,
automation: true,
ai: true,
},
operationsTabs: [
{ id: 'overview', label: 'Обзор', route: '/operations' },
{ id: 'booking', label: 'Расписание & Запись', route: '/operations/booking', requiredCapability: 'booking' },
{ id: 'pos', label: 'Касса & Ресепшн', route: '/operations/pos', requiredCapability: 'pos' },
{ id: 'trainers', label: 'Тренеры', route: '/employees' },
{ id: 'catalog', label: 'Абонементы & Услуги', route: '/operations/catalog' },
{ id: 'inventory', label: 'Склад (Бар/Мерч)', route: '/operations/inventory', requiredCapability: 'inventory' },
],
terminology: {
itemLabel: 'Абонемент / Тренировка',
itemsLabel: 'Прайс-лист',
bookingResourceLabel: 'Зал / Тренер',
orderSourceLabel: 'Онлайн / Ресепшн',
clientIdentifierLabel: 'Член клуба',
},
dashboardPreset: {
primaryKpis: ['revenue_today', 'active_memberships', 'visits_today', 'avg_check'],
quickActionIds: ['new_booking', 'new_client', 'pos_sale', 'create_task'],
},
},
clinic: {
id: 'clinic',
name: 'Медицинская клиника / Стоматология',
label: 'Крона Мед',
category: 'Здоровье',
companyName: 'Крона Мед',
locations: ['Главный филиал'],
capabilities: {
crm: true,
sales: true,
communications: true,
helpdesk: true,
tasks: true,
cms: true,
marketing: true,
analytics: true,
finance: true,
employees: true,
inventory: true,
purchasing: true,
delivery: false,
booking: true,
pos: true,
kds: false,
dining: false,
work_orders: false,
production: false,
automation: true,
ai: true,
},
operationsTabs: [
{ id: 'overview', label: 'Обзор', route: '/operations' },
{ id: 'booking', label: 'Приемы & Расписание', route: '/operations/booking', requiredCapability: 'booking' },
{ id: 'pos', label: 'Касса', route: '/operations/pos', requiredCapability: 'pos' },
{ id: 'doctors', label: 'Врачи', route: '/employees' },
{ id: 'catalog', label: 'Услуги', route: '/operations/catalog' },
{ id: 'inventory', label: 'Медикаменты & Расходники', route: '/operations/inventory', requiredCapability: 'inventory' },
],
terminology: {
itemLabel: 'Услуга',
itemsLabel: 'Услуги',
bookingResourceLabel: 'Врач / Кабинет',
orderSourceLabel: 'Запись',
clientIdentifierLabel: 'Пациент',
},
dashboardPreset: {
primaryKpis: ['revenue_today', 'visits_today', 'avg_check'],
quickActionIds: ['new_booking', 'new_client', 'pos_sale', 'create_task'],
},
},
coworking: {
id: 'coworking',
name: 'Коворкинг / Аренда пространств',
label: 'Крона Коворкинг',
category: 'Недвижимость',
companyName: 'Крона Коворкинг',
locations: ['Кампус'],
capabilities: {
crm: true,
sales: true,
communications: true,
helpdesk: true,
tasks: true,
cms: true,
marketing: true,
analytics: true,
finance: true,
employees: true,
inventory: true,
purchasing: true,
delivery: false,
booking: true,
pos: true,
kds: false,
dining: false,
work_orders: false,
production: false,
automation: true,
ai: true,
},
operationsTabs: [
{ id: 'overview', label: 'Обзор', route: '/operations' },
{ id: 'booking', label: 'Бронирование', route: '/operations/booking', requiredCapability: 'booking' },
{ id: 'pos', label: 'Оплата', route: '/operations/pos', requiredCapability: 'pos' },
{ id: 'catalog', label: 'Тарифы', route: '/operations/catalog' },
{ id: 'inventory', label: 'Склад', route: '/operations/inventory', requiredCapability: 'inventory' },
],
terminology: {
itemLabel: 'Тариф / Опция',
itemsLabel: 'Тарифы',
bookingResourceLabel: 'Переговорная / Стол',
orderSourceLabel: 'Онлайн / Ресепшн',
clientIdentifierLabel: 'Резидент',
},
dashboardPreset: {
primaryKpis: ['revenue_today', 'occupancy_rate', 'active_memberships'],
quickActionIds: ['new_booking', 'new_client', 'pos_sale', 'create_task'],
},
},
logistics: {
id: 'logistics',
name: 'Логистика / Курьерская служба',
label: 'Крона Доставка',
category: 'Логистика',
companyName: 'Крона Доставка',
locations: ['Сортировочный центр'],
capabilities: {
crm: true,
sales: true,
communications: true,
helpdesk: true,
tasks: true,
cms: true,
marketing: true,
analytics: true,
finance: true,
employees: true,
inventory: false,
purchasing: false,
delivery: true,
booking: false,
pos: true,
kds: false,
dining: false,
work_orders: false,
production: false,
automation: true,
ai: true,
},
operationsTabs: [
{ id: 'overview', label: 'Обзор', route: '/operations' },
{ id: 'delivery', label: 'Заказы & Маршруты', route: '/operations/delivery', requiredCapability: 'delivery' },
{ id: 'pos', label: 'Оплата', route: '/operations/pos', requiredCapability: 'pos' },
],
terminology: {
itemLabel: 'Услуга доставки',
itemsLabel: 'Тарифы',
bookingResourceLabel: 'Курьер / Машина',
orderSourceLabel: 'Заказ',
clientIdentifierLabel: 'Отправитель / Получатель',
},
dashboardPreset: {
primaryKpis: ['orders_today', 'avg_delivery_time', 'success_rate'],
quickActionIds: ['new_order', 'assign_courier', 'create_task'],
},
},
custom_meta: {
id: 'custom_meta',
name: 'Кастомный профиль / Meta-profile',
label: 'Универсальный Business OS (Все модули)',
category: 'Конфигурируемый',
companyName: 'Бизнес-экосистема Крона',
locations: ['Главный офис', 'Локация 2'],
capabilities: {
crm: true,
sales: true,
communications: true,
helpdesk: true,
tasks: true,
cms: true,
marketing: true,
analytics: true,
finance: true,
employees: true,
inventory: true,
purchasing: true,
delivery: true,
booking: true,
pos: true,
kds: true,
dining: true,
work_orders: true,
production: true,
automation: true,
ai: true,
},
operationsTabs: [
{ id: 'overview', label: 'Обзор', route: '/operations' },
{ id: 'orders', label: 'Заказы', route: '/operations/orders' },
{ id: 'pos', label: 'Касса (POS)', route: '/operations/pos' },
{ id: 'dining', label: 'Зал', route: '/operations/dining' },
{ id: 'kds', label: 'KDS', route: '/operations/kds' },
{ id: 'work_orders', label: 'Заказ-наряды', route: '/operations/work-orders' },
{ id: 'catalog', label: 'Каталог', route: '/operations/catalog' },
{ id: 'inventory', label: 'Склад', route: '/operations/inventory' },
{ id: 'purchasing', label: 'Закупки', route: '/operations/purchasing' },
{ id: 'delivery', label: 'Доставка', route: '/operations/delivery' },
{ id: 'booking', label: 'Бронирования', route: '/operations/booking' },
],
terminology: {
itemLabel: 'Единица продукции',
itemsLabel: 'Каталог сущностей',
bookingResourceLabel: 'Бизнес-ресурс',
orderSourceLabel: 'Канал продаж',
clientIdentifierLabel: 'Клиент / Субъект',
},
dashboardPreset: {
primaryKpis: ['revenue_today', 'orders_count', 'avg_check', 'tasks_open', 'ai_insights'],
quickActionIds: ['new_order', 'new_client', 'create_task', 'new_expense', 'chat_message'],
},
},
};

View file

@ -0,0 +1,31 @@
import { CapabilityId } from '../capabilities/types';
export interface OperationsTabConfig {
id: string;
label: string;
route: string;
badge?: string;
requiredCapability?: CapabilityId;
}
export interface BusinessProfile {
id: string;
name: string;
label: string;
category: string;
companyName: string;
locations: string[];
capabilities: Record<CapabilityId, boolean>;
operationsTabs: OperationsTabConfig[];
terminology: {
itemLabel: string;
itemsLabel: string;
bookingResourceLabel: string;
orderSourceLabel: string;
clientIdentifierLabel: string;
};
dashboardPreset: {
primaryKpis: string[];
quickActionIds: string[];
};
}

View file

@ -0,0 +1,182 @@
import React, { useState } from 'react';
import { Badge } from '../../shared/components/Badge';
import { Button } from '../../shared/components/Button';
import { Sparkles, Bot, Brain, Send, Cpu, CheckCircle2, Shield, Activity } from 'lucide-react';
interface AiAgent {
id: string;
name: string;
role: string;
description: string;
tools: string[];
status: 'active' | 'busy' | 'idle';
tasksCompleted: number;
}
const AI_AGENTS: AiAgent[] = [
{
id: 'orch',
name: 'Главный Оркестратор',
role: 'Orchestrator',
description: 'Декомпозирует глобальные бизнес-цели и распределяет задачи между агентами-специалистами',
tools: ['Task Delegator', 'Cross-module Context', 'Guardian Policy'],
status: 'active',
tasksCompleted: 342,
},
{
id: 'analyst',
name: 'Бизнес-аналитик',
role: 'BI & Financial Analyst',
description: 'Анализирует чеки, food cost, маржинальность, динамику продаж и выявляет аномалии',
tools: ['SQL Query', 'Revenue Forecast', 'ABC Matrix', 'Shift Audit'],
status: 'active',
tasksCompleted: 819,
},
{
id: 'marketer',
name: 'Бизнес-маркетолог',
role: 'Growth & Retention',
description: 'Генерирует контент-планы, офферы для рассылок, анализирует сегменты клиентов в зоне риска',
tools: ['Promo Engine', 'Telegram Bot Sender', 'Copywriter', 'Cohort Analysis'],
status: 'active',
tasksCompleted: 420,
},
{
id: 'warehouse_agent',
name: 'Складской су-шеф',
role: 'Supply Chain Agent',
description: 'Мониторит критические остатки, сроки годности и формирует авто-заказы поставщикам',
tools: ['Supplier Auto-Order', 'Waste Tracker', 'Inventory Audit'],
status: 'active',
tasksCompleted: 612,
},
];
export const AiCenterModule: React.FC = () => {
const [activeAgentId, setActiveAgentId] = useState<string>('orch');
const [chatMessages, setChatMessages] = useState<{ sender: 'user' | 'ai'; text: string; time: string }[]>([
{
sender: 'ai',
text: 'Здравствуйте! Я Главный Оркестратор Кроны. Моя команда специалистов (Аналитик, Маркетолог, Склад) готова помочь вам оптимизировать операционные процессы, снизить списания и поднять средний чек.',
time: '12:00',
},
]);
const [prompt, setPrompt] = useState('');
const handleSend = (e: React.FormEvent) => {
e.preventDefault();
if (!prompt.trim()) return;
const userText = prompt;
setChatMessages((prev) => [
...prev,
{ sender: 'user', text: userText, time: 'Только что' },
]);
setPrompt('');
setTimeout(() => {
setChatMessages((prev) => [
...prev,
{
sender: 'ai',
text: `Агент «${AI_AGENTS.find(a => a.id === activeAgentId)?.name}»: Анализ запроса «${userText}» завершен. Сформирован проект решения в задачах. Выручка и себестоимость учтены в сводном отчете.`,
time: 'Только что',
},
]);
}, 450);
};
return (
<div className="space-y-4 animate-fade-in">
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3 bg-krona-surface p-3.5 rounded-xl border border-krona-border shadow-krona-soft">
<div>
<div className="flex items-center gap-2">
<h2 className="text-sm font-bold text-krona-primary">
✦ Krona AI Center & Автономная бизнес-команда
</h2>
<Badge variant="gold" size="sm">Крона Модель v1.8 Flash</Badge>
</div>
<p className="text-xs text-krona-secondary mt-0.5">
Специализированные агенты с прямым доступом к инструментам платформы и памяти заведения
</p>
</div>
<div className="flex items-center gap-2">
<Badge variant="green" size="md">Токены: 48 200 / 100 000</Badge>
</div>
</div>
{/* Agents Selection Cards */}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-3.5">
{AI_AGENTS.map((agent) => (
<div
key={agent.id}
onClick={() => setActiveAgentId(agent.id)}
className={`p-4 rounded-xl border cursor-pointer transition-all shadow-krona-soft flex flex-col justify-between space-y-2.5 ${
agent.id === activeAgentId
? 'border-krona-gold bg-krona-gold/10 ring-2 ring-krona-gold/20'
: 'border-krona-border bg-krona-surface hover:border-krona-border-strong'
}`}
>
<div>
<div className="flex items-center justify-between mb-1.5">
<span className="text-[10px] uppercase font-bold text-krona-secondary">{agent.role}</span>
<span className="w-2 h-2 rounded-full bg-krona-green animate-pulse"></span>
</div>
<h4 className="text-xs font-bold text-krona-primary">{agent.name}</h4>
<p className="text-[11px] text-krona-secondary line-clamp-2 mt-1">{agent.description}</p>
</div>
<div className="pt-2 border-t border-krona-border/50 text-[10px] text-krona-secondary flex items-center justify-between">
<span>Инструментов: {agent.tools.length}</span>
<span className="font-semibold text-krona-primary">{agent.tasksCompleted} задач</span>
</div>
</div>
))}
</div>
{/* Interactive AI Dialog Box */}
<div className="bg-krona-surface border border-krona-border rounded-xl p-4 shadow-krona-soft space-y-3">
<div className="flex items-center justify-between pb-2 border-b border-krona-border">
<div className="flex items-center gap-2">
<Bot className="w-4 h-4 text-krona-gold" />
<span className="text-xs font-bold text-krona-primary">
Диалог с агентом: {AI_AGENTS.find(a => a.id === activeAgentId)?.name}
</span>
</div>
<Badge variant="gold" size="sm">Контекст синхронизирован</Badge>
</div>
<div className="h-64 overflow-y-auto space-y-2.5 p-2 text-xs">
{chatMessages.map((m, i) => (
<div
key={i}
className={`p-3 rounded-xl max-w-xl ${
m.sender === 'user'
? 'ml-auto bg-[#2F684C] text-white rounded-br-none'
: 'mr-auto bg-krona-workspace border border-krona-border text-krona-primary rounded-bl-none shadow-krona-soft'
}`}
>
<p className="leading-relaxed">{m.text}</p>
<span className="block text-[9px] mt-1 opacity-70 text-right">{m.time}</span>
</div>
))}
</div>
<form onSubmit={handleSend} className="flex gap-2 pt-2 border-t border-krona-border">
<input
type="text"
value={prompt}
onChange={(e) => setPrompt(e.target.value)}
placeholder="Сформулируйте задачу для агента (например: 'Оптимизируй закупку лосося')..."
className="flex-1 bg-krona-workspace border border-krona-border rounded-lg px-3 py-2 text-xs text-krona-primary placeholder:text-krona-secondary focus:outline-none focus:border-krona-gold"
/>
<Button type="submit" variant="gold" size="sm">
<Send className="w-3.5 h-3.5 mr-1" />
Отправить в работу
</Button>
</form>
</div>
</div>
);
};

View file

@ -0,0 +1,152 @@
import React from 'react';
import { StatCard } from '../../shared/components/StatCard';
import { Badge } from '../../shared/components/Badge';
import { formatCurrency } from '../../shared/utils/formatters';
import {
BarChart,
Bar,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
ResponsiveContainer,
PieChart,
Pie,
Cell,
} from 'recharts';
import { BarChart3, TrendingUp, DollarSign, Users, Award } from 'lucide-react';
const SALES_CHANNEL_DATA = [
{ name: 'Зал ресторана', value: 48, color: '#2F684C' },
{ name: 'Собственная доставка', value: 26, color: '#B78335' },
{ name: 'Telegram-бот', value: 14, color: '#527286' },
{ name: 'Яндекс Еда / Агрегаторы', value: 12, color: '#B16D50' },
];
const ABC_ITEMS_DATA = [
{ name: 'Филадельфия', revenue: 420000, category: 'A' },
{ name: 'Том Ям', revenue: 310000, category: 'A' },
{ name: 'Калифорния', revenue: 265000, category: 'A' },
{ name: 'Рамен Тонкоцу', revenue: 195000, category: 'B' },
{ name: 'Гёдза', revenue: 160000, category: 'B' },
{ name: 'Эби Темпура', revenue: 84000, category: 'C' },
];
export const AnalyticsModule: React.FC = () => {
return (
<div className="space-y-4 animate-fade-in">
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3 bg-krona-surface p-3.5 rounded-xl border border-krona-border shadow-krona-soft">
<div>
<h2 className="text-sm font-bold text-krona-primary">
Сквозная бизнес-аналитика & BI
</h2>
<p className="text-xs text-krona-secondary">
Анализ маржинальности, ABC-классификация блюд, когорты и LTV
</p>
</div>
<div className="flex items-center gap-2">
<Badge variant="green" size="md">Отчетный период: Сентябрь 2026</Badge>
</div>
</div>
<div className="grid grid-cols-1 sm:grid-cols-4 gap-3.5">
<StatCard
title="Месячная выручка"
value={formatCurrency(4120000)}
change={{ value: '+18.5%', trend: 'up' }}
subtitle="Прогноз: 5.2M ₽"
icon={<DollarSign className="w-4 h-4 text-krona-green" />}
/>
<StatCard
title="Валовая рентабельность"
value="70.6%"
change={{ value: '+1.2%', trend: 'up' }}
subtitle="Food cost: 29.4%"
icon={<TrendingUp className="w-4 h-4 text-krona-green" />}
/>
<StatCard
title="Средний чек по сети"
value={formatCurrency(3180)}
change={{ value: '+210 ₽', trend: 'up' }}
subtitle="1 295 заказов"
icon={<BarChart3 className="w-4 h-4 text-krona-gold" />}
/>
<StatCard
title="Коэффициент удержания (CRR)"
value="68.4%"
change={{ value: '+4.0%', trend: 'up' }}
subtitle="Повторные покупки"
icon={<Users className="w-4 h-4 text-krona-info" />}
/>
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
{/* Sales by channel pie chart */}
<div className="bg-krona-surface border border-krona-border rounded-xl p-4 shadow-krona-soft space-y-3">
<h4 className="text-xs font-bold text-krona-primary">Структура выручки по каналам продаж</h4>
<div className="h-60 w-full flex items-center justify-center">
<ResponsiveContainer width="100%" height="100%">
<PieChart>
<Pie
data={SALES_CHANNEL_DATA}
innerRadius={50}
outerRadius={80}
paddingAngle={4}
dataKey="value"
>
{SALES_CHANNEL_DATA.map((entry, index) => (
<Cell key={`cell-${index}`} fill={entry.color} />
))}
</Pie>
<Tooltip
formatter={(val: any) => [`${val}%`, 'Доля']}
contentStyle={{ backgroundColor: '#F8F6F0', borderRadius: 8, fontSize: 12 }}
/>
</PieChart>
</ResponsiveContainer>
</div>
<div className="grid grid-cols-2 gap-2 text-xs pt-1 border-t border-krona-border/50">
{SALES_CHANNEL_DATA.map((ch, idx) => (
<div key={idx} className="flex items-center justify-between">
<div className="flex items-center gap-1.5">
<span className="w-2.5 h-2.5 rounded-full" style={{ backgroundColor: ch.color }} />
<span className="text-krona-secondary text-[11px]">{ch.name}</span>
</div>
<span className="font-bold text-krona-primary">{ch.value}%</span>
</div>
))}
</div>
</div>
{/* ABC Analysis Bar chart */}
<div className="bg-krona-surface border border-krona-border rounded-xl p-4 shadow-krona-soft space-y-3">
<div className="flex items-center justify-between">
<h4 className="text-xs font-bold text-krona-primary">ABC анализ позиций меню</h4>
<Badge variant="gold" size="sm">Группа A = 80% выручки</Badge>
</div>
<div className="h-60 w-full">
<ResponsiveContainer width="100%" height="100%">
<BarChart data={ABC_ITEMS_DATA} layout="vertical" margin={{ left: 20 }}>
<CartesianGrid strokeDasharray="3 3" opacity={0.3} />
<XAxis type="number" tickFormatter={(v) => `${v / 1000}k`} fontSize={11} />
<YAxis type="category" dataKey="name" fontSize={11} width={90} />
<Tooltip
formatter={(val: any) => [formatCurrency(Number(val)), 'Выручка']}
contentStyle={{ backgroundColor: '#F8F6F0', borderRadius: 8, fontSize: 12 }}
/>
<Bar dataKey="revenue" fill="#2F684C" radius={[0, 4, 4, 0]} />
</BarChart>
</ResponsiveContainer>
</div>
<div className="text-[11px] text-krona-secondary">
Позиции группы А формируют основную прибыль заведения. Контролируйте стоп-лист и остатки лосося и сыра ежедневно.
</div>
</div>
</div>
</div>
);
};

View file

@ -0,0 +1,64 @@
import React from 'react';
import { Badge } from '../../shared/components/Badge';
import { Button } from '../../shared/components/Button';
import { CAPABILITIES_LIST } from '../../core/capabilities/types';
import { useDemoStore } from '../../mock/store';
import { Grid, Check, Sparkles, Plus } from 'lucide-react';
export const AppsCatalogModule: React.FC = () => {
const { customCapabilities, toggleCapability } = useDemoStore();
return (
<div className="space-y-4 animate-fade-in">
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3 bg-krona-surface p-3.5 rounded-xl border border-krona-border shadow-krona-soft">
<div>
<h2 className="text-sm font-bold text-krona-primary">
Каталог приложений & Модульная экосистема Кроны
</h2>
<p className="text-xs text-krona-secondary">
Подключайте и отключайте функциональные блоки для адаптации платформы под новые ниши
</p>
</div>
<Badge variant="gold" size="md">Всего модулей: {CAPABILITIES_LIST.length}</Badge>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3.5">
{CAPABILITIES_LIST.map((app) => {
const isEnabled = customCapabilities[app.id];
return (
<div
key={app.id}
className="p-4 rounded-xl border border-krona-border bg-krona-surface shadow-krona-soft flex flex-col justify-between space-y-3"
>
<div>
<div className="flex items-center justify-between mb-1.5">
<span className="text-[10px] uppercase font-bold text-krona-secondary">{app.category}</span>
<Badge variant={isEnabled ? 'green' : 'neutral'} size="sm">
{isEnabled ? 'Активен' : 'Отключен'}
</Badge>
</div>
<h4 className="text-xs font-bold text-krona-primary">{app.name}</h4>
<p className="text-[11px] text-krona-secondary mt-1 leading-relaxed">
{app.description}
</p>
</div>
<div className="pt-2 border-t border-krona-border/50 flex justify-end">
<Button
variant={isEnabled ? 'secondary' : 'primary'}
size="sm"
className="w-full text-xs"
onClick={() => toggleCapability(app.id)}
>
{isEnabled ? 'Отключить модуль' : 'Включить в контур'}
</Button>
</div>
</div>
);
})}
</div>
</div>
);
};

View file

@ -0,0 +1,109 @@
import React from 'react';
import { Badge } from '../../shared/components/Badge';
import { Button } from '../../shared/components/Button';
import { Cpu, ArrowDown, Play, Sparkles, MessageSquare, UserCheck, CheckSquare, Plus } from 'lucide-react';
interface WorkflowNode {
type: 'trigger' | 'condition' | 'ai' | 'action';
title: string;
description: string;
badge: string;
badgeVariant: 'green' | 'gold' | 'info' | 'neutral';
}
const WORKFLOW_CHAIN: WorkflowNode[] = [
{
type: 'trigger',
title: 'Триггер: Новое входящее сообщение в Telegram',
description: 'Срабатывает мгновенно при обращении гостя в бота заведения',
badge: 'Событие',
badgeVariant: 'gold',
},
{
type: 'condition',
title: 'Условие: Поиск клиента в CRM платформы',
description: 'Проверка номера телефона и истории заказов: клиент найден или новый',
badge: 'CRM Match',
badgeVariant: 'neutral',
},
{
type: 'ai',
title: '✦ Krona AI: Определение намерения гостя (Intent Analysis)',
description: 'Классификация: бронь столика / жалоба на доставку / повтор заказа',
badge: 'ИИ-анализ',
badgeVariant: 'gold',
},
{
type: 'action',
title: 'Действие: Создание сделки и назначение менеджера',
description: 'Открытие карточки сделки в воронке со статусом «Бронирование стола»',
badge: 'Действие',
badgeVariant: 'green',
},
{
type: 'action',
title: 'Действие: Отправка подтверждения гостю',
description: 'Формирование персонализированного ответа с кнопкой подтверждения слота',
badge: 'Telegram Reply',
badgeVariant: 'info',
},
];
export const AutomationModule: React.FC = () => {
return (
<div className="space-y-4 animate-fade-in">
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3 bg-krona-surface p-3.5 rounded-xl border border-krona-border shadow-krona-soft">
<div>
<h2 className="text-sm font-bold text-krona-primary">
Автоматизация & Visual Workflow Engine
</h2>
<p className="text-xs text-krona-secondary">
Бескодовые сценарии автоматизации рутинных процессов, триггеры и цепочки действий с AI
</p>
</div>
<div className="flex items-center gap-2">
<Button variant="primary" size="sm">
<Plus className="w-3.5 h-3.5 mr-1" />
Создать сценарий
</Button>
</div>
</div>
<div className="bg-krona-surface border border-krona-border rounded-xl p-5 shadow-krona-soft space-y-4">
<div className="flex items-center justify-between pb-3 border-b border-krona-border">
<div>
<h4 className="text-xs font-bold text-krona-primary">
Сценарий: «Автоматическая обработка брони стола из Telegram»
</h4>
<span className="text-[11px] text-krona-secondary">Сработало 148 раз за неделю • Успешность 100%</span>
</div>
<Badge variant="green" size="sm">Активен (On)</Badge>
</div>
{/* Visual Flow nodes chain */}
<div className="max-w-xl mx-auto py-2 flex flex-col items-center space-y-2">
{WORKFLOW_CHAIN.map((node, i) => (
<React.Fragment key={i}>
<div className="w-full p-3.5 rounded-xl border border-krona-border bg-krona-workspace shadow-krona-soft flex items-start justify-between gap-3">
<div>
<div className="flex items-center gap-2 mb-1">
<span className="text-xs font-bold text-krona-primary">{node.title}</span>
</div>
<p className="text-[11px] text-krona-secondary leading-snug">{node.description}</p>
</div>
<Badge variant={node.badgeVariant} size="sm">{node.badge}</Badge>
</div>
{i < WORKFLOW_CHAIN.length - 1 && (
<div className="flex flex-col items-center my-0.5 text-krona-gold">
<ArrowDown className="w-4 h-4" />
</div>
)}
</React.Fragment>
))}
</div>
</div>
</div>
);
};

View file

@ -0,0 +1,357 @@
import React, { useState, useEffect } from 'react';
import { useLocation, useNavigate } from 'react-router-dom';
import { Badge } from '../../shared/components/Badge';
import { Button } from '../../shared/components/Button';
import { Input } from '../../shared/components/Input';
import {
Globe,
Layout,
Smartphone,
Monitor,
CheckCircle,
ExternalLink,
Plus,
Edit3,
Image,
Search,
Check,
ShieldCheck,
Server,
FileCode,
} from 'lucide-react';
interface CmsPage {
id: string;
title: string;
slug: string;
viewsToday: number;
status: 'published' | 'draft';
lastEdited: string;
}
const PAGES_LIST: CmsPage[] = [
{ id: 'p1', title: 'Главная страница доставки', slug: '/', viewsToday: 1420, status: 'published', lastEdited: 'Вчера, 18:40' },
{ id: 'p2', title: 'Меню & Корзина онлайн', slug: '/menu', viewsToday: 3890, status: 'published', lastEdited: '08.09.2026' },
{ id: 'p3', title: 'Акции и Сеты сентября', slug: '/promo-autumn', viewsToday: 840, status: 'published', lastEdited: '09.09.2026' },
{ id: 'p4', title: 'Бронирование столов / запись', slug: '/booking', viewsToday: 410, status: 'published', lastEdited: '05.09.2026' },
{ id: 'p5', title: 'Контакты и точки в городе', slug: '/contacts', viewsToday: 230, status: 'published', lastEdited: '01.09.2026' },
];
export const CmsModule: React.FC = () => {
const location = useLocation();
const navigate = useNavigate();
const [pages, setPages] = useState<CmsPage[]>(PAGES_LIST);
const [activeTab, setActiveTab] = useState<'pages' | 'builder' | 'forms' | 'media' | 'seo' | 'domains'>('pages');
// Sync with topbar route
useEffect(() => {
const p = location.pathname;
if (p.includes('/cms/pages')) setActiveTab('pages');
else if (p.includes('/cms/forms')) setActiveTab('forms');
else if (p.includes('/cms/media')) setActiveTab('media');
else if (p.includes('/cms/seo')) setActiveTab('seo');
else if (p.includes('/cms/domains')) setActiveTab('domains');
}, [location.pathname]);
// SEO Form Mock State
const [seoTitle, setSeoTitle] = useState('Суши & Гёдза — Доставка роллов и паназиатской кухни в Новосибирске');
const [seoDescription, setSeoDescription] = useState('Свежие суши, роллы, воки и супы с быстрой доставкой за 35 минут. Заказывайте любимые сеты онлайн со скидкой 20%!');
return (
<div className="space-y-4 animate-fade-in">
{/* Header */}
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3 bg-krona-surface p-3.5 rounded-xl border border-krona-border shadow-krona-soft">
<div>
<div className="flex items-center gap-2">
<h2 className="text-sm font-bold text-krona-primary">Сайты & Krona CMS</h2>
<Badge variant="gold" size="sm">krona.site/sushi-nsk</Badge>
</div>
<p className="text-xs text-krona-secondary mt-0.5">
Конструктор страниц, лид-формы, медиатека, SEO и управление доменами
</p>
</div>
<div className="flex items-center gap-1.5 overflow-x-auto text-xs">
{[
{ id: 'pages', label: 'Страницы', route: '/cms/pages' },
{ id: 'builder', label: 'Конструктор' },
{ id: 'forms', label: 'Формы', route: '/cms/forms' },
{ id: 'media', label: 'Медиа', route: '/cms/media' },
{ id: 'seo', label: 'SEO', route: '/cms/seo' },
{ id: 'domains', label: 'Домены', route: '/cms/domains' },
].map((tab) => (
<button
key={tab.id}
onClick={() => {
setActiveTab(tab.id as any);
if (tab.route) navigate(tab.route);
}}
className={`px-3 py-1.5 rounded-md font-medium transition-colors whitespace-nowrap ${
activeTab === tab.id
? 'bg-[#2F684C] text-white shadow-sm'
: 'bg-krona-workspace text-krona-secondary hover:text-krona-primary border border-krona-border'
}`}
>
{tab.label}
</button>
))}
</div>
</div>
{/* 1. PAGES TAB */}
{activeTab === 'pages' && (
<div className="bg-krona-surface border border-krona-border rounded-xl shadow-krona-soft overflow-hidden">
<table className="w-full text-left text-xs border-collapse">
<thead className="bg-krona-workspace/80 text-krona-secondary border-b border-krona-border">
<tr>
<th className="p-3 font-semibold">Название страницы</th>
<th className="p-3 font-semibold">URL / Slug</th>
<th className="p-3 font-semibold">Просмотров сегодня</th>
<th className="p-3 font-semibold">Дата редакции</th>
<th className="p-3 font-semibold text-center">Статус</th>
<th className="p-3 font-semibold text-right">Действие</th>
</tr>
</thead>
<tbody className="divide-y divide-krona-border/50">
{pages.map((p) => (
<tr key={p.id} className="hover:bg-krona-workspace/50 transition-colors">
<td className="p-3 font-medium text-krona-primary flex items-center gap-2">
<Globe className="w-4 h-4 text-krona-secondary" />
<span>{p.title}</span>
</td>
<td className="p-3 font-mono text-krona-secondary">{p.slug}</td>
<td className="p-3 font-bold text-krona-primary">{p.viewsToday}</td>
<td className="p-3 text-krona-secondary">{p.lastEdited}</td>
<td className="p-3 text-center">
<Badge variant="green" size="sm">Опубликована</Badge>
</td>
<td className="p-3 text-right">
<button
onClick={() => setActiveTab('builder')}
className="text-krona-green font-medium hover:underline inline-flex items-center gap-1"
>
<Edit3 className="w-3.5 h-3.5" />
Редактор
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{/* 2. VISUAL BUILDER TAB */}
{activeTab === 'builder' && (
<div className="bg-krona-surface border border-krona-border rounded-xl p-4 shadow-krona-soft space-y-4">
<div className="flex items-center justify-between pb-3 border-b border-krona-border">
<div className="flex items-center gap-2">
<h4 className="text-xs font-bold text-krona-primary">
Блочный редактор: «Главная страница доставки»
</h4>
<Badge variant="green" size="sm">Auto-save</Badge>
</div>
<div className="flex items-center gap-2 text-xs">
<Button variant="outline" size="sm">
<Monitor className="w-3.5 h-3.5 mr-1" />
Превью
</Button>
<Button variant="primary" size="sm">
Опубликовать изменения
</Button>
</div>
</div>
<div className="space-y-3 max-w-2xl mx-auto">
<div className="p-4 rounded-xl border border-dashed border-krona-green/70 bg-krona-green/5 text-center space-y-1">
<div className="text-xs font-bold text-krona-green-text">1. Hero блок: Баннер акции «Сет Осенний -20%»</div>
<p className="text-[11px] text-krona-secondary">Фоновое видео + кнопка заказа в 1 клик</p>
</div>
<div className="p-4 rounded-xl border border-dashed border-krona-border bg-krona-workspace text-center space-y-1">
<div className="text-xs font-bold text-krona-primary">2. Витрина популярных роллов (Филадельфия, Калифорния)</div>
<p className="text-[11px] text-krona-secondary">Автоматическая синхронизация с кассой и остатками</p>
</div>
<div className="p-4 rounded-xl border border-dashed border-krona-border bg-krona-workspace text-center space-y-1">
<div className="text-xs font-bold text-krona-primary">3. Интерактивная форма бронирования столиков</div>
<p className="text-[11px] text-krona-secondary">Интеграция со схемой залов Krona Hall Engine</p>
</div>
<button className="w-full p-3 rounded-xl border border-dashed border-krona-gold/60 text-krona-gold hover:bg-krona-gold/5 text-xs font-semibold flex items-center justify-center gap-1.5 transition-colors">
<Plus className="w-4 h-4" />
Добавить контентный блок
</button>
</div>
</div>
)}
{/* 3. FORMS TAB */}
{activeTab === 'forms' && (
<div className="bg-krona-surface border border-krona-border rounded-xl p-4 shadow-krona-soft space-y-4">
<div className="flex items-center justify-between pb-2 border-b border-krona-border">
<div>
<h4 className="text-xs font-bold text-krona-primary">Лид-формы и виджеты захвата заявок</h4>
<p className="text-[11px] text-krona-secondary">Формы автоматически создают лиды и сделки в CRM</p>
</div>
<Button variant="primary" size="sm">
<Plus className="w-3.5 h-3.5 mr-1" />
Новая форма
</Button>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3.5 text-xs">
<div className="p-3.5 rounded-xl border border-krona-border bg-krona-workspace space-y-2">
<div className="flex items-center justify-between">
<span className="font-bold text-krona-primary text-sm">Форма быстрой брони столика</span>
<Badge variant="green" size="sm">Активна</Badge>
</div>
<p className="text-krona-secondary text-[11px]">Поля: Имя, Телефон, Дата/Время, Персоны, Пожелания</p>
<div className="flex justify-between pt-2 border-t border-krona-border/60 text-[11px]">
<span>Конверсия: 24.8%</span>
<span className="font-semibold text-krona-green">148 заявок</span>
</div>
</div>
<div className="p-3.5 rounded-xl border border-krona-border bg-krona-workspace space-y-2">
<div className="flex items-center justify-between">
<span className="font-bold text-krona-primary text-sm">Виджет подписки на скидку 20%</span>
<Badge variant="green" size="sm">Всплывающее окно</Badge>
</div>
<p className="text-krona-secondary text-[11px]">Поля: Номер телефона (с отправкой промокода в SMS/Telegram)</p>
<div className="flex justify-between pt-2 border-t border-krona-border/60 text-[11px]">
<span>Конверсия: 14.2%</span>
<span className="font-semibold text-krona-green">312 лидов</span>
</div>
</div>
</div>
</div>
)}
{/* 4. MEDIA LIBRARY TAB */}
{activeTab === 'media' && (
<div className="bg-krona-surface border border-krona-border rounded-xl p-4 shadow-krona-soft space-y-4">
<div className="flex items-center justify-between pb-2 border-b border-krona-border">
<div>
<h4 className="text-xs font-bold text-krona-primary">Медиабиблиотека сайта</h4>
<p className="text-[11px] text-krona-secondary">Фотографии блюд, баннеры акций, логотипы и видеоматериалы</p>
</div>
<Button variant="primary" size="sm">
<Plus className="w-3.5 h-3.5 mr-1" />
Загрузить файл
</Button>
</div>
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
{[
{ name: 'philadelphia_banner.jpg', size: '1.2 MB', category: 'Баннеры' },
{ name: 'tom_yam_dish.png', size: '840 KB', category: 'Каталог' },
{ name: 'autumn_set_promo.jpg', size: '2.1 MB', category: 'Акции' },
{ name: 'krona_hall_interior.jpg', size: '1.8 MB', category: 'Интерьер' },
].map((media, idx) => (
<div key={idx} className="p-3 rounded-xl border border-krona-border bg-krona-workspace space-y-2 text-xs">
<div className="h-24 rounded-lg bg-krona-surface border border-krona-border flex items-center justify-center text-krona-secondary">
<Image className="w-8 h-8 opacity-40" />
</div>
<div>
<div className="font-bold text-krona-primary truncate">{media.name}</div>
<div className="text-[10px] text-krona-secondary flex justify-between">
<span>{media.category}</span>
<span>{media.size}</span>
</div>
</div>
</div>
))}
</div>
</div>
)}
{/* 5. SEO TAB */}
{activeTab === 'seo' && (
<div className="bg-krona-surface border border-krona-border rounded-xl p-4 shadow-krona-soft space-y-4 max-w-3xl">
<div className="pb-2 border-b border-krona-border">
<h4 className="text-xs font-bold text-krona-primary">SEO настройки и Open Graph метаданные</h4>
<p className="text-[11px] text-krona-secondary">Отображение сайта в поиске Яндекса, Google и при шеринге в мессенджерах</p>
</div>
<div className="space-y-3 text-xs">
<Input
label="Meta Title (Заголовок страницы)"
value={seoTitle}
onChange={(e) => setSeoTitle(e.target.value)}
/>
<div className="flex flex-col gap-1.5 text-left">
<label className="text-xs font-medium text-krona-secondary">Meta Description (Описание для поисковиков)</label>
<textarea
rows={3}
value={seoDescription}
onChange={(e) => setSeoDescription(e.target.value)}
className="w-full bg-krona-workspace border border-krona-border rounded-md px-3 py-2 text-xs text-krona-primary focus:outline-none focus:border-krona-green"
/>
</div>
{/* Snippet Preview */}
<div className="p-3.5 rounded-xl border border-krona-border bg-krona-workspace space-y-1">
<span className="text-[10px] uppercase font-bold text-krona-secondary block">Превью в поисковой выдаче Яндекса:</span>
<div className="text-blue-600 font-medium text-sm hover:underline cursor-pointer">{seoTitle}</div>
<div className="text-green-700 text-[11px]">krona.site/sushi-nsk</div>
<div className="text-krona-secondary text-xs">{seoDescription}</div>
</div>
<div className="flex justify-end pt-2">
<Button variant="primary" size="sm">
Сохранить SEO параметры
</Button>
</div>
</div>
</div>
)}
{/* 6. DOMAINS TAB */}
{activeTab === 'domains' && (
<div className="bg-krona-surface border border-krona-border rounded-xl p-4 shadow-krona-soft space-y-4">
<div className="flex items-center justify-between pb-2 border-b border-krona-border">
<div>
<h4 className="text-xs font-bold text-krona-primary">Управление доменами и SSL сертификатами</h4>
<p className="text-[11px] text-krona-secondary">Привязка собственных доменов, автоматический выпуск Let's Encrypt SSL</p>
</div>
<Button variant="primary" size="sm">
<Plus className="w-3.5 h-3.5 mr-1" />
Подключить домен
</Button>
</div>
<div className="space-y-2 text-xs">
<div className="p-3 rounded-lg border border-krona-border bg-krona-workspace flex items-center justify-between">
<div className="flex items-center gap-3">
<Globe className="w-5 h-5 text-krona-green" />
<div>
<div className="font-bold text-krona-primary">sushi-gyodza.ru</div>
<div className="text-[11px] text-krona-secondary flex items-center gap-1.5">
<span className="flex items-center gap-0.5 text-krona-green font-semibold">
<ShieldCheck className="w-3.5 h-3.5" /> SSL Активен
</span>
<span>• DNS A-запись 185.122.40.12 (OK)</span>
</div>
</div>
</div>
<Badge variant="green" size="sm">Основной домен</Badge>
</div>
<div className="p-3 rounded-lg border border-krona-border bg-krona-workspace flex items-center justify-between">
<div className="flex items-center gap-3">
<Globe className="w-5 h-5 text-krona-secondary" />
<div>
<div className="font-bold text-krona-primary">krona.site/sushi-nsk</div>
<div className="text-[11px] text-krona-secondary">Системный поддомен Кроны • SSL Wildcard</div>
</div>
</div>
<Badge variant="neutral" size="sm">Системный алиас</Badge>
</div>
</div>
</div>
)}
</div>
);
};

View file

@ -0,0 +1,159 @@
import React, { useState } from 'react';
import { INITIAL_CONVERSATIONS, ConversationItem } from '../../mock/mockData';
import { mockApi } from '../../mock/mockApi';
import { Badge } from '../../shared/components/Badge';
import { Button } from '../../shared/components/Button';
import {
MessageSquareText,
Send,
User,
Phone,
Paperclip,
Smile,
Search,
CheckCheck,
} from 'lucide-react';
export const CommunicationsModule: React.FC = () => {
const [conversations, setConversations] = useState<ConversationItem[]>(INITIAL_CONVERSATIONS);
const [activeConvId, setActiveConvId] = useState<string>(INITIAL_CONVERSATIONS[0].id);
const [replyText, setReplyText] = useState('');
const activeConv = conversations.find((c) => c.id === activeConvId) || conversations[0];
const handleSend = async (e: React.FormEvent) => {
e.preventDefault();
if (!replyText.trim()) return;
await mockApi.sendMessage(activeConvId, replyText);
const updated = await mockApi.getMessages();
setConversations(updated);
setReplyText('');
};
const getChannelBadge = (channel: ConversationItem['channel']) => {
switch (channel) {
case 'telegram':
return <Badge variant="gold" size="sm">Telegram Bot</Badge>;
case 'whatsapp':
return <Badge variant="green" size="sm">WhatsApp</Badge>;
case 'max':
return <Badge variant="info" size="sm">MAX</Badge>;
case 'internal':
return <Badge variant="warning" size="sm">Командный чат</Badge>;
default:
return <Badge variant="neutral" size="sm">Чат</Badge>;
}
};
return (
<div className="h-[calc(100vh-8.5rem)] flex flex-col md:flex-row gap-3 bg-krona-surface border border-krona-border rounded-xl shadow-krona-soft overflow-hidden animate-fade-in">
{/* Left: Conversations List */}
<div className="w-full md:w-80 lg:w-96 border-r border-krona-border flex flex-col bg-krona-workspace/50">
<div className="p-3 border-b border-krona-border">
<h3 className="text-xs font-bold text-krona-primary uppercase tracking-wider mb-2">
Единый входящий Inbox
</h3>
<div className="relative">
<Search className="w-3.5 h-3.5 absolute left-2.5 top-2.5 text-krona-secondary" />
<input
type="text"
placeholder="Поиск диалогов..."
className="w-full pl-8 pr-3 py-1.5 text-xs bg-krona-surface border border-krona-border rounded-lg text-krona-primary placeholder:text-krona-secondary focus:outline-none"
/>
</div>
</div>
<div className="flex-1 overflow-y-auto divide-y divide-krona-border/40">
{conversations.map((conv) => (
<div
key={conv.id}
onClick={() => setActiveConvId(conv.id)}
className={`p-3 cursor-pointer transition-colors ${
conv.id === activeConvId
? 'bg-krona-surface font-medium border-l-2 border-krona-gold'
: 'hover:bg-krona-surface/60 text-krona-secondary'
}`}
>
<div className="flex items-center justify-between mb-1">
<span className="font-semibold text-xs text-krona-primary truncate">
{conv.clientName}
</span>
<span className="text-[10px] text-krona-secondary">{conv.time}</span>
</div>
<p className="text-xs text-krona-secondary truncate mb-1.5">
{conv.lastMessage}
</p>
<div className="flex items-center justify-between">
{getChannelBadge(conv.channel)}
{conv.unreadCount > 0 && (
<span className="w-4 h-4 rounded-full bg-krona-gold text-white text-[10px] font-bold flex items-center justify-center">
{conv.unreadCount}
</span>
)}
</div>
</div>
))}
</div>
</div>
{/* Center: Active Conversation Thread */}
<div className="flex-1 flex flex-col min-w-0 bg-krona-surface">
{/* Chat header */}
<div className="p-3.5 border-b border-krona-border flex items-center justify-between bg-krona-surface/80">
<div className="flex items-center gap-2.5">
<div className="w-8 h-8 rounded-full bg-krona-workspace border border-krona-border flex items-center justify-center font-bold text-xs text-krona-primary">
{activeConv.avatar}
</div>
<div>
<h4 className="text-xs font-bold text-krona-primary">{activeConv.clientName}</h4>
<span className="text-[10px] text-krona-secondary">Канал связи: {activeConv.channel}</span>
</div>
</div>
{getChannelBadge(activeConv.channel)}
</div>
{/* Message bubbles */}
<div className="flex-1 overflow-y-auto p-4 space-y-3">
{activeConv.messages.map((msg) => (
<div
key={msg.id}
className={`flex flex-col ${msg.sender === 'agent' ? 'items-end' : 'items-start'}`}
>
<span className="text-[10px] text-krona-secondary mb-0.5 px-1">{msg.senderName}</span>
<div
className={`p-3 rounded-xl max-w-[75%] text-xs leading-relaxed shadow-krona-soft ${
msg.sender === 'agent'
? 'bg-[#2F684C] text-[#F8F6F0] rounded-br-none'
: 'bg-krona-workspace border border-krona-border text-krona-primary rounded-bl-none'
}`}
>
<p>{msg.text}</p>
<div className="flex items-center justify-end gap-1 mt-1 text-[9px] opacity-70">
<span>{msg.time}</span>
{msg.sender === 'agent' && <CheckCheck className="w-3 h-3" />}
</div>
</div>
</div>
))}
</div>
{/* Reply form */}
<div className="p-3 border-t border-krona-border bg-krona-workspace/50">
<form onSubmit={handleSend} className="flex items-center gap-2">
<input
type="text"
value={replyText}
onChange={(e) => setReplyText(e.target.value)}
placeholder="Напишите ответ клиенту или коллегам..."
className="flex-1 bg-krona-surface border border-krona-border rounded-lg px-3 py-2 text-xs text-krona-primary placeholder:text-krona-secondary focus:outline-none focus:border-krona-green"
/>
<Button type="submit" variant="primary" size="sm" className="px-3">
<Send className="w-3.5 h-3.5 mr-1" />
Отправить
</Button>
</form>
</div>
</div>
</div>
);
};

View file

@ -0,0 +1,668 @@
import React, { useState, useEffect } from 'react';
import { useLocation, useNavigate } from 'react-router-dom';
import { INITIAL_CLIENTS, INITIAL_DEALS, ClientEntity, DealEntity } from '../../mock/mockData';
import { mockApi } from '../../mock/mockApi';
import { useDemoStore } from '../../mock/store';
import { Button } from '../../shared/components/Button';
import { Badge } from '../../shared/components/Badge';
import { Input } from '../../shared/components/Input';
import { Modal } from '../../shared/components/Modal';
import { formatCurrency } from '../../shared/utils/formatters';
import {
Users,
Search,
Plus,
Phone,
Mail,
Calendar,
DollarSign,
TrendingUp,
Tag,
ArrowRight,
Car,
FileText,
ShoppingBag,
CheckSquare,
MessageSquare,
Clock,
Building,
Check,
} from 'lucide-react';
export const CrmModule: React.FC = () => {
const location = useLocation();
const navigate = useNavigate();
const { setPosCustomer, setIsQuickActionsOpen } = useDemoStore();
const [clients, setClients] = useState<ClientEntity[]>(INITIAL_CLIENTS);
const [deals, setDeals] = useState<DealEntity[]>(INITIAL_DEALS);
const [search, setSearch] = useState('');
const [activeTab, setActiveTab] = useState<'clients' | 'deals' | 'leads' | 'companies'>('clients');
// Selected client for detail view
const [selectedClient, setSelectedClient] = useState<ClientEntity | null>(null);
const [clientDetailTab, setClientDetailTab] = useState<'overview' | 'orders' | 'tasks' | 'comms'>('overview');
// Selected deal for detail view
const [selectedDeal, setSelectedDeal] = useState<DealEntity | null>(null);
// New client modal
const [isAddModalOpen, setIsAddModalOpen] = useState(false);
const [newName, setNewName] = useState('');
const [newPhone, setNewPhone] = useState('');
const [newEmail, setNewEmail] = useState('');
const [newCategory, setNewCategory] = useState<'VIP' | 'Постоянный' | 'Новый'>('Новый');
// Synchronize activeTab with URL
useEffect(() => {
const path = location.pathname;
if (path.includes('/crm/deals')) {
setActiveTab('deals');
} else if (path.includes('/crm/leads')) {
setActiveTab('leads');
} else if (path.includes('/crm/companies')) {
setActiveTab('companies');
} else if (path.includes('/crm/clients') || path === '/crm') {
setActiveTab('clients');
}
}, [location.pathname]);
const handleAddClient = async (e: React.FormEvent) => {
e.preventDefault();
const created = await mockApi.createCustomer({
name: newName,
phone: newPhone,
email: newEmail || `${newName.toLowerCase().replace(/\s+/g, '.')}@example.com`,
category: newCategory,
totalSpent: 0,
ordersCount: 1,
lastVisit: 'Только что',
source: 'Вручную менеджером',
});
setClients([created, ...clients]);
setIsAddModalOpen(false);
setNewName('');
setNewPhone('');
setNewEmail('');
};
const handleUpdateDealStage = (dealId: string, nextStage: DealEntity['stage']) => {
setDeals((prev) => prev.map((d) => (d.id === dealId ? { ...d, stage: nextStage } : d)));
if (selectedDeal && selectedDeal.id === dealId) {
setSelectedDeal({ ...selectedDeal, stage: nextStage });
}
};
const filteredClients = clients.filter(
(c) =>
c.name.toLowerCase().includes(search.toLowerCase()) ||
c.phone.includes(search) ||
(c.vehicle && c.vehicle.toLowerCase().includes(search.toLowerCase()))
);
return (
<div className="space-y-4 animate-fade-in">
{/* Top Header */}
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3 bg-krona-surface p-3.5 rounded-xl border border-krona-border shadow-krona-soft">
<div>
<h2 className="text-sm font-bold text-krona-primary">
CRM & Единая клиентская база
</h2>
<p className="text-xs text-krona-secondary">
Профили клиентов, история визитов, LTV, сделки и воронка продаж
</p>
</div>
<div className="flex items-center gap-2">
<div className="flex items-center bg-krona-workspace p-1 rounded-lg border border-krona-border text-xs">
<button
onClick={() => {
setActiveTab('clients');
navigate('/crm/clients');
}}
className={`px-3 py-1 rounded font-medium transition-colors ${
activeTab === 'clients' ? 'bg-[#2F684C] text-white shadow-sm' : 'text-krona-secondary'
}`}
>
Клиенты ({clients.length})
</button>
<button
onClick={() => {
setActiveTab('deals');
navigate('/crm/deals');
}}
className={`px-3 py-1 rounded font-medium transition-colors ${
activeTab === 'deals' ? 'bg-[#2F684C] text-white shadow-sm' : 'text-krona-secondary'
}`}
>
Сделки ({deals.length})
</button>
<button
onClick={() => {
setActiveTab('leads');
navigate('/crm/leads');
}}
className={`px-3 py-1 rounded font-medium transition-colors ${
activeTab === 'leads' ? 'bg-[#2F684C] text-white shadow-sm' : 'text-krona-secondary'
}`}
>
Лиды
</button>
<button
onClick={() => {
setActiveTab('companies');
navigate('/crm/companies');
}}
className={`px-3 py-1 rounded font-medium transition-colors ${
activeTab === 'companies' ? 'bg-[#2F684C] text-white shadow-sm' : 'text-krona-secondary'
}`}
>
Компании
</button>
</div>
<Button variant="primary" size="sm" onClick={() => setIsAddModalOpen(true)}>
<Plus className="w-3.5 h-3.5 mr-1" />
Добавить клиента
</Button>
</div>
</div>
{activeTab === 'clients' && (
/* Clients Table & Search */
<div className="bg-krona-surface border border-krona-border rounded-xl shadow-krona-soft overflow-hidden space-y-3 p-4">
<div className="flex items-center justify-between gap-3">
<div className="relative w-72">
<Search className="w-3.5 h-3.5 absolute left-2.5 top-2.5 text-krona-secondary" />
<input
type="text"
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Поиск по имени, телефону, авто..."
className="w-full pl-8 pr-3 py-1.5 text-xs bg-krona-workspace border border-krona-border rounded-lg text-krona-primary placeholder:text-krona-secondary focus:outline-none focus:border-krona-green"
/>
</div>
</div>
<div className="overflow-x-auto">
<table className="w-full text-left text-xs border-collapse">
<thead className="bg-krona-workspace/80 text-krona-secondary border-b border-krona-border">
<tr>
<th className="p-3 font-semibold">Клиент</th>
<th className="p-3 font-semibold">Контакты</th>
<th className="p-3 font-semibold">Сегмент</th>
<th className="p-3 font-semibold">Всего покупок (LTV)</th>
<th className="p-3 font-semibold">Заказов</th>
<th className="p-3 font-semibold">Последний визит</th>
<th className="p-3 font-semibold text-right">Действие</th>
</tr>
</thead>
<tbody className="divide-y divide-krona-border/50">
{filteredClients.map((client) => (
<tr
key={client.id}
onClick={() => {
setSelectedClient(client);
setClientDetailTab('overview');
}}
className="hover:bg-krona-workspace/50 transition-colors cursor-pointer"
>
<td className="p-3 font-medium text-krona-primary">
<div className="flex items-center gap-2">
<div className="w-7 h-7 rounded-full bg-krona-workspace border border-krona-border flex items-center justify-center font-bold text-[11px] text-krona-primary">
{client.name.charAt(0)}
</div>
<div>
<div>{client.name}</div>
{client.vehicle && (
<div className="text-[11px] text-krona-secondary flex items-center gap-1">
<Car className="w-3 h-3 text-krona-info" />
<span>{client.vehicle}</span>
</div>
)}
</div>
</div>
</td>
<td className="p-3 text-krona-secondary font-mono">
<div>{client.phone}</div>
<div className="text-[11px] text-krona-secondary/80 font-sans">{client.email}</div>
</td>
<td className="p-3">
<Badge
variant={
client.category === 'VIP'
? 'gold'
: client.category === 'Постоянный'
? 'green'
: client.category === 'В зоне риска'
? 'danger'
: 'neutral'
}
size="sm"
>
{client.category}
</Badge>
</td>
<td className="p-3 font-bold text-krona-primary">
{formatCurrency(client.totalSpent)}
</td>
<td className="p-3 text-krona-secondary">{client.ordersCount}</td>
<td className="p-3 text-krona-secondary">{client.lastVisit}</td>
<td className="p-3 text-right">
<button className="text-krona-green font-medium hover:underline">
Карточка →
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
{activeTab === 'deals' && (
/* Deals Kanban View */
<div className="grid grid-cols-1 md:grid-cols-4 gap-3.5">
{(['lead', 'qualified', 'proposal', 'negotiation'] as const).map((stageKey) => {
const stageDeals = deals.filter((d) => d.stage === stageKey);
const titles: Record<string, string> = {
lead: 'Новые лиды',
qualified: 'Квалификация',
proposal: 'КП / Счет выставлен',
negotiation: 'Согласование / Оплата',
};
return (
<div
key={stageKey}
className="bg-krona-workspace/80 border border-krona-border rounded-xl p-3 flex flex-col min-h-[420px]"
>
<div className="flex items-center justify-between pb-2 mb-2 border-b border-krona-border">
<span className="text-xs font-bold text-krona-primary">{titles[stageKey]}</span>
<span className="text-xs text-krona-secondary font-mono font-bold">
{stageDeals.length}
</span>
</div>
<div className="space-y-2.5 flex-1 overflow-y-auto">
{stageDeals.map((deal) => (
<div
key={deal.id}
onClick={() => setSelectedDeal(deal)}
className="p-3 rounded-lg border border-krona-border bg-krona-surface shadow-krona-soft space-y-2 cursor-pointer hover:border-krona-gold/60 transition-all group"
>
<h5 className="text-xs font-bold text-krona-primary leading-tight group-hover:text-krona-gold-text transition-colors">
{deal.title}
</h5>
<div className="text-[11px] text-krona-secondary">{deal.clientName}</div>
<div className="flex items-center justify-between pt-1 border-t border-krona-border/60 text-xs">
<span className="font-bold text-krona-green">{formatCurrency(deal.amount)}</span>
<span className="text-[10px] text-krona-secondary">{deal.deadline}</span>
</div>
</div>
))}
</div>
</div>
);
})}
</div>
)}
{activeTab === 'leads' && (
/* Leads list */
<div className="bg-krona-surface border border-krona-border rounded-xl shadow-krona-soft p-4 space-y-3">
<h4 className="text-xs font-bold text-krona-primary uppercase tracking-wider">
Очередь необработанных лидов
</h4>
<div className="space-y-2 text-xs">
<div className="p-3 rounded-lg border border-krona-border bg-krona-workspace flex items-center justify-between">
<div>
<h5 className="font-bold text-krona-primary">Заявка на проведение банкета на 25 персон</h5>
<p className="text-krona-secondary text-[11px]">Дмитрий Ковалев • +7 (913) 998-12-34 • Источник: Telegram-бот</p>
</div>
<Button variant="primary" size="sm" onClick={() => setIsQuickActionsOpen(true)}>
Взять в работу
</Button>
</div>
<div className="p-3 rounded-lg border border-krona-border bg-krona-workspace flex items-center justify-between">
<div>
<h5 className="font-bold text-krona-primary">Запрос на корпоративное обслуживание ланчами</h5>
<p className="text-krona-secondary text-[11px]">ООО "ТехноАльянс" • info@techno.ru • Источник: Сайт / Форма</p>
</div>
<Button variant="primary" size="sm" onClick={() => setIsQuickActionsOpen(true)}>
Взять в работу
</Button>
</div>
</div>
</div>
)}
{activeTab === 'companies' && (
/* Companies directory */
<div className="bg-krona-surface border border-krona-border rounded-xl shadow-krona-soft p-4 space-y-3">
<h4 className="text-xs font-bold text-krona-primary uppercase tracking-wider">
Корпоративные клиенты & B2B Контрагенты
</h4>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3 text-xs">
<div className="p-3.5 rounded-lg border border-krona-border bg-krona-workspace space-y-2">
<div className="flex items-center justify-between">
<span className="font-bold text-krona-primary text-sm">ООО "Сибирский Ритейл"</span>
<Badge variant="gold" size="sm">Договор активен</Badge>
</div>
<p className="text-krona-secondary text-[11px]">ИНН 5408123456 • Оплата по безналу с НДС</p>
<div className="flex justify-between pt-1 border-t border-krona-border/60 text-[11px]">
<span>Объем заказов: 480 000 ₽</span>
<span className="text-krona-green font-medium">Сверка пройдена</span>
</div>
</div>
<div className="p-3.5 rounded-lg border border-krona-border bg-krona-workspace space-y-2">
<div className="flex items-center justify-between">
<span className="font-bold text-krona-primary text-sm">ИТ-Компания "ДатаНова"</span>
<Badge variant="green" size="sm">Кейтеринг</Badge>
</div>
<p className="text-krona-secondary text-[11px]">ИНН 5402998877 • Договор на корпоративные ланчи</p>
<div className="flex justify-between pt-1 border-t border-krona-border/60 text-[11px]">
<span>Объем заказов: 340 000 ₽</span>
<span className="text-krona-green font-medium">Счет оплачен</span>
</div>
</div>
</div>
</div>
)}
{/* Deal Detail Modal */}
{selectedDeal && (
<Modal
isOpen={Boolean(selectedDeal)}
onClose={() => setSelectedDeal(null)}
title={`Сделка: ${selectedDeal.title}`}
subtitle={`Клиент: ${selectedDeal.clientName} • Сумма: ${formatCurrency(selectedDeal.amount)}`}
maxWidth="md"
footer={
<Button variant="secondary" onClick={() => setSelectedDeal(null)}>
Закрыть
</Button>
}
>
<div className="space-y-4 text-xs">
{/* Stage Selector */}
<div>
<span className="font-bold text-krona-secondary uppercase text-[10px] block mb-1.5">
Этап воронки сделки:
</span>
<div className="grid grid-cols-4 gap-1.5 bg-krona-workspace p-1 rounded-lg border border-krona-border">
{(['lead', 'qualified', 'proposal', 'negotiation'] as const).map((st) => (
<button
key={st}
onClick={() => handleUpdateDealStage(selectedDeal.id, st)}
className={`py-1.5 px-2 rounded text-[11px] font-medium transition-colors ${
selectedDeal.stage === st
? 'bg-[#2F684C] text-white shadow-sm font-bold'
: 'text-krona-secondary hover:text-krona-primary'
}`}
>
{st === 'lead' ? 'Лид' : st === 'qualified' ? 'Квалификация' : st === 'proposal' ? 'КП / Счет' : 'Согласование'}
</button>
))}
</div>
</div>
<div className="grid grid-cols-2 gap-3 p-3 rounded-lg bg-krona-workspace border border-krona-border">
<div>
<span className="text-krona-secondary block text-[10px]">Ответственный:</span>
<span className="font-semibold text-krona-primary">{selectedDeal.responsible}</span>
</div>
<div>
<span className="text-krona-secondary block text-[10px]">Плановый срок:</span>
<span className="font-semibold text-krona-primary">{selectedDeal.deadline}</span>
</div>
<div>
<span className="text-krona-secondary block text-[10px]">Бюджет сделки:</span>
<span className="font-bold text-krona-green text-sm">{formatCurrency(selectedDeal.amount)}</span>
</div>
<div>
<span className="text-krona-secondary block text-[10px]">Клиент:</span>
<span className="font-medium text-krona-primary">{selectedDeal.clientName}</span>
</div>
</div>
<div className="p-3 border border-krona-border rounded-lg space-y-2">
<span className="text-[10px] uppercase font-bold text-krona-secondary block">
История изменений по сделке:
</span>
<div className="space-y-1 text-krona-secondary text-[11px]">
<div className="flex items-center gap-2">
<span className="w-1.5 h-1.5 rounded-full bg-krona-green"></span>
<span>10.09.2026 — Счет на оплату сформирован и отправлен в бухгалтерию контрагента</span>
</div>
<div className="flex items-center gap-2">
<span className="w-1.5 h-1.5 rounded-full bg-krona-gold"></span>
<span>08.09.2026 — Квалификация пройдена, согласовано меню на мероприятие</span>
</div>
</div>
</div>
</div>
</Modal>
)}
{/* Enhanced Client Detail Modal with Subtabs */}
{selectedClient && (
<Modal
isOpen={Boolean(selectedClient)}
onClose={() => setSelectedClient(null)}
title={`Клиент: ${selectedClient.name}`}
subtitle={`Категория: ${selectedClient.category} • ${selectedClient.phone}`}
maxWidth="lg"
footer={
<div className="flex items-center justify-between w-full">
<div className="flex gap-2">
<Button
variant="primary"
size="sm"
onClick={() => {
setPosCustomer(selectedClient.name);
setSelectedClient(null);
navigate('/operations/pos');
}}
>
<ShoppingBag className="w-3.5 h-3.5 mr-1" />
Создать заказ в POS
</Button>
<Button
variant="gold"
size="sm"
onClick={() => {
setSelectedClient(null);
setIsQuickActionsOpen(true);
}}
>
<Plus className="w-3.5 h-3.5 mr-1" />
Открыть сделку
</Button>
</div>
<Button variant="secondary" size="sm" onClick={() => setSelectedClient(null)}>
Закрыть
</Button>
</div>
}
>
<div className="space-y-3 text-xs">
{/* Sub-tabs selector */}
<div className="flex items-center gap-1 bg-krona-workspace p-1 rounded-lg border border-krona-border text-xs">
<button
onClick={() => setClientDetailTab('overview')}
className={`px-3 py-1 rounded font-medium transition-colors ${
clientDetailTab === 'overview' ? 'bg-[#2F684C] text-white shadow-sm' : 'text-krona-secondary'
}`}
>
Обзор
</button>
<button
onClick={() => setClientDetailTab('orders')}
className={`px-3 py-1 rounded font-medium transition-colors ${
clientDetailTab === 'orders' ? 'bg-[#2F684C] text-white shadow-sm' : 'text-krona-secondary'
}`}
>
Связанные заказы (3)
</button>
<button
onClick={() => setClientDetailTab('tasks')}
className={`px-3 py-1 rounded font-medium transition-colors ${
clientDetailTab === 'tasks' ? 'bg-[#2F684C] text-white shadow-sm' : 'text-krona-secondary'
}`}
>
Задачи (1)
</button>
<button
onClick={() => setClientDetailTab('comms')}
className={`px-3 py-1 rounded font-medium transition-colors ${
clientDetailTab === 'comms' ? 'bg-[#2F684C] text-white shadow-sm' : 'text-krona-secondary'
}`}
>
Коммуникации
</button>
</div>
{clientDetailTab === 'overview' && (
<div className="space-y-3">
<div className="grid grid-cols-2 gap-3 p-3 rounded-lg bg-krona-workspace border border-krona-border">
<div>
<span className="text-krona-secondary block text-[10px]">Телефон:</span>
<span className="font-mono font-semibold text-krona-primary">{selectedClient.phone}</span>
</div>
<div>
<span className="text-krona-secondary block text-[10px]">Email:</span>
<span className="font-medium text-krona-primary">{selectedClient.email}</span>
</div>
<div>
<span className="text-krona-secondary block text-[10px]">LTV (Всего покупок):</span>
<span className="font-bold text-krona-green text-sm">{formatCurrency(selectedClient.totalSpent)}</span>
</div>
<div>
<span className="text-krona-secondary block text-[10px]">Всего визитов:</span>
<span className="font-semibold text-krona-primary">{selectedClient.ordersCount} заказов</span>
</div>
</div>
{selectedClient.vehicle && (
<div className="p-3 bg-krona-surface border border-krona-border rounded-lg flex items-center gap-2">
<Car className="w-4 h-4 text-krona-info" />
<div>
<span className="font-bold text-krona-primary">Привязанный автомобиль:</span>
<span className="text-krona-secondary ml-1.5 font-mono">{selectedClient.vehicle}</span>
</div>
</div>
)}
{selectedClient.notes && (
<div className="p-3 border border-krona-border rounded-lg bg-krona-surface">
<span className="text-[10px] uppercase font-bold text-krona-secondary block mb-1">
Заметки & Предпочтения:
</span>
<p className="text-krona-primary">{selectedClient.notes}</p>
</div>
)}
</div>
)}
{clientDetailTab === 'orders' && (
<div className="space-y-2">
<div className="p-2.5 border border-krona-border rounded-lg bg-krona-workspace flex items-center justify-between">
<div>
<div className="font-bold text-krona-primary">#ORD-104 (Зал, Стол 4)</div>
<div className="text-[11px] text-krona-secondary">Сегодня, 13:40 • 3 позиции • Оплата картой</div>
</div>
<span className="font-bold text-krona-green">{formatCurrency(4200)}</span>
</div>
<div className="p-2.5 border border-krona-border rounded-lg bg-krona-workspace flex items-center justify-between">
<div>
<div className="font-bold text-krona-primary">#ORD-082 (Доставка курьером)</div>
<div className="text-[11px] text-krona-secondary">04.09.2026 • 5 позиций • Доставлен вовремя</div>
</div>
<span className="font-bold text-krona-green">{formatCurrency(3850)}</span>
</div>
</div>
)}
{clientDetailTab === 'tasks' && (
<div className="p-3 border border-krona-border rounded-lg bg-krona-workspace space-y-1">
<div className="flex items-center justify-between">
<span className="font-bold text-krona-primary">Связаться перед банкетом для утверждения винной карты</span>
<Badge variant="warning" size="sm">В работе</Badge>
</div>
<p className="text-[11px] text-krona-secondary">Срок: Завтра, 14:00 • Назначено: Ольга Власова</p>
</div>
)}
{clientDetailTab === 'comms' && (
<div className="p-3 border border-krona-border rounded-lg bg-krona-workspace space-y-2">
<div className="flex items-center gap-1.5 font-bold text-krona-primary">
<MessageSquare className="w-3.5 h-3.5 text-blue-500" />
<span>Telegram бот</span>
</div>
<p className="text-krona-secondary text-[11px]">
Последнее сообщение: «Добрый день! Подскажите, стол 4 у окна сегодня свободен к 19:30?» (Сегодня, 13:42)
</p>
<Button
variant="outline"
size="sm"
onClick={() => {
setSelectedClient(null);
navigate('/communications');
}}
className="text-xs"
>
Перейти в чат диалога →
</Button>
</div>
)}
</div>
</Modal>
)}
{/* Add Client Modal */}
<Modal
isOpen={isAddModalOpen}
onClose={() => setIsAddModalOpen(false)}
title="Новый клиент"
subtitle="Добавление в единую CRM экосистему Кроны"
maxWidth="sm"
>
<form onSubmit={handleAddClient} className="space-y-3 text-xs">
<Input
label="ФИО / Название компании"
value={newName}
onChange={(e) => setNewName(e.target.value)}
placeholder="Сергей Иванов"
required
/>
<Input
label="Номер телефона"
value={newPhone}
onChange={(e) => setNewPhone(e.target.value)}
placeholder="+7 (913) 000-00-00"
required
/>
<Input
label="Email"
value={newEmail}
onChange={(e) => setNewEmail(e.target.value)}
placeholder="sergey@example.com"
/>
<div className="pt-2 flex justify-end gap-2">
<Button type="button" variant="secondary" onClick={() => setIsAddModalOpen(false)}>
Отмена
</Button>
<Button type="submit" variant="primary">
Сохранить
</Button>
</div>
</form>
</Modal>
</div>
);
};

View file

@ -0,0 +1,728 @@
import React, { useState } from 'react';
import { useDemoStore } from '../../mock/store';
import { StatCard } from '../../shared/components/StatCard';
import { Button } from '../../shared/components/Button';
import { Badge } from '../../shared/components/Badge';
import { formatCurrency, formatNumber } from '../../shared/utils/formatters';
import {
TrendingUp,
AlertTriangle,
AlertCircle,
Clock,
CheckCircle2,
ArrowRight,
Utensils,
Car,
ShoppingBag,
Sparkles,
Users,
Truck,
DollarSign,
Package,
Layers,
} from 'lucide-react';
import {
AreaChart,
Area,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
ResponsiveContainer,
BarChart,
Bar,
} from 'recharts';
import { useNavigate } from 'react-router-dom';
const REVENUE_DATA_TODAY = [
{ time: '10:00', revenue: 14200, orders: 4 },
{ time: '12:00', revenue: 38400, orders: 12 },
{ time: '14:00', revenue: 76000, orders: 24 },
{ time: '16:00', revenue: 104500, orders: 32 },
{ time: '18:00', revenue: 149000, orders: 45 },
{ time: '20:00', revenue: 184500, orders: 58 },
];
const REVENUE_DATA_WEEK = [
{ time: 'Пн', revenue: 142000, orders: 48 },
{ time: 'Вт', revenue: 156000, orders: 52 },
{ time: 'Ср', revenue: 168000, orders: 56 },
{ time: 'Чт', revenue: 174000, orders: 59 },
{ time: 'Пт', revenue: 245000, orders: 84 },
{ time: 'Сб', revenue: 290000, orders: 98 },
{ time: 'Вс', revenue: 260000, orders: 89 },
];
export const DynamicDashboard: React.FC = () => {
const {
getActiveProfile,
getActiveCapabilities,
currentRoleId,
activeLocation,
setIsQuickActionsOpen,
kdsTickets,
deliveries,
bookings,
workOrders,
} = useDemoStore();
const profile = getActiveProfile();
const capabilities = getActiveCapabilities();
const navigate = useNavigate();
const [chartPeriod, setChartPeriod] = useState<'today' | 'week'>('today');
// Dynamic KPIs based on Profile
const renderKpis = () => {
switch (profile.id) {
case 'car_wash':
return (
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3.5">
<StatCard
title="Машин в работе"
value={`${workOrders.filter((w) => w.status === 'in_progress').length || 4} авто`}
change={{ value: '+2', trend: 'up' }}
subtitle="2 бокса на въезде"
icon={<Car className="w-4 h-4 text-krona-info" />}
/>
<StatCard
title="Загрузка боксов"
value="83%"
change={{ value: '+5%', trend: 'up' }}
subtitle="5 из 6 боксов заняты"
icon={<Layers className="w-4 h-4 text-krona-gold" />}
/>
<StatCard
title="Средний чек мойки"
value={formatCurrency(1850)}
change={{ value: '+12%', trend: 'up' }}
subtitle="Комплекс + воск"
icon={<TrendingUp className="w-4 h-4 text-krona-green" />}
/>
<StatCard
title="Время на пост"
value="28 минут"
change={{ value: '-3 мин', trend: 'up' }}
subtitle="Норматив: 30 мин"
icon={<Clock className="w-4 h-4 text-krona-green" />}
/>
</div>
);
case 'auto_service':
return (
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3.5">
<StatCard
title="Авто на постах"
value={`${workOrders.length} авто`}
change={{ value: '+3', trend: 'up' }}
subtitle="В работе: 3 • Ожидают: 1"
icon={<Car className="w-4 h-4 text-krona-info" />}
/>
<StatCard
title="Подъемники"
value="4 из 4 заняты"
change={{ value: '100%', trend: 'up' }}
subtitle="Очередь: 2 авто"
icon={<Layers className="w-4 h-4 text-krona-gold" />}
/>
<StatCard
title="Нормо-часы"
value="42.5 н-ч"
change={{ value: '+6.5', trend: 'up' }}
subtitle="Ставка: 2 100 ₽/н-ч"
icon={<Clock className="w-4 h-4 text-krona-green" />}
/>
<StatCard
title="Запчасти в нарядах"
value={formatCurrency(186400)}
change={{ value: '+18%', trend: 'up' }}
subtitle="На складе: 94% позиций"
icon={<DollarSign className="w-4 h-4 text-krona-green" />}
/>
</div>
);
case 'restaurant_delivery_only':
return (
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3.5">
<StatCard
title="Выручка доставки"
value={formatCurrency(142800)}
change={{ value: '+22%', trend: 'up' }}
subtitle="План: 130 000 ₽"
icon={<DollarSign className="w-4 h-4 text-krona-green" />}
/>
<StatCard
title="Курьеров на линии"
value={`${deliveries.filter((d) => d.status === 'in_transit').length + 3} курьера`}
change={{ value: '+2', trend: 'up' }}
subtitle="3 на авто, 2 самокат"
icon={<Truck className="w-4 h-4 text-krona-gold" />}
/>
<StatCard
title="Среднее время доставки"
value="34 мин"
change={{ value: '-4 мин', trend: 'up' }}
subtitle="Готовка 14 мин + путь 20 мин"
icon={<Clock className="w-4 h-4 text-krona-green" />}
/>
<StatCard
title="Онлайн-чеков"
value={`${deliveries.length + 38} заказов`}
change={{ value: '+14', trend: 'up' }}
subtitle="Сайт: 60% • Бот: 40%"
icon={<ShoppingBag className="w-4 h-4 text-krona-info" />}
/>
</div>
);
case 'fitness':
return (
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3.5">
<StatCard
title="Активных абонементов"
value="840 карт"
change={{ value: '+32', trend: 'up' }}
subtitle="Продлений в мес: 78%"
icon={<Users className="w-4 h-4 text-krona-green" />}
/>
<StatCard
title="Загрузка залов"
value="72%"
change={{ value: '+8%', trend: 'up' }}
subtitle="Пик: 18:00 - 21:00"
icon={<Layers className="w-4 h-4 text-krona-gold" />}
/>
<StatCard
title="Тренировок сегодня"
value="18 занятий"
subtitle="Персональных: 12"
icon={<Clock className="w-4 h-4 text-krona-info" />}
/>
<StatCard
title="Выручка сегодня"
value={formatCurrency(92000)}
change={{ value: '+15%', trend: 'up' }}
subtitle="Карты + спортбар"
icon={<DollarSign className="w-4 h-4 text-krona-green" />}
/>
</div>
);
case 'clinic':
return (
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3.5">
<StatCard
title="Приёмов сегодня"
value="36 пациентов"
change={{ value: '+4', trend: 'up' }}
subtitle="Подтверждено: 34"
icon={<Users className="w-4 h-4 text-krona-green" />}
/>
<StatCard
title="Занятость кабинетов"
value="90%"
change={{ value: '+6%', trend: 'up' }}
subtitle="9 из 10 кабинетов"
icon={<Layers className="w-4 h-4 text-krona-gold" />}
/>
<StatCard
title="Средний чек приема"
value={formatCurrency(3400)}
change={{ value: '+300 ₽', trend: 'up' }}
subtitle="Первичный + анализы"
icon={<TrendingUp className="w-4 h-4 text-krona-gold" />}
/>
<StatCard
title="Выручка сегодня"
value={formatCurrency(122400)}
change={{ value: '+11%', trend: 'up' }}
subtitle="Оплата картами: 82%"
icon={<DollarSign className="w-4 h-4 text-krona-green" />}
/>
</div>
);
case 'coworking':
return (
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3.5">
<StatCard
title="Занятость рабочих мест"
value="88% (44 из 50)"
change={{ value: '+6%', trend: 'up' }}
subtitle="Свободно 6 мест"
icon={<Layers className="w-4 h-4 text-krona-gold" />}
/>
<StatCard
title="Брони переговорных"
value="9 броней"
change={{ value: '+2', trend: 'up' }}
subtitle="Зал А, Б и Скайп-румы"
icon={<Clock className="w-4 h-4 text-krona-info" />}
/>
<StatCard
title="Активных резидентов"
value="64 чел"
subtitle="Командных офисов: 6"
icon={<Users className="w-4 h-4 text-krona-green" />}
/>
<StatCard
title="Выручка сегодня"
value={formatCurrency(45000)}
change={{ value: '+8%', trend: 'up' }}
subtitle="Дневные пропуски + кофе"
icon={<DollarSign className="w-4 h-4 text-krona-green" />}
/>
</div>
);
case 'logistics':
return (
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3.5">
<StatCard
title="Заказов в пути"
value="18 отправлений"
change={{ value: '+5', trend: 'up' }}
subtitle="В доставке прямо сейчас"
icon={<Truck className="w-4 h-4 text-krona-info" />}
/>
<StatCard
title="Доставлено вовремя %"
value="97.8%"
change={{ value: '+0.5%', trend: 'up' }}
subtitle="SLA: 95%"
icon={<CheckCircle2 className="w-4 h-4 text-krona-green" />}
/>
<StatCard
title="Курьеров на маршрутах"
value="8 курьеров"
subtitle="Авто: 5, Мото: 3"
icon={<Users className="w-4 h-4 text-krona-gold" />}
/>
<StatCard
title="Среднее время рейса"
value="26 минут"
change={{ value: '-2 мин', trend: 'up' }}
subtitle="Городской экспресс"
icon={<Clock className="w-4 h-4 text-krona-green" />}
/>
</div>
);
case 'retail':
return (
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3.5">
<StatCard
title="Выручка магазина"
value={formatCurrency(248000)}
change={{ value: '+19%', trend: 'up' }}
subtitle="Чеков сегодня: 142"
icon={<ShoppingBag className="w-4 h-4 text-krona-green" />}
/>
<StatCard
title="Средний чек"
value={formatCurrency(1746)}
change={{ value: '+4%', trend: 'up' }}
subtitle="Товаров в чеке: 3.2"
icon={<TrendingUp className="w-4 h-4 text-krona-gold" />}
/>
<StatCard
title="Товарных позиций на складе"
value="1 420 SKU"
subtitle="Критический остаток: 4 SKU"
icon={<Package className="w-4 h-4 text-krona-info" />}
/>
<StatCard
title="Оборачиваемость склада"
value="14.2 дня"
change={{ value: '-1.5 дн', trend: 'up' }}
subtitle="Маржинальность: 42.4%"
icon={<DollarSign className="w-4 h-4 text-krona-green" />}
/>
</div>
);
case 'beauty_services':
return (
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3.5">
<StatCard
title="Выручка салона"
value={formatCurrency(124000)}
change={{ value: '+12%', trend: 'up' }}
subtitle="18 визитов сегодня"
icon={<DollarSign className="w-4 h-4 text-krona-green" />}
/>
<StatCard
title="Загрузка мастеров"
value="86%"
change={{ value: '+5%', trend: 'up' }}
subtitle="5 мастеров на смене"
icon={<Users className="w-4 h-4 text-krona-gold" />}
/>
<StatCard
title="Записей на сегодня"
value="22 клиента"
subtitle="Подтверждено: 19"
icon={<Clock className="w-4 h-4 text-krona-info" />}
/>
<StatCard
title="Средний чек визита"
value={formatCurrency(4850)}
change={{ value: '+350 ₽', trend: 'up' }}
subtitle="Услуги + уход дома"
icon={<TrendingUp className="w-4 h-4 text-krona-green" />}
/>
</div>
);
case 'wholesale_b2b':
return (
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3.5">
<StatCard
title="Объем отгрузок сегодня"
value={formatCurrency(1450000)}
change={{ value: '+24%', trend: 'up' }}
subtitle="9 партий сформировано"
icon={<DollarSign className="w-4 h-4 text-krona-green" />}
/>
<StatCard
title="Активные B2B контракты"
value="42 контрагента"
subtitle="В согласовании: 3"
icon={<Users className="w-4 h-4 text-krona-info" />}
/>
<StatCard
title="Дебиторская задолженность"
value={formatCurrency(840000)}
change={{ value: '-8%', trend: 'up' }}
subtitle="Просрочено: 95 000 ₽"
icon={<TrendingUp className="w-4 h-4 text-krona-gold" />}
/>
<StatCard
title="Загрузка распредцентра"
value="84%"
subtitle="Свободно 320 паллетомест"
icon={<Package className="w-4 h-4 text-krona-green" />}
/>
</div>
);
case 'restaurant_hall_delivery':
default:
return (
<div className="grid grid-cols-2 lg:grid-cols-5 gap-3.5">
<StatCard
title="Выручка сегодня"
value={formatCurrency(184500)}
change={{ value: '+14%', trend: 'up' }}
subtitle="План: 170 000 ₽ (108%)"
icon={<DollarSign className="w-4 h-4 text-krona-green" />}
/>
<StatCard
title="Заказы"
value="58 чеков"
change={{ value: '+8', trend: 'up' }}
subtitle="Зал: 34 • Доставка: 24"
icon={<Utensils className="w-4 h-4 text-krona-gold" />}
/>
<StatCard
title="Средний чек"
value={formatCurrency(3180)}
change={{ value: '+6.2%', trend: 'up' }}
subtitle="Зал: 3 650 ₽ • Онлайн: 2 450 ₽"
icon={<TrendingUp className="w-4 h-4 text-krona-green" />}
/>
<StatCard
title="Загрузка зала"
value="82%"
change={{ value: '+12%', trend: 'up' }}
subtitle="11 из 14 столов заняты"
icon={<Users className="w-4 h-4 text-krona-info" />}
/>
<StatCard
title="Food Cost / Маржа"
value="29.4%"
change={{ value: '-0.8%', trend: 'up' }}
subtitle="Маржинальность: 70.6%"
icon={<Package className="w-4 h-4 text-krona-green" />}
/>
</div>
);
}
};
return (
<div className="space-y-5 animate-fade-in">
{/* Top Welcome Bar */}
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3 bg-krona-surface border border-krona-border rounded-xl p-4 shadow-krona-soft">
<div>
<div className="flex items-center gap-2">
<h1 className="text-lg font-bold text-krona-primary tracking-tight">
Операционный центр • {profile.companyName}
</h1>
<Badge variant="gold" size="sm">{profile.category}</Badge>
</div>
<p className="text-xs text-krona-secondary mt-1">
Локация: <span className="font-semibold text-krona-primary">{activeLocation}</span> • Контур реального времени синхронизирован
</p>
</div>
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" onClick={() => navigate('/operations')}>
Открыть рабочий стол
</Button>
<Button variant="primary" size="sm" onClick={() => setIsQuickActionsOpen(true)}>
+ Быстрое действие
</Button>
</div>
</div>
{/* 1. ЧТО ПРОИСХОДИТ? — KPI МЕТРИКИ */}
{renderKpis()}
{/* 2. БОЛЬШОЙ ГРАФИК ВЫРУЧКИ И ЧЕКОВ */}
<div className="bg-krona-surface border border-krona-border rounded-xl p-4 shadow-krona-soft">
<div className="flex items-center justify-between mb-4">
<div>
<h3 className="text-sm font-semibold text-krona-primary">
Динамика выручки и среднего чека
</h3>
<p className="text-xs text-krona-secondary">
Почасовая разбивка с кассовых узлов и онлайн-заказов
</p>
</div>
<div className="flex items-center gap-1 bg-krona-workspace p-1 rounded-lg border border-krona-border text-xs">
<button
onClick={() => setChartPeriod('today')}
className={`px-3 py-1 rounded font-medium transition-colors ${
chartPeriod === 'today' ? 'bg-[#2F684C] text-white' : 'text-krona-secondary hover:text-krona-primary'
}`}
>
Сегодня
</button>
<button
onClick={() => setChartPeriod('week')}
className={`px-3 py-1 rounded font-medium transition-colors ${
chartPeriod === 'week' ? 'bg-[#2F684C] text-white' : 'text-krona-secondary hover:text-krona-primary'
}`}
>
Неделя
</button>
</div>
</div>
<div className="h-64 w-full">
<ResponsiveContainer width="100%" height="100%">
<AreaChart data={chartPeriod === 'today' ? REVENUE_DATA_TODAY : REVENUE_DATA_WEEK}>
<defs>
<linearGradient id="colorRev" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#2F684C" stopOpacity={0.4} />
<stop offset="95%" stopColor="#2F684C" stopOpacity={0.0} />
</linearGradient>
</defs>
<CartesianGrid strokeDasharray="3 3" stroke="#D8D5CB" opacity={0.4} />
<XAxis dataKey="time" stroke="#686C65" fontSize={11} />
<YAxis stroke="#686C65" fontSize={11} tickFormatter={(val) => `${val / 1000}k`} />
<Tooltip
formatter={(value: any) => [formatCurrency(Number(value)), 'Выручка']}
contentStyle={{
backgroundColor: '#F8F6F0',
borderColor: '#D8D5CB',
borderRadius: 8,
fontSize: 12,
}}
/>
<Area
type="monotone"
dataKey="revenue"
stroke="#2F684C"
strokeWidth={2.5}
fillOpacity={1}
fill="url(#colorRev)"
/>
</AreaChart>
</ResponsiveContainer>
</div>
</div>
{/* 3. ЧТО ТРЕБУЕТ ВНИМАНИЯ? (Alerts with direct actions) */}
<div className="bg-krona-surface border border-krona-border rounded-xl p-4 shadow-krona-soft">
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2">
<AlertTriangle className="w-4 h-4 text-krona-warning" />
<h3 className="text-sm font-semibold text-krona-primary">Требует внимания управляющего</h3>
</div>
<span className="text-xs text-krona-secondary">3 критических инцидента</span>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-3">
<div className="p-3 rounded-lg border border-krona-danger/40 bg-krona-danger/10 flex flex-col justify-between">
<div>
<div className="flex items-center gap-1.5 text-xs font-semibold text-krona-danger">
<AlertCircle className="w-4 h-4" />
<span>Критический остаток</span>
</div>
<p className="text-xs text-krona-primary mt-1.5 leading-relaxed">
Лосось охлажденный: осталось <strong>2.4 кг</strong> (порог 6 кг). Хватит на ~2.5 часа работы кухни.
</p>
</div>
<Button
variant="outline"
size="sm"
className="mt-3 bg-krona-surface text-xs justify-between"
onClick={() => navigate('/operations/inventory')}
>
<span>Заявка поставщику</span>
<ArrowRight className="w-3.5 h-3.5" />
</Button>
</div>
<div className="p-3 rounded-lg border border-krona-warning/40 bg-krona-warning/10 flex flex-col justify-between">
<div>
<div className="flex items-center gap-1.5 text-xs font-semibold text-krona-warning">
<Clock className="w-4 h-4" />
<span>Задержка на кухне (KDS)</span>
</div>
<p className="text-xs text-krona-primary mt-1.5 leading-relaxed">
Заказ #104 на <strong>Стол 4</strong> находится в работе уже 18 минут при норме 12 минут.
</p>
</div>
<Button
variant="outline"
size="sm"
className="mt-3 bg-krona-surface text-xs justify-between"
onClick={() => navigate('/operations/kds')}
>
<span>Открыть KDS кухни</span>
<ArrowRight className="w-3.5 h-3.5" />
</Button>
</div>
<div className="p-3 rounded-lg border border-krona-info/40 bg-krona-info/10 flex flex-col justify-between">
<div>
<div className="flex items-center gap-1.5 text-xs font-semibold text-krona-info">
<Truck className="w-4 h-4" />
<span>Наличные у курьера</span>
</div>
<p className="text-xs text-krona-primary mt-1.5 leading-relaxed">
Курьер Алексей Смирнов держит <strong>24 500 ₽</strong> наличными после 7 доставок.
</p>
</div>
<Button
variant="outline"
size="sm"
className="mt-3 bg-krona-surface text-xs justify-between"
onClick={() => navigate('/operations/delivery')}
>
<span>Принять инкассацию</span>
<ArrowRight className="w-3.5 h-3.5" />
</Button>
</div>
</div>
</div>
{/* 4. СОСТОЯНИЕ БИЗНЕСА СЕЙЧАС (Live Pulse Grid) */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-3.5">
{capabilities.dining && (
<div className="bg-krona-surface border border-krona-border rounded-lg p-3.5 shadow-krona-soft">
<div className="flex items-center justify-between text-xs mb-2">
<span className="font-semibold text-krona-primary">Зал и столы</span>
<Badge variant="green" size="sm">82% занято</Badge>
</div>
<div className="text-xs text-krona-secondary space-y-1">
<div className="flex justify-between">
<span>Открытых чеков:</span>
<span className="font-medium text-krona-primary">11 столов</span>
</div>
<div className="flex justify-between">
<span>Среднее время посадки:</span>
<span className="font-medium text-krona-primary">48 минут</span>
</div>
</div>
<button
onClick={() => navigate('/operations/dining')}
className="mt-2.5 text-xs text-krona-green font-medium hover:underline block"
>
Схема зала →
</button>
</div>
)}
{capabilities.kds && (
<div className="bg-krona-surface border border-krona-border rounded-lg p-3.5 shadow-krona-soft">
<div className="flex items-center justify-between text-xs mb-2">
<span className="font-semibold text-krona-primary">Кухня (KDS)</span>
<Badge variant="gold" size="sm">{kdsTickets.length} в работе</Badge>
</div>
<div className="text-xs text-krona-secondary space-y-1">
<div className="flex justify-between">
<span>Суши-цех:</span>
<span className="font-medium text-krona-primary">4 блюда</span>
</div>
<div className="flex justify-between">
<span>Горячий цех:</span>
<span className="font-medium text-krona-primary">2 блюда</span>
</div>
</div>
<button
onClick={() => navigate('/operations/kds')}
className="mt-2.5 text-xs text-krona-green font-medium hover:underline block"
>
Кухонный экран →
</button>
</div>
)}
{capabilities.delivery && (
<div className="bg-krona-surface border border-krona-border rounded-lg p-3.5 shadow-krona-soft">
<div className="flex items-center justify-between text-xs mb-2">
<span className="font-semibold text-krona-primary">Доставка & Курьеры</span>
<Badge variant="info" size="sm">3 в пути</Badge>
</div>
<div className="text-xs text-krona-secondary space-y-1">
<div className="flex justify-between">
<span>Среднее время доставки:</span>
<span className="font-medium text-krona-primary">34 мин</span>
</div>
<div className="flex justify-between">
<span>Курьеров на линии:</span>
<span className="font-medium text-krona-primary">4 сотрудника</span>
</div>
</div>
<button
onClick={() => navigate('/operations/delivery')}
className="mt-2.5 text-xs text-krona-green font-medium hover:underline block"
>
Управление доставкой →
</button>
</div>
)}
<div className="bg-krona-surface border border-krona-border rounded-lg p-3.5 shadow-krona-soft">
<div className="flex items-center justify-between text-xs mb-2">
<span className="font-semibold text-krona-primary">Финансы & Кассы</span>
<Badge variant="green" size="sm">Смены открыты</Badge>
</div>
<div className="text-xs text-krona-secondary space-y-1">
<div className="flex justify-between">
<span>Наличные в кассе:</span>
<span className="font-medium text-krona-primary">42 800 ₽</span>
</div>
<div className="flex justify-between">
<span>Безнал / Эквайринг:</span>
<span className="font-medium text-krona-primary">141 700 ₽</span>
</div>
</div>
<button
onClick={() => navigate('/finance')}
className="mt-2.5 text-xs text-krona-green font-medium hover:underline block"
>
ДДС & Сальдо →
</button>
</div>
</div>
</div>
);
};

View file

@ -0,0 +1,79 @@
import React, { useState } from 'react';
import { INITIAL_EMPLOYEES, EmployeeEntity } from '../../mock/mockData';
import { Badge } from '../../shared/components/Badge';
import { Button } from '../../shared/components/Button';
import { formatCurrency } from '../../shared/utils/formatters';
import { UserCheck, Clock, Star, Phone, Plus, Calendar } from 'lucide-react';
export const EmployeesModule: React.FC = () => {
const [employees, setEmployees] = useState<EmployeeEntity[]>(INITIAL_EMPLOYEES);
return (
<div className="space-y-4 animate-fade-in">
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3 bg-krona-surface p-3.5 rounded-xl border border-krona-border shadow-krona-soft">
<div>
<h2 className="text-sm font-bold text-krona-primary">
Сотрудники, смены и табель учета рабочего времени
</h2>
<p className="text-xs text-krona-secondary">
Управление графиками работы, ставками, опозданиями и персональным KPI
</p>
</div>
<div className="flex items-center gap-2">
<Button variant="primary" size="sm">
<Plus className="w-3.5 h-3.5 mr-1" />
Добавить сотрудника
</Button>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3.5">
{employees.map((emp) => (
<div
key={emp.id}
className="p-4 rounded-xl border border-krona-border bg-krona-surface shadow-krona-soft space-y-3"
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2.5">
<div className="w-10 h-10 rounded-full bg-krona-workspace border border-krona-border flex items-center justify-center font-bold text-sm text-krona-primary">
{emp.avatarInitials}
</div>
<div>
<h4 className="text-xs font-bold text-krona-primary">{emp.name}</h4>
<span className="text-[11px] text-krona-secondary">{emp.role}</span>
</div>
</div>
<Badge variant={emp.status === 'on_shift' ? 'green' : 'neutral'} size="sm">
{emp.status === 'on_shift' ? 'На смене' : 'Выходной'}
</Badge>
</div>
<div className="grid grid-cols-2 gap-2 text-xs py-1 border-y border-krona-border/50">
<div>
<span className="text-krona-secondary block text-[10px]">График сегодня:</span>
<span className="font-semibold text-krona-primary flex items-center gap-1">
<Clock className="w-3 h-3 text-krona-gold" />
{emp.shiftHours}
</span>
</div>
<div>
<span className="text-krona-secondary block text-[10px]">Рейтинг качества:</span>
<span className="font-semibold text-krona-primary flex items-center gap-1">
<Star className="w-3 h-3 text-krona-gold fill-krona-gold" />
{emp.rating} / 5.0
</span>
</div>
</div>
<div className="flex items-center justify-between text-xs pt-1">
<span className="text-krona-secondary">Телефон:</span>
<span className="font-mono text-krona-primary font-medium">{emp.phone}</span>
</div>
</div>
))}
</div>
</div>
);
};

View file

@ -0,0 +1,205 @@
import React, { useState } from 'react';
import { INITIAL_FINANCE, FinanceEntry } from '../../mock/mockData';
import { Badge } from '../../shared/components/Badge';
import { Button } from '../../shared/components/Button';
import { StatCard } from '../../shared/components/StatCard';
import { Modal } from '../../shared/components/Modal';
import { Input } from '../../shared/components/Input';
import { formatCurrency } from '../../shared/utils/formatters';
import { BadgeDollarSign, TrendingUp, TrendingDown, Plus, Wallet, CreditCard } from 'lucide-react';
export const FinanceModule: React.FC = () => {
const [entries, setEntries] = useState<FinanceEntry[]>(INITIAL_FINANCE);
const [isAddModalOpen, setIsAddModalOpen] = useState(false);
const [entryType, setEntryType] = useState<'income' | 'expense'>('expense');
const [category, setCategory] = useState('');
const [amount, setAmount] = useState('');
const [account, setAccount] = useState<'Касса Горский' | 'Расчетный счет (Точка)'>('Касса Горский');
const [comment, setComment] = useState('');
const totalIncome = entries.filter(e => e.type === 'income').reduce((s, e) => s + e.amount, 0);
const totalExpense = entries.filter(e => e.type === 'expense').reduce((s, e) => s + e.amount, 0);
const netCashFlow = totalIncome - totalExpense;
const handleAddEntry = (e: React.FormEvent) => {
e.preventDefault();
const newEnt: FinanceEntry = {
id: `fin-${Date.now()}`,
date: new Date().toLocaleDateString('ru-RU'),
type: entryType,
category: category || (entryType === 'income' ? 'Прочая выручка' : 'Хоз. расходы'),
amount: Number(amount) || 0,
account,
comment,
status: 'completed',
};
setEntries([newEnt, ...entries]);
setIsAddModalOpen(false);
setAmount('');
setCategory('');
setComment('');
};
return (
<div className="space-y-4 animate-fade-in">
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3 bg-krona-surface p-3.5 rounded-xl border border-krona-border shadow-krona-soft">
<div>
<h2 className="text-sm font-bold text-krona-primary">
Финансы, кассовые смены и отчет ДДС
</h2>
<p className="text-xs text-krona-secondary">
Учет движения денежных средств (Cash Flow), безналичных счетов и инкассаций
</p>
</div>
<div className="flex items-center gap-2">
<Button
variant="primary"
size="sm"
onClick={() => {
setEntryType('expense');
setIsAddModalOpen(true);
}}
>
<Plus className="w-3.5 h-3.5 mr-1" />
Расходный ордер
</Button>
<Button
variant="gold"
size="sm"
onClick={() => {
setEntryType('income');
setIsAddModalOpen(true);
}}
>
<Plus className="w-3.5 h-3.5 mr-1" />
Приход денег
</Button>
</div>
</div>
{/* Financial KPIs */}
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3.5">
<StatCard
title="Поступления за день"
value={formatCurrency(totalIncome)}
change={{ value: '+14%', trend: 'up' }}
subtitle="Выручка касс и безнал"
icon={<TrendingUp className="w-4 h-4 text-krona-green" />}
/>
<StatCard
title="Выплаты и расходы"
value={formatCurrency(totalExpense)}
change={{ value: '-5%', trend: 'neutral' }}
subtitle="Закупки сырья и упаковки"
icon={<TrendingDown className="w-4 h-4 text-krona-danger" />}
/>
<StatCard
title="Чистый денежный поток (ДДС)"
value={formatCurrency(netCashFlow)}
change={{ value: '+22%', trend: 'up' }}
subtitle="Операционная прибыль"
icon={<Wallet className="w-4 h-4 text-krona-gold" />}
/>
</div>
{/* Transactions Table */}
<div className="bg-krona-surface border border-krona-border rounded-xl shadow-krona-soft overflow-hidden p-4">
<h4 className="text-xs font-bold text-krona-primary uppercase tracking-wider mb-3">
Журнал кассовых и банковских операций
</h4>
<div className="overflow-x-auto">
<table className="w-full text-left text-xs border-collapse">
<thead className="bg-krona-workspace/80 text-krona-secondary border-b border-krona-border">
<tr>
<th className="p-3 font-semibold">Дата</th>
<th className="p-3 font-semibold">Тип</th>
<th className="p-3 font-semibold">Статья ДДС</th>
<th className="p-3 font-semibold">Счет / Касса</th>
<th className="p-3 font-semibold">Комментарий</th>
<th className="p-3 font-semibold text-right">Сумма</th>
</tr>
</thead>
<tbody className="divide-y divide-krona-border/50">
{entries.map((entry) => (
<tr key={entry.id} className="hover:bg-krona-workspace/50 transition-colors">
<td className="p-3 font-mono text-krona-secondary">{entry.date}</td>
<td className="p-3">
<Badge variant={entry.type === 'income' ? 'green' : 'danger'} size="sm">
{entry.type === 'income' ? 'Поступление' : 'Расход'}
</Badge>
</td>
<td className="p-3 font-medium text-krona-primary">{entry.category}</td>
<td className="p-3 text-krona-secondary">{entry.account}</td>
<td className="p-3 text-krona-secondary">{entry.comment}</td>
<td
className={`p-3 font-bold text-right ${
entry.type === 'income' ? 'text-krona-green' : 'text-krona-danger'
}`}
>
{entry.type === 'income' ? '+' : '-'}{formatCurrency(entry.amount)}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
{/* Add Finance Entry Modal */}
{isAddModalOpen && (
<Modal
isOpen={isAddModalOpen}
onClose={() => setIsAddModalOpen(false)}
title={entryType === 'income' ? 'Внесение дохода' : 'Оформление расхода'}
subtitle="Запись в финансовый журнал ДДС"
maxWidth="sm"
>
<form onSubmit={handleAddEntry} className="space-y-3 text-xs">
<Input
label="Сумма операции (₽)"
type="number"
value={amount}
onChange={(e) => setAmount(e.target.value)}
placeholder="5000"
required
/>
<Input
label="Статья расходов / доходов"
value={category}
onChange={(e) => setCategory(e.target.value)}
placeholder="Закупка хозтоваров / Оплата поставщику"
/>
<div className="flex flex-col gap-1.5 text-left">
<label className="text-xs font-medium text-krona-secondary">Счет списания/поступления</label>
<select
value={account}
onChange={(e) => setAccount(e.target.value as any)}
className="w-full bg-krona-surface border border-krona-border rounded-md px-3 py-2 text-xs text-krona-primary"
>
<option value="Касса Горский">Касса Горский</option>
<option value="Расчетный счет (Точка)">Расчетный счет (Точка)</option>
</select>
</div>
<Input
label="Комментарий к платежу"
value={comment}
onChange={(e) => setComment(e.target.value)}
placeholder="Номер накладной, основание..."
/>
<div className="pt-2 flex justify-end gap-2">
<Button type="button" variant="secondary" onClick={() => setIsAddModalOpen(false)}>
Отмена
</Button>
<Button type="submit" variant="primary">
Провести проводку
</Button>
</div>
</form>
</Modal>
)}
</div>
);
};

View file

@ -0,0 +1,133 @@
import React, { useState } from 'react';
import { INITIAL_TICKETS, TicketEntity } from '../../mock/mockData';
import { Badge } from '../../shared/components/Badge';
import { Button } from '../../shared/components/Button';
import { Modal } from '../../shared/components/Modal';
import { Headset, AlertCircle, Clock, CheckCircle2, Sparkles, MessageSquare } from 'lucide-react';
export const HelpDeskModule: React.FC = () => {
const [tickets, setTickets] = useState<TicketEntity[]>(INITIAL_TICKETS);
const [selectedTicket, setSelectedTicket] = useState<TicketEntity | null>(null);
const getPriorityBadge = (p: TicketEntity['priority']) => {
switch (p) {
case 'urgent':
return <Badge variant="danger" size="sm">Срочно (15 мин SLA)</Badge>;
case 'high':
return <Badge variant="warning" size="sm">Высокий</Badge>;
case 'medium':
return <Badge variant="gold" size="sm">Средний</Badge>;
default:
return <Badge variant="neutral" size="sm">Обычный</Badge>;
}
};
return (
<div className="space-y-4 animate-fade-in">
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3 bg-krona-surface p-3.5 rounded-xl border border-krona-border shadow-krona-soft">
<div>
<h2 className="text-sm font-bold text-krona-primary">
HelpDesk & Центр решения обращений клиентов
</h2>
<p className="text-xs text-krona-secondary">
Очередь инцидентов, контроль SLA ответа, база знаний и AI-подсказки
</p>
</div>
<div className="flex items-center gap-2 text-xs">
<Badge variant="danger" size="md">1 срочный инцидент</Badge>
<Badge variant="green" size="md">SLA 98.4%</Badge>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
{tickets.map((t) => (
<div
key={t.id}
onClick={() => setSelectedTicket(t)}
className="p-4 rounded-xl border border-krona-border bg-krona-surface hover:border-krona-border-strong shadow-krona-soft cursor-pointer transition-all space-y-3"
>
<div className="flex items-center justify-between">
<span className="font-mono font-bold text-xs text-krona-secondary">{t.id}</span>
{getPriorityBadge(t.priority)}
</div>
<h4 className="text-xs font-bold text-krona-primary leading-snug line-clamp-2">
{t.subject}
</h4>
<div className="space-y-1 text-xs text-krona-secondary border-t border-krona-border/40 pt-2">
<div className="flex justify-between">
<span>Клиент:</span>
<span className="font-medium text-krona-primary">{t.clientName}</span>
</div>
<div className="flex justify-between">
<span>Канал обращения:</span>
<span className="capitalize">{t.channel}</span>
</div>
<div className="flex justify-between">
<span>Остаток по SLA:</span>
<span className={`font-mono font-bold ${t.slaMinutesLeft < 15 ? 'text-krona-danger' : 'text-krona-primary'}`}>
{t.slaMinutesLeft} мин
</span>
</div>
</div>
<div className="pt-2 border-t border-krona-border/40 flex justify-end">
<button className="text-xs text-krona-green font-medium hover:underline">
Разрешить инцидент →
</button>
</div>
</div>
))}
</div>
{/* Ticket Modal with AI suggestion */}
{selectedTicket && (
<Modal
isOpen={Boolean(selectedTicket)}
onClose={() => setSelectedTicket(null)}
title={`Тикет: ${selectedTicket.id}`}
subtitle={`Клиент: ${selectedTicket.clientName} (${selectedTicket.createdTime})`}
maxWidth="md"
footer={
<div className="flex gap-2 w-full justify-end">
<Button variant="secondary" onClick={() => setSelectedTicket(null)}>
Закрыть
</Button>
<Button
variant="primary"
onClick={() => {
setTickets(tickets.filter(x => x.id !== selectedTicket.id));
setSelectedTicket(null);
}}
>
Отметить решенным
</Button>
</div>
}
>
<div className="space-y-3 text-xs">
<div className="p-3 bg-krona-workspace rounded-lg border border-krona-border">
<span className="font-bold text-krona-primary block mb-1">{selectedTicket.subject}</span>
<p className="text-krona-secondary">
Обращение поступило через {selectedTicket.channel} в {selectedTicket.createdTime}. Назначено на сотрудника: {selectedTicket.assignee}.
</p>
</div>
{/* AI Suggestion Box */}
<div className="p-3 bg-krona-gold/10 border border-krona-gold/30 rounded-lg space-y-1.5">
<div className="flex items-center gap-1.5 text-krona-gold-text font-bold text-xs">
<Sparkles className="w-3.5 h-3.5 text-krona-gold" />
<span>Рекомендация Krona AI:</span>
</div>
<p className="text-krona-primary leading-relaxed">
Начислить клиенту 300 бонусных баллов с извинениями и отправить курьера с недостающими соусами и комплиментом (морс 0.5л). Клиент относится к категории VIP (LTV 84 200 ₽).
</p>
</div>
</div>
</Modal>
)}
</div>
);
};

View file

@ -0,0 +1,169 @@
import React, { useState } from 'react';
import { INITIAL_INVENTORY, InventoryItemEntity } from '../../mock/mockData';
import { Badge } from '../../shared/components/Badge';
import { Button } from '../../shared/components/Button';
import { Modal } from '../../shared/components/Modal';
import { Input } from '../../shared/components/Input';
import { formatCurrency } from '../../shared/utils/formatters';
import { Boxes, AlertTriangle, ArrowRightLeft, Trash2, Plus, Search } from 'lucide-react';
export const InventoryModule: React.FC = () => {
const [items, setItems] = useState<InventoryItemEntity[]>(INITIAL_INVENTORY);
const [search, setSearch] = useState('');
const [isWriteoffModalOpen, setIsWriteoffModalOpen] = useState(false);
const [selectedItem, setSelectedItem] = useState<InventoryItemEntity | null>(null);
const [writeoffAmount, setWriteoffAmount] = useState('1');
const [writeoffReason, setWriteoffReason] = useState('Порча / Срок годности');
const handleWriteoff = (e: React.FormEvent) => {
e.preventDefault();
if (!selectedItem) return;
const qty = Number(writeoffAmount) || 0;
setItems((prev) =>
prev.map((it) =>
it.id === selectedItem.id
? { ...it, currentStock: Math.max(0, it.currentStock - qty) }
: it
)
);
setIsWriteoffModalOpen(false);
};
const filteredItems = items.filter(
(i) => i.name.toLowerCase().includes(search.toLowerCase()) || i.sku.includes(search)
);
return (
<div className="space-y-4 animate-fade-in">
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3 bg-krona-surface p-3.5 rounded-xl border border-krona-border shadow-krona-soft">
<div>
<h2 className="text-sm font-bold text-krona-primary">
Складской учет, остатки и партии
</h2>
<p className="text-xs text-krona-secondary">
Контроль неснижаемых остатков, себестоимости партий и перемещений между складами
</p>
</div>
<div className="flex items-center gap-2">
<Button
variant="secondary"
size="sm"
onClick={() => {
setSelectedItem(items[0]);
setIsWriteoffModalOpen(true);
}}
>
<Trash2 className="w-3.5 h-3.5 mr-1" />
Оформить списание
</Button>
<Button variant="primary" size="sm">
<Plus className="w-3.5 h-3.5 mr-1" />
Приходная накладная
</Button>
</div>
</div>
{/* Stock Table */}
<div className="bg-krona-surface border border-krona-border rounded-xl shadow-krona-soft p-4 space-y-3">
<div className="relative w-72">
<Search className="w-3.5 h-3.5 absolute left-2.5 top-2.5 text-krona-secondary" />
<input
type="text"
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Поиск по артикулу, названию сырья..."
className="w-full pl-8 pr-3 py-1.5 text-xs bg-krona-workspace border border-krona-border rounded-lg text-krona-primary placeholder:text-krona-secondary focus:outline-none focus:border-krona-green"
/>
</div>
<div className="overflow-x-auto">
<table className="w-full text-left text-xs border-collapse">
<thead className="bg-krona-workspace/80 text-krona-secondary border-b border-krona-border">
<tr>
<th className="p-3 font-semibold">Артикул</th>
<th className="p-3 font-semibold">Наименование позиции</th>
<th className="p-3 font-semibold">Склад</th>
<th className="p-3 font-semibold">Текущий остаток</th>
<th className="p-3 font-semibold">Мин. порог</th>
<th className="p-3 font-semibold">Себестоимость</th>
<th className="p-3 font-semibold">Поставщик</th>
<th className="p-3 font-semibold text-center">Статус</th>
</tr>
</thead>
<tbody className="divide-y divide-krona-border/50">
{filteredItems.map((item) => (
<tr key={item.id} className="hover:bg-krona-workspace/50 transition-colors">
<td className="p-3 font-mono font-semibold text-krona-secondary">{item.sku}</td>
<td className="p-3 font-medium text-krona-primary">{item.name}</td>
<td className="p-3 text-krona-secondary">{item.warehouse}</td>
<td className="p-3 font-bold text-krona-primary">
{item.currentStock} {item.unit}
</td>
<td className="p-3 text-krona-secondary font-mono">
{item.minStock} {item.unit}
</td>
<td className="p-3 text-krona-primary">{formatCurrency(item.unitCost)} / {item.unit}</td>
<td className="p-3 text-krona-secondary">{item.supplier}</td>
<td className="p-3 text-center">
{item.status === 'critical' ? (
<Badge variant="danger" size="sm">Критический!</Badge>
) : item.status === 'low' ? (
<Badge variant="warning" size="sm">Заканчивается</Badge>
) : (
<Badge variant="green" size="sm">В норме</Badge>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
{/* Writeoff Modal */}
{isWriteoffModalOpen && selectedItem && (
<Modal
isOpen={isWriteoffModalOpen}
onClose={() => setIsWriteoffModalOpen(false)}
title="Списание остатков со склада"
subtitle={`Позиция: ${selectedItem.name} (${selectedItem.sku})`}
maxWidth="sm"
>
<form onSubmit={handleWriteoff} className="space-y-3 text-xs">
<Input
label={`Количество для списания (${selectedItem.unit})`}
type="number"
step="0.1"
value={writeoffAmount}
onChange={(e) => setWriteoffAmount(e.target.value)}
required
/>
<div className="flex flex-col gap-1.5 text-left">
<label className="text-xs font-medium text-krona-secondary">Причина списания</label>
<select
value={writeoffReason}
onChange={(e) => setWriteoffReason(e.target.value)}
className="w-full bg-krona-surface border border-krona-border rounded-md px-3 py-2 text-xs text-krona-primary"
>
<option>Порча / Истечение срока годности</option>
<option>Брак при приемке</option>
<option>Технологические потери кухни</option>
<option>Инвентаризационная недостача</option>
</select>
</div>
<div className="pt-2 flex justify-end gap-2">
<Button type="button" variant="secondary" onClick={() => setIsWriteoffModalOpen(false)}>
Отмена
</Button>
<Button type="submit" variant="danger">
Списать
</Button>
</div>
</form>
</Modal>
)}
</div>
);
};

View file

@ -0,0 +1,91 @@
import React from 'react';
import { Badge } from '../../shared/components/Badge';
import { Button } from '../../shared/components/Button';
import { StatCard } from '../../shared/components/StatCard';
import { Megaphone, Sparkles, Send, Tag, TrendingUp, Users, Calendar } from 'lucide-react';
export const MarketingModule: React.FC = () => {
return (
<div className="space-y-4 animate-fade-in">
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3 bg-krona-surface p-3.5 rounded-xl border border-krona-border shadow-krona-soft">
<div>
<h2 className="text-sm font-bold text-krona-primary">
Маркетинг, промо-акции и удержание гостей
</h2>
<p className="text-xs text-krona-secondary">
Управление промокодами, триггерными рассылками и генерация контент-плана с AI
</p>
</div>
<div className="flex items-center gap-2">
<Button variant="gold" size="sm">
<Sparkles className="w-3.5 h-3.5 mr-1" />
AI Генератор акций
</Button>
<Button variant="primary" size="sm">
<Megaphone className="w-3.5 h-3.5 mr-1" />
Запустить рассылку
</Button>
</div>
</div>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3.5">
<StatCard
title="Выручка по промокодам"
value="48 200 ₽"
change={{ value: '+24%', trend: 'up' }}
subtitle="Сентябрьская кампания"
icon={<Tag className="w-4 h-4 text-krona-gold" />}
/>
<StatCard
title="Конверсия из рассылки в заказ"
value="18.4%"
change={{ value: '+3.1%', trend: 'up' }}
subtitle="Telegram & Push"
icon={<TrendingUp className="w-4 h-4 text-krona-green" />}
/>
<StatCard
title="Возвращено 'спящих' клиентов"
value="34 гостя"
subtitle="После авто-триггера 'Мы соскучились'"
icon={<Users className="w-4 h-4 text-krona-info" />}
/>
</div>
{/* Campaigns list */}
<div className="bg-krona-surface border border-krona-border rounded-xl shadow-krona-soft p-4 space-y-3">
<h4 className="text-xs font-bold text-krona-primary uppercase tracking-wider">
Активные кампании и промокоды
</h4>
<div className="space-y-2 text-xs">
<div className="p-3 rounded-lg border border-krona-border bg-krona-workspace flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="p-2 rounded bg-krona-gold/10 text-krona-gold">
<Tag className="w-4 h-4" />
</div>
<div>
<h5 className="font-bold text-krona-primary">Промокод: AUTUMN20 (-20% на роллы)</h5>
<span className="text-krona-secondary text-[11px]">Канал: Telegram бот • Применений: 142 • Выручка: 98 000 ₽</span>
</div>
</div>
<Badge variant="green" size="sm">Активна</Badge>
</div>
<div className="p-3 rounded-lg border border-krona-border bg-krona-workspace flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="p-2 rounded bg-krona-green/10 text-krona-green">
<Send className="w-4 h-4" />
</div>
<div>
<h5 className="font-bold text-krona-primary">Триггерная рассылка: Возврат спящих клиентов</h5>
<span className="text-krona-secondary text-[11px]">Сегмент: Без визитов более 30 дней • Отправлено: 84 • Конверсия: 22%</span>
</div>
</div>
<Badge variant="green" size="sm">Автоматизирована</Badge>
</div>
</div>
</div>
</div>
);
};

View file

@ -0,0 +1,123 @@
import React from 'react';
import { useDemoStore } from '../../mock/store';
import { Badge } from '../../shared/components/Badge';
import { Button } from '../../shared/components/Button';
import { Car, Wrench, CheckCircle2, Clock, Plus } from 'lucide-react';
import { useNavigate } from 'react-router-dom';
interface BayItem {
id: string;
name: string;
type: string;
status: 'busy' | 'free' | 'maintenance';
vehicleOrMaster?: string;
orderNumber?: string;
timeRemaining?: string;
workOrderType?: string;
}
const BAYS_DATA: BayItem[] = [
{ id: 'b1', name: 'Пост 1 (Мойка + Детейлинг)', type: 'Моечный пост', status: 'busy', vehicleOrMaster: 'BMW X5 (А001АА 154)', orderNumber: 'WO-880', timeRemaining: '12 мин', workOrderType: 'Комплекс Люкс' },
{ id: 'b2', name: 'Пост 2 (Подъёмник гидравлический)', type: 'Слесарный пост', status: 'busy', vehicleOrMaster: 'Toyota Land Cruiser (Р777УУ 154)', orderNumber: 'WO-881', timeRemaining: '25 мин', workOrderType: 'Замена масла + колодки' },
{ id: 'b3', name: 'Пост 3 (Подъёмник 4-стоечный / Сход-развал)', type: 'Сход-развал', status: 'free' },
{ id: 'b4', name: 'Пост 4 (Детейлинг бокс / Керамика)', type: 'Детейлинг', status: 'busy', vehicleOrMaster: 'Porsche Cayenne (В500ВВ 154)', orderNumber: 'WO-882', timeRemaining: '45 мин', workOrderType: 'Керамика кузова' },
{ id: 'b5', name: 'Пост 5 (Быстрая мойка кузова)', type: 'Моечный пост', status: 'free' },
{ id: 'b6', name: 'Пост 6 (Шиномонтаж / Балансировка)', type: 'Шиномонтаж', status: 'free' },
];
export const BaysView: React.FC = () => {
const { getActiveProfile, setIsQuickActionsOpen } = useDemoStore();
const profile = getActiveProfile();
const navigate = useNavigate();
return (
<div className="space-y-4 animate-fade-in">
<div className="flex items-center justify-between bg-krona-surface p-3.5 rounded-xl border border-krona-border shadow-krona-soft">
<div>
<h2 className="text-sm font-bold text-krona-primary">
Посты, подъемники и рабочие зоны ({profile.terminology.bookingResourceLabel})
</h2>
<p className="text-xs text-krona-secondary">
Мониторинг загрузки постов, времени обслуживания и закрепленных заказ-нарядов
</p>
</div>
<Button variant="primary" size="sm" onClick={() => setIsQuickActionsOpen(true)}>
<Plus className="w-3.5 h-3.5 mr-1" />
Заезд на пост
</Button>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
{BAYS_DATA.map((bay) => {
const isBusy = bay.status === 'busy';
return (
<div
key={bay.id}
className={`p-4 rounded-xl border shadow-krona-soft flex flex-col justify-between space-y-3 transition-all ${
isBusy
? 'border-krona-gold/60 bg-krona-surface'
: 'border-krona-border bg-krona-surface/60 opacity-90'
}`}
>
<div>
<div className="flex items-center justify-between mb-2">
<span className="font-bold text-xs text-krona-primary">{bay.name}</span>
<Badge variant={isBusy ? 'gold' : 'green'} size="sm">
{isBusy ? 'Занят' : 'Свободен'}
</Badge>
</div>
<div className="text-[11px] text-krona-secondary mb-2">{bay.type}</div>
{isBusy ? (
<div className="p-3 rounded-lg bg-krona-workspace space-y-1.5 text-xs">
<div className="flex items-center gap-1.5 font-bold text-krona-primary">
<Car className="w-3.5 h-3.5 text-krona-info" />
<span>{bay.vehicleOrMaster}</span>
</div>
<div className="text-krona-secondary text-[11px]">{bay.workOrderType}</div>
<div className="flex items-center justify-between pt-1 border-t border-krona-border/60 text-[11px]">
<span className="text-krona-secondary font-mono">{bay.orderNumber}</span>
<span className="text-krona-warning font-semibold flex items-center gap-1">
<Clock className="w-3 h-3" />
Осталось {bay.timeRemaining}
</span>
</div>
</div>
) : (
<div className="py-6 text-center text-xs text-krona-secondary">
Пост свободен. Готов к приему автомобиля.
</div>
)}
</div>
<div className="pt-2 border-t border-krona-border/60 flex justify-end gap-2">
{isBusy ? (
<Button
variant="outline"
size="sm"
className="w-full text-xs"
onClick={() => navigate('/operations/work-orders')}
>
Открыть заказ-наряд
</Button>
) : (
<Button
variant="primary"
size="sm"
className="w-full text-xs"
onClick={() => setIsQuickActionsOpen(true)}
>
Назначить автомобиль
</Button>
)}
</div>
</div>
);
})}
</div>
</div>
);
};

View file

@ -0,0 +1,127 @@
import React, { useState } from 'react';
import { useDemoStore, BookingSlot } from '../../mock/store';
import { Badge } from '../../shared/components/Badge';
import { Button } from '../../shared/components/Button';
import { formatCurrency } from '../../shared/utils/formatters';
import { Calendar, Clock, User, Phone, CheckCircle, Plus, XCircle } from 'lucide-react';
export const BookingCalendarView: React.FC = () => {
const { bookings, updateBookingStatus, setIsQuickActionsOpen, getActiveProfile } = useDemoStore();
const profile = getActiveProfile();
const [filterResource, setFilterResource] = useState<string>('Все');
const getStatusBadge = (status: BookingSlot['status']) => {
switch (status) {
case 'confirmed':
return <Badge variant="gold" size="sm">Подтверждена</Badge>;
case 'seated':
return <Badge variant="green" size="sm">Гость пришел / Заезд</Badge>;
case 'completed':
return <Badge variant="neutral" size="sm">Завершено</Badge>;
case 'cancelled':
return <Badge variant="danger" size="sm">Отменена / No-Show</Badge>;
}
};
return (
<div className="space-y-4 animate-fade-in">
<div className="flex items-center justify-between bg-krona-surface p-3.5 rounded-xl border border-krona-border shadow-krona-soft">
<div>
<h2 className="text-sm font-bold text-krona-primary">
Журнал бронирования & Запись ресурсов ({profile.terminology.bookingResourceLabel})
</h2>
<p className="text-xs text-krona-secondary">
Сетка временных слотов, подтверждение депозитов и контроль неявок (no-show)
</p>
</div>
<Button variant="primary" size="sm" onClick={() => setIsQuickActionsOpen(true)}>
<Plus className="w-3.5 h-3.5 mr-1.5" />
Записать клиента
</Button>
</div>
{/* Slots List / Grid */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3.5">
{bookings.map((slot) => (
<div
key={slot.id}
className="p-4 rounded-xl border border-krona-border bg-krona-surface shadow-krona-soft flex flex-col justify-between space-y-3"
>
<div>
<div className="flex items-center justify-between pb-2 border-b border-krona-border/60">
<div className="flex items-center gap-1.5 font-bold text-sm text-krona-primary">
<Clock className="w-4 h-4 text-krona-gold" />
<span>{slot.time}</span>
</div>
{getStatusBadge(slot.status)}
</div>
<div className="py-2 space-y-1.5 text-xs">
<div>
<span className="text-[10px] text-krona-secondary uppercase font-semibold">
{profile.terminology.bookingResourceLabel}:
</span>
<div className="font-semibold text-krona-primary">{slot.resourceName}</div>
</div>
<div>
<span className="text-[10px] text-krona-secondary uppercase font-semibold">
Клиент:
</span>
<div className="text-krona-primary font-medium">{slot.clientName}</div>
<div className="text-[11px] text-krona-secondary font-mono">{slot.clientPhone}</div>
{slot.guestsOrCar && (
<div className="text-[11px] text-krona-secondary italic mt-0.5">{slot.guestsOrCar}</div>
)}
</div>
{slot.deposit > 0 && (
<div className="flex justify-between pt-1 border-t border-krona-border/40 text-[11px]">
<span className="text-krona-secondary">Внесен депозит:</span>
<span className="font-semibold text-krona-green">{formatCurrency(slot.deposit)}</span>
</div>
)}
</div>
</div>
<div className="pt-2 border-t border-krona-border/60 flex items-center justify-end gap-2">
{slot.status === 'confirmed' && (
<>
<Button
variant="outline"
size="sm"
className="text-xs"
onClick={() => updateBookingStatus(slot.id, 'cancelled')}
>
No-Show
</Button>
<Button
variant="primary"
size="sm"
className="text-xs"
onClick={() => updateBookingStatus(slot.id, 'seated')}
>
Прибыл
</Button>
</>
)}
{slot.status === 'seated' && (
<Button
variant="secondary"
size="sm"
className="w-full text-xs"
onClick={() => updateBookingStatus(slot.id, 'completed')}
>
Завершить визит
</Button>
)}
</div>
</div>
))}
</div>
</div>
);
};

View file

@ -0,0 +1,205 @@
import React, { useState } from 'react';
import { useDemoStore, DeliveryItem } from '../../mock/store';
import { Badge } from '../../shared/components/Badge';
import { Button } from '../../shared/components/Button';
import { formatCurrency } from '../../shared/utils/formatters';
import {
Truck,
MapPin,
Phone,
Clock,
CheckCircle2,
Navigation,
Smartphone,
CreditCard,
Banknote,
} from 'lucide-react';
export interface DeliveryViewProps {
initialMobile?: boolean;
}
export const DeliveryView: React.FC<DeliveryViewProps> = ({ initialMobile = false }) => {
const { deliveries, updateDeliveryStatus } = useDemoStore();
const [courierMobileMode, setCourierMobileMode] = useState(initialMobile);
const getStatusBadge = (status: DeliveryItem['status']) => {
switch (status) {
case 'in_transit':
return <Badge variant="info" size="sm">Курьер в пути</Badge>;
case 'delivered':
return <Badge variant="green" size="sm">Доставлен</Badge>;
case 'pending':
default:
return <Badge variant="warning" size="sm">Ожидает курьера</Badge>;
}
};
return (
<div className="space-y-4 animate-fade-in">
<div className="flex items-center justify-between bg-krona-surface p-3.5 rounded-xl border border-krona-border shadow-krona-soft">
<div>
<h2 className="text-sm font-bold text-krona-primary">
Управление доставкой & Мониторинг курьеров
</h2>
<p className="text-xs text-krona-secondary">
Логистический контур: распределение заказов, расчет времени прибытия (ETA) и контроль налички
</p>
</div>
<Button
variant={courierMobileMode ? 'gold' : 'secondary'}
size="sm"
onClick={() => setCourierMobileMode(!courierMobileMode)}
>
<Smartphone className="w-3.5 h-3.5 mr-1.5" />
{courierMobileMode ? 'Обычный диспетчерский вид' : 'Экран курьера (Mobile View)'}
</Button>
</div>
{courierMobileMode ? (
/* Courier Mobile-First Screen */
<div className="max-w-md mx-auto bg-krona-surface border-2 border-krona-gold/60 rounded-2xl p-4 shadow-xl space-y-4">
<div className="flex items-center justify-between pb-3 border-b border-krona-border">
<div className="flex items-center gap-2">
<div className="w-8 h-8 rounded-full bg-krona-gold/20 flex items-center justify-center text-krona-gold">
<Truck className="w-4 h-4" />
</div>
<div>
<h4 className="text-xs font-bold text-krona-primary">Алексей Смирнов (Авто)</h4>
<span className="text-[10px] text-krona-secondary">Смена активна • Наличные: 24 500 ₽</span>
</div>
</div>
<Badge variant="green" size="sm">Онлайн</Badge>
</div>
<div className="space-y-3">
<span className="text-xs font-semibold uppercase tracking-wider text-krona-secondary block">
Мои текущие доставки ({deliveries.filter((d) => d.status !== 'delivered').length})
</span>
{deliveries.map((del) => (
<div
key={del.id}
className="p-3.5 rounded-xl border border-krona-border bg-krona-workspace space-y-2.5"
>
<div className="flex items-center justify-between">
<span className="font-bold text-xs text-krona-primary">{del.orderId}</span>
{getStatusBadge(del.status)}
</div>
<div className="space-y-1 text-xs">
<div className="flex items-start gap-1.5 text-krona-primary font-medium">
<MapPin className="w-3.5 h-3.5 text-krona-danger shrink-0 mt-0.5" />
<span>{del.address}</span>
</div>
<div className="flex items-center justify-between pt-1 text-[11px] text-krona-secondary">
<span>{del.recipientName}</span>
<a href={`tel:${del.phone}`} className="text-krona-green font-semibold flex items-center gap-1">
<Phone className="w-3 h-3" />
{del.phone}
</a>
</div>
<div className="flex items-center justify-between pt-1 border-t border-krona-border/60">
<span className="text-krona-secondary">К оплате:</span>
<span className="font-bold text-krona-primary">{formatCurrency(del.amount)} ({del.paymentMethod})</span>
</div>
</div>
<div className="pt-2 flex gap-2">
{del.status === 'pending' && (
<Button
variant="primary"
size="sm"
className="w-full text-xs"
onClick={() => updateDeliveryStatus(del.id, 'in_transit')}
>
Заказ забрал, еду к клиенту
</Button>
)}
{del.status === 'in_transit' && (
<Button
variant="gold"
size="sm"
className="w-full text-xs"
onClick={() => updateDeliveryStatus(del.id, 'delivered')}
>
<CheckCircle2 className="w-3.5 h-3.5 mr-1" />
Заказ вручен и оплачен
</Button>
)}
</div>
</div>
))}
</div>
</div>
) : (
/* Dispatcher Desktop Grid */
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
{deliveries.map((del) => (
<div
key={del.id}
className="p-4 rounded-xl border border-krona-border bg-krona-surface shadow-krona-soft space-y-3"
>
<div className="flex items-center justify-between pb-2 border-b border-krona-border/60">
<span className="font-bold text-sm text-krona-primary">{del.orderId}</span>
{getStatusBadge(del.status)}
</div>
<div className="space-y-1.5 text-xs">
<div className="flex items-start gap-1.5">
<MapPin className="w-3.5 h-3.5 text-krona-danger shrink-0 mt-0.5" />
<span className="text-krona-primary font-medium leading-tight">{del.address}</span>
</div>
<div className="pt-1 text-krona-secondary">
<div>Получатель: <strong className="text-krona-primary">{del.recipientName}</strong></div>
<div>Телефон: <span className="font-mono">{del.phone}</span></div>
<div>Курьер: <strong className="text-krona-primary">{del.courierName}</strong></div>
</div>
<div className="flex justify-between pt-1 border-t border-krona-border/40 font-bold">
<span>Сумма:</span>
<span className="text-krona-green">{formatCurrency(del.amount)}</span>
</div>
</div>
<div className="pt-2 border-t border-krona-border/60 flex justify-end gap-2">
{del.status === 'pending' && (
<Button
variant="primary"
size="sm"
className="w-full text-xs"
onClick={() => updateDeliveryStatus(del.id, 'in_transit')}
>
Передать курьеру
</Button>
)}
{del.status === 'in_transit' && (
<Button
variant="secondary"
size="sm"
className="w-full text-xs"
onClick={() => updateDeliveryStatus(del.id, 'delivered')}
>
Отметить вручение
</Button>
)}
{del.status === 'delivered' && (
<div className="w-full text-center text-xs text-krona-green font-semibold py-1">
✓ Успешно доставлен
</div>
)}
</div>
</div>
))}
</div>
)}
</div>
);
};

View file

@ -0,0 +1,205 @@
import React, { useState } from 'react';
import { useDemoStore } from '../../mock/store';
import { Badge } from '../../shared/components/Badge';
import { Button } from '../../shared/components/Button';
import { Modal } from '../../shared/components/Modal';
import { formatCurrency } from '../../shared/utils/formatters';
import { Users, Clock, Receipt, UserCheck, Plus, Check } from 'lucide-react';
import { useNavigate } from 'react-router-dom';
interface TableModel {
id: string;
name: string;
capacity: number;
status: 'occupied' | 'free' | 'reserved' | 'bill_printed';
waiter?: string;
seatedMinutes?: number;
currentTotal?: number;
guestsCount?: number;
reservationTime?: string;
reservationClient?: string;
}
const INITIAL_TABLES: TableModel[] = [
{ id: 't1', name: 'Стол 1 (VIP)', capacity: 6, status: 'occupied', waiter: 'Алина Соколова', seatedMinutes: 45, currentTotal: 6850, guestsCount: 5 },
{ id: 't2', name: 'Стол 2', capacity: 2, status: 'occupied', waiter: 'Артем Васильев', seatedMinutes: 22, currentTotal: 2450, guestsCount: 2 },
{ id: 't3', name: 'Стол 3', capacity: 4, status: 'free' },
{ id: 't4', name: 'Стол 4 (у окна)', capacity: 4, status: 'occupied', waiter: 'Алина Соколова', seatedMinutes: 18, currentTotal: 4200, guestsCount: 3 },
{ id: 't5', name: 'Стол 5 (диван)', capacity: 6, status: 'reserved', reservationTime: '19:00', reservationClient: 'Анна Воронина (3 чел)' },
{ id: 't6', name: 'Стол 6', capacity: 2, status: 'free' },
{ id: 't7', name: 'Стол 7', capacity: 4, status: 'bill_printed', waiter: 'Артем Васильев', seatedMinutes: 62, currentTotal: 5100, guestsCount: 4 },
{ id: 't8', name: 'Стол 8', capacity: 4, status: 'free' },
{ id: 'bar1', name: 'Барная стойка 1-3', capacity: 3, status: 'occupied', waiter: 'Бармен', seatedMinutes: 12, currentTotal: 1800, guestsCount: 2 },
];
export const DiningView: React.FC = () => {
const [tables, setTables] = useState<TableModel[]>(INITIAL_TABLES);
const [selectedTable, setSelectedTable] = useState<TableModel | null>(null);
const { setPosTableNumber } = useDemoStore();
const navigate = useNavigate();
const handleOpenInPos = (table: TableModel) => {
setPosTableNumber(table.name);
navigate('/operations/pos');
};
const getStatusBadge = (status: TableModel['status']) => {
switch (status) {
case 'occupied':
return <Badge variant="green" size="sm">Занят</Badge>;
case 'reserved':
return <Badge variant="gold" size="sm">Бронь</Badge>;
case 'bill_printed':
return <Badge variant="warning" size="sm">Пречек</Badge>;
case 'free':
default:
return <Badge variant="neutral" size="sm">Свободен</Badge>;
}
};
return (
<div className="space-y-4 animate-fade-in">
<div className="flex items-center justify-between bg-krona-surface p-3.5 rounded-xl border border-krona-border shadow-krona-soft">
<div>
<h2 className="text-sm font-bold text-krona-primary">
Схема залов и обслуживание гостей
</h2>
<p className="text-xs text-krona-secondary">
Интерактивная карта столов, статус гостей, пречеки и назначенные официанты
</p>
</div>
<div className="flex items-center gap-2">
<Badge variant="green" size="md">Занято: 4</Badge>
<Badge variant="gold" size="md">Бронь: 1</Badge>
<Badge variant="neutral" size="md">Свободно: 3</Badge>
</div>
</div>
{/* Hall Layout Grid */}
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-4">
{tables.map((t) => {
const isOccupied = t.status === 'occupied' || t.status === 'bill_printed';
return (
<div
key={t.id}
onClick={() => setSelectedTable(t)}
className={`p-4 rounded-xl border flex flex-col justify-between transition-all cursor-pointer shadow-krona-soft select-none ${
t.status === 'occupied'
? 'border-krona-green/70 bg-krona-green/5 hover:border-krona-green'
: t.status === 'bill_printed'
? 'border-krona-warning/70 bg-krona-warning/5 hover:border-krona-warning'
: t.status === 'reserved'
? 'border-krona-gold/70 bg-krona-gold/5 hover:border-krona-gold'
: 'border-krona-border bg-krona-surface hover:border-krona-border-strong'
}`}
>
<div>
<div className="flex items-center justify-between mb-2">
<span className="font-bold text-sm text-krona-primary">{t.name}</span>
{getStatusBadge(t.status)}
</div>
<div className="flex items-center gap-1 text-xs text-krona-secondary mb-2">
<Users className="w-3.5 h-3.5" />
<span>До {t.capacity} гостей</span>
</div>
{isOccupied && (
<div className="space-y-1 py-1 text-xs">
<div className="flex justify-between font-bold text-krona-primary">
<span>Счет:</span>
<span>{formatCurrency(t.currentTotal || 0)}</span>
</div>
<div className="flex justify-between text-krona-secondary text-[11px]">
<span>Официант:</span>
<span className="truncate">{t.waiter}</span>
</div>
<div className="flex justify-between text-krona-secondary text-[11px]">
<span>Время:</span>
<span>{t.seatedMinutes} мин</span>
</div>
</div>
)}
{t.status === 'reserved' && (
<div className="text-xs text-krona-gold-text bg-krona-gold/10 p-2 rounded-md mt-1">
<div className="font-semibold">{t.reservationTime}</div>
<div className="text-[11px] truncate">{t.reservationClient}</div>
</div>
)}
</div>
<div className="mt-3 pt-2.5 border-t border-krona-border/40 flex justify-end">
<Button
variant={isOccupied ? 'primary' : 'outline'}
size="sm"
className="w-full text-xs"
onClick={(e) => {
e.stopPropagation();
handleOpenInPos(t);
}}
>
{isOccupied ? 'Открыть чек в POS' : 'Посадить гостей'}
</Button>
</div>
</div>
);
})}
</div>
{/* Table Detail Modal */}
{selectedTable && (
<Modal
isOpen={Boolean(selectedTable)}
onClose={() => setSelectedTable(null)}
title={`Управление: ${selectedTable.name}`}
subtitle={`Вместимость: ${selectedTable.capacity} персон`}
maxWidth="sm"
footer={
<div className="flex gap-2 w-full">
<Button variant="secondary" onClick={() => setSelectedTable(null)} className="flex-1">
Закрыть
</Button>
<Button
variant="primary"
onClick={() => {
handleOpenInPos(selectedTable);
setSelectedTable(null);
}}
className="flex-1"
>
Перейти в POS
</Button>
</div>
}
>
<div className="space-y-3 text-xs">
<div className="flex justify-between p-2 rounded bg-krona-workspace">
<span className="text-krona-secondary">Текущий статус:</span>
<span>{getStatusBadge(selectedTable.status)}</span>
</div>
{selectedTable.currentTotal && (
<div className="p-3 rounded-lg border border-krona-border space-y-1">
<div className="flex justify-between font-bold text-sm text-krona-primary">
<span>Сумма заказа:</span>
<span className="text-krona-green">{formatCurrency(selectedTable.currentTotal)}</span>
</div>
<div className="flex justify-between text-krona-secondary">
<span>Обслуживает:</span>
<span>{selectedTable.waiter}</span>
</div>
<div className="flex justify-between text-krona-secondary">
<span>Посадка:</span>
<span>{selectedTable.seatedMinutes} минут назад</span>
</div>
</div>
)}
</div>
</Modal>
)}
</div>
);
};

View file

@ -0,0 +1,200 @@
import React from 'react';
import { useDemoStore, KdsTicket } from '../../mock/store';
import { Badge } from '../../shared/components/Badge';
import { Button } from '../../shared/components/Button';
import { Clock, AlertCircle, CheckCircle2, Play, Flame, Check } from 'lucide-react';
export const KdsView: React.FC = () => {
const { kdsTickets, updateKdsTicketStatus } = useDemoStore();
const newTickets = kdsTickets.filter((t) => t.status === 'new');
const cookingTickets = kdsTickets.filter((t) => t.status === 'cooking');
const readyTickets = kdsTickets.filter((t) => t.status === 'ready');
const renderTicketCard = (ticket: KdsTicket) => {
const isLate = ticket.elapsedMinutes > 15;
const isWarning = ticket.elapsedMinutes > 10;
return (
<div
key={ticket.id}
className={`p-3.5 rounded-xl border flex flex-col justify-between transition-all shadow-krona-soft bg-krona-surface ${
ticket.isUrgent
? 'border-krona-danger/70 bg-krona-danger/5'
: isLate
? 'border-krona-warning/70'
: 'border-krona-border'
}`}
>
<div>
{/* Header */}
<div className="flex items-center justify-between pb-2 border-b border-krona-border/60">
<div className="flex items-center gap-1.5">
<span className="font-bold text-sm text-krona-primary">{ticket.orderNumber}</span>
<Badge variant="neutral" size="sm">{ticket.tableOrChannel}</Badge>
</div>
<div className="flex items-center gap-1">
<Clock className={`w-3.5 h-3.5 ${isLate ? 'text-krona-danger' : isWarning ? 'text-krona-warning' : 'text-krona-secondary'}`} />
<span
className={`text-xs font-mono font-bold ${
isLate ? 'text-krona-danger' : isWarning ? 'text-krona-warning' : 'text-krona-secondary'
}`}
>
{ticket.elapsedMinutes} мин
</span>
</div>
</div>
{/* Dish items */}
<div className="py-2.5 space-y-2 text-xs">
{ticket.items.map((it, idx) => (
<div key={idx} className="flex items-start justify-between gap-2">
<div className="flex items-center gap-1.5 font-medium text-krona-primary">
<span className="w-5 h-5 rounded bg-krona-workspace border border-krona-border flex items-center justify-center font-bold text-xs">
{it.quantity}
</span>
<span>{it.name}</span>
</div>
<span className="text-[10px] px-1.5 py-0.5 rounded bg-krona-muted text-krona-secondary">
{it.station}
</span>
</div>
))}
</div>
</div>
{/* Action Button */}
<div className="pt-2 border-t border-krona-border/60 flex justify-end gap-2">
{ticket.status === 'new' && (
<Button
variant="gold"
size="sm"
className="w-full text-xs"
onClick={() => updateKdsTicketStatus(ticket.id, 'cooking')}
>
<Flame className="w-3.5 h-3.5 mr-1.5" />
Начать готовить
</Button>
)}
{ticket.status === 'cooking' && (
<Button
variant="primary"
size="sm"
className="w-full text-xs"
onClick={() => updateKdsTicketStatus(ticket.id, 'ready')}
>
<Check className="w-3.5 h-3.5 mr-1.5" />
Готово к выдаче
</Button>
)}
{ticket.status === 'ready' && (
<Button
variant="secondary"
size="sm"
className="w-full text-xs"
onClick={() => updateKdsTicketStatus(ticket.id, 'served')}
>
<CheckCircle2 className="w-3.5 h-3.5 mr-1.5 text-krona-green" />
Выдано гостю / курьеру
</Button>
)}
</div>
</div>
);
};
return (
<div className="space-y-4 animate-fade-in">
<div className="flex items-center justify-between bg-krona-surface p-3.5 rounded-xl border border-krona-border shadow-krona-soft">
<div>
<h2 className="text-sm font-bold text-krona-primary">
Дисплей кухни & сборки (KDS)
</h2>
<p className="text-xs text-krona-secondary">
Управление очередью приготовления блюд по цехам в реальном времени
</p>
</div>
<div className="flex items-center gap-2 text-xs">
<Badge variant="warning" size="md">В работе: {cookingTickets.length}</Badge>
<Badge variant="green" size="md">Готово: {readyTickets.length}</Badge>
</div>
</div>
{/* 3 Columns Kanban: New, Cooking, Ready */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
{/* Col 1: Новые */}
<div className="bg-krona-workspace/80 border border-krona-border rounded-xl p-3 flex flex-col min-h-[500px]">
<div className="flex items-center justify-between pb-2 mb-2 border-b border-krona-border">
<span className="text-xs font-bold text-krona-primary flex items-center gap-1.5">
<span className="w-2 h-2 rounded-full bg-blue-500"></span>
Новые заказы
</span>
<span className="text-xs font-mono font-bold text-krona-secondary">
{newTickets.length}
</span>
</div>
<div className="space-y-3 flex-1 overflow-y-auto">
{newTickets.length === 0 ? (
<div className="text-center py-12 text-xs text-krona-secondary">
Нет новых заказов
</div>
) : (
newTickets.map(renderTicketCard)
)}
</div>
</div>
{/* Col 2: Готовятся */}
<div className="bg-krona-workspace/80 border border-krona-border rounded-xl p-3 flex flex-col min-h-[500px]">
<div className="flex items-center justify-between pb-2 mb-2 border-b border-krona-border">
<span className="text-xs font-bold text-krona-primary flex items-center gap-1.5">
<span className="w-2 h-2 rounded-full bg-krona-gold"></span>
Готовятся на кухне
</span>
<span className="text-xs font-mono font-bold text-krona-secondary">
{cookingTickets.length}
</span>
</div>
<div className="space-y-3 flex-1 overflow-y-auto">
{cookingTickets.length === 0 ? (
<div className="text-center py-12 text-xs text-krona-secondary">
Цех свободен
</div>
) : (
cookingTickets.map(renderTicketCard)
)}
</div>
</div>
{/* Col 3: Готовы к выдаче */}
<div className="bg-krona-workspace/80 border border-krona-border rounded-xl p-3 flex flex-col min-h-[500px]">
<div className="flex items-center justify-between pb-2 mb-2 border-b border-krona-border">
<span className="text-xs font-bold text-krona-primary flex items-center gap-1.5">
<span className="w-2 h-2 rounded-full bg-krona-green"></span>
Готовы к выдаче
</span>
<span className="text-xs font-mono font-bold text-krona-secondary">
{readyTickets.length}
</span>
</div>
<div className="space-y-3 flex-1 overflow-y-auto">
{readyTickets.length === 0 ? (
<div className="text-center py-12 text-xs text-krona-secondary">
Нет ожидающих выдачи
</div>
) : (
readyTickets.map(renderTicketCard)
)}
</div>
</div>
</div>
</div>
);
};

View file

@ -0,0 +1,186 @@
import React from 'react';
import { useLocation, useNavigate } from 'react-router-dom';
import { useDemoStore } from '../../mock/store';
import { PosView } from './PosView';
import { KdsView } from './KdsView';
import { DiningView } from './DiningView';
import { WorkOrdersView } from './WorkOrdersView';
import { BookingCalendarView } from './BookingCalendarView';
import { DeliveryView } from './DeliveryView';
import { RecipesView } from './RecipesView';
import { PurchasingView } from './PurchasingView';
import { BaysView } from './BaysView';
import { InventoryModule } from '../inventory/InventoryModule';
import { SalesModule } from '../sales/SalesModule';
import { INITIAL_CATALOG } from '../../mock/mockData';
import { Button } from '../../shared/components/Button';
import { Badge } from '../../shared/components/Badge';
import { formatCurrency } from '../../shared/utils/formatters';
import { mockApi } from '../../mock/mockApi';
import {
Workflow,
ShoppingBag,
Clock,
Layers,
CheckCircle2,
AlertCircle,
Plus,
} from 'lucide-react';
export const OperationsModule: React.FC = () => {
const location = useLocation();
const navigate = useNavigate();
const { getActiveProfile, getActiveCapabilities, setIsQuickActionsOpen, activeLocation } = useDemoStore();
const profile = getActiveProfile();
const capabilities = getActiveCapabilities();
const currentPath = location.pathname;
// Sub-routes dispatcher
if (currentPath === '/operations/pos') {
return <PosView />;
}
if (currentPath === '/operations/kds') {
return <KdsView />;
}
if (currentPath === '/operations/dining') {
return <DiningView />;
}
if (currentPath === '/operations/work-orders') {
return <WorkOrdersView />;
}
if (currentPath === '/operations/booking') {
return <BookingCalendarView />;
}
if (currentPath === '/operations/delivery') {
return <DeliveryView />;
}
if (currentPath === '/operations/courier') {
return <DeliveryView initialMobile={true} />;
}
if (currentPath === '/operations/recipes') {
return <RecipesView />;
}
if (currentPath === '/operations/inventory') {
return <InventoryModule />;
}
if (currentPath === '/operations/orders') {
return <SalesModule />;
}
if (currentPath === '/operations/purchasing') {
return <PurchasingView />;
}
if (currentPath === '/operations/bays') {
return <BaysView />;
}
// Catalog / Menu view
if (currentPath === '/operations/catalog') {
return (
<div className="space-y-4 animate-fade-in">
<div className="flex items-center justify-between bg-krona-surface p-3.5 rounded-xl border border-krona-border shadow-krona-soft">
<div>
<h2 className="text-sm font-bold text-krona-primary">
{profile.terminology.itemsLabel} ({activeLocation})
</h2>
<p className="text-xs text-krona-secondary">
Управление ассортиментом, ценами продажи, себестоимостью и стоп-листом
</p>
</div>
<Button variant="primary" size="sm" onClick={() => setIsQuickActionsOpen(true)}>
+ Добавить {profile.terminology.itemLabel.toLowerCase()}
</Button>
</div>
<div className="bg-krona-surface border border-krona-border rounded-xl shadow-krona-soft overflow-hidden">
<table className="w-full text-left text-xs border-collapse">
<thead className="bg-krona-workspace/80 text-krona-secondary border-b border-krona-border">
<tr>
<th className="p-3 font-semibold">Наименование</th>
<th className="p-3 font-semibold">Категория</th>
<th className="p-3 font-semibold">Цена продажи</th>
<th className="p-3 font-semibold">Себестоимость</th>
<th className="p-3 font-semibold">Маржа %</th>
<th className="p-3 font-semibold text-center">Статус в меню</th>
</tr>
</thead>
<tbody className="divide-y divide-krona-border/50">
{INITIAL_CATALOG.map((item) => (
<tr key={item.id} className="hover:bg-krona-workspace/50 transition-colors">
<td className="p-3 font-medium text-krona-primary">
<div>{item.name}</div>
{item.description && <div className="text-[11px] text-krona-secondary">{item.description}</div>}
</td>
<td className="p-3 text-krona-secondary">{item.category}</td>
<td className="p-3 font-bold text-krona-primary">{formatCurrency(item.price)}</td>
<td className="p-3 text-krona-secondary">{formatCurrency(item.cost)}</td>
<td className="p-3 font-semibold text-krona-green">{item.marginPercent}%</td>
<td className="p-3 text-center">
{item.isStopListed ? (
<Badge variant="danger" size="sm">В стоп-листе</Badge>
) : (
<Badge variant="green" size="sm">Доступно</Badge>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}
// Default: Operations Overview Dashboard
return (
<div className="space-y-4 animate-fade-in">
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3 bg-krona-surface p-4 rounded-xl border border-krona-border shadow-krona-soft">
<div>
<h1 className="text-base font-bold text-krona-primary">
Операционный хаб: {profile.name}
</h1>
<p className="text-xs text-krona-secondary mt-0.5">
Точка: <strong className="text-krona-primary">{activeLocation}</strong> • Отраслевой рабочий контур с быстрым доступом
</p>
</div>
<div className="flex items-center gap-2">
{capabilities.pos && (
<Button variant="primary" size="sm" onClick={() => navigate('/operations/pos')}>
Открыть кассу (POS)
</Button>
)}
{capabilities.kds && (
<Button variant="gold" size="sm" onClick={() => navigate('/operations/kds')}>
Кухонный экран (KDS)
</Button>
)}
</div>
</div>
{/* Grid of active operational capabilities for this profile */}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3.5">
{profile.operationsTabs.filter(t => t.id !== 'overview').map((tab) => (
<div
key={tab.id}
onClick={() => navigate(tab.route)}
className="p-4 rounded-xl border border-krona-border bg-krona-surface hover:border-krona-green/70 hover:shadow-krona-soft cursor-pointer transition-all space-y-2 group"
>
<div className="flex items-center justify-between">
<span className="font-bold text-sm text-krona-primary group-hover:text-krona-green transition-colors">
{tab.label}
</span>
{tab.badge && <Badge variant="gold" size="sm">{tab.badge}</Badge>}
</div>
<p className="text-xs text-krona-secondary">
Перейти в рабочий экран «{tab.label}» для текущей смены
</p>
<div className="text-xs text-krona-green font-medium pt-1">
Открыть раздел →
</div>
</div>
))}
</div>
</div>
);
};

View file

@ -0,0 +1,433 @@
import React, { useState } from 'react';
import { useDemoStore, PosCartItem } from '../../mock/store';
import { INITIAL_CATALOG } from '../../mock/mockData';
import { Button } from '../../shared/components/Button';
import { Badge } from '../../shared/components/Badge';
import { Modal } from '../../shared/components/Modal';
import { formatCurrency } from '../../shared/utils/formatters';
import {
Search,
Plus,
Minus,
Trash2,
CreditCard,
Banknote,
QrCode,
Receipt,
CheckCircle2,
User,
Percent,
} from 'lucide-react';
export const PosView: React.FC = () => {
const {
posCart,
addToCart,
removeFromCart,
updateCartQuantity,
clearCart,
posDiscountPercent,
setPosDiscount,
posCustomerName,
setPosCustomer,
posOrderType,
setPosOrderType,
posTableNumber,
setPosTableNumber,
posPaymentModalOpen,
setPosPaymentModalOpen,
posLastReceipt,
completeCheckout,
getActiveProfile,
activeLocation,
} = useDemoStore();
const profile = getActiveProfile();
const [selectedCategory, setSelectedCategory] = useState<string>('Все');
const [searchQuery, setSearchQuery] = useState<string>('');
const [showReceiptModal, setShowReceiptModal] = useState(false);
const categories = ['Все', 'Роллы', 'Горячее', 'Супы', 'Автоуслуги', 'Автосервис'];
const filteredItems = INITIAL_CATALOG.filter((item) => {
const matchesCat = selectedCategory === 'Все' || item.category === selectedCategory;
const matchesSearch = item.name.toLowerCase().includes(searchQuery.toLowerCase());
return matchesCat && matchesSearch;
});
const subtotal = posCart.reduce((sum, item) => sum + item.price * item.quantity, 0);
const discountAmount = (subtotal * posDiscountPercent) / 100;
const totalAmount = subtotal - discountAmount;
const handlePayClick = () => {
if (posCart.length === 0) return;
setPosPaymentModalOpen(true);
};
const handlePaymentSelect = (method: 'cash' | 'card' | 'qr' | 'split') => {
completeCheckout(method);
setShowReceiptModal(true);
};
return (
<div className="h-[calc(100vh-8.5rem)] flex flex-col md:flex-row gap-4 animate-fade-in overflow-hidden">
{/* Left: Product Catalog Grid */}
<div className="flex-1 flex flex-col min-w-0 bg-krona-surface border border-krona-border rounded-xl shadow-krona-soft overflow-hidden">
{/* Filter bar */}
<div className="p-3 border-b border-krona-border flex flex-wrap items-center justify-between gap-3 bg-krona-surface/70">
<div className="flex items-center gap-1.5 overflow-x-auto">
{categories.map((cat) => (
<button
key={cat}
onClick={() => setSelectedCategory(cat)}
className={`px-3 py-1.5 rounded-lg text-xs font-medium transition-colors ${
selectedCategory === cat
? 'bg-[#2F684C] text-[#F8F6F0] shadow-sm'
: 'bg-krona-workspace text-krona-secondary hover:text-krona-primary border border-krona-border'
}`}
>
{cat}
</button>
))}
</div>
<div className="relative w-56">
<Search className="w-3.5 h-3.5 absolute left-2.5 top-2.5 text-krona-secondary" />
<input
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="Поиск по названию..."
className="w-full pl-8 pr-3 py-1.5 text-xs bg-krona-workspace border border-krona-border rounded-lg text-krona-primary placeholder:text-krona-secondary focus:outline-none focus:border-krona-green"
/>
</div>
</div>
{/* Products Grid */}
<div className="flex-1 overflow-y-auto p-4 grid grid-cols-2 sm:grid-cols-3 xl:grid-cols-4 gap-3">
{filteredItems.map((item) => {
const inCart = posCart.find((c) => c.id === item.id);
const isStop = item.isStopListed;
return (
<div
key={item.id}
onClick={() => !isStop && addToCart(item)}
className={`group relative p-3.5 rounded-xl border flex flex-col justify-between transition-all select-none ${
isStop
? 'opacity-50 border-krona-danger/40 bg-krona-danger/5 cursor-not-allowed'
: 'cursor-pointer border-krona-border bg-krona-surface hover:border-krona-green/60 hover:shadow-krona-soft active:scale-[0.99]'
} ${inCart ? 'ring-2 ring-krona-green/40' : ''}`}
>
<div>
<div className="flex items-center justify-between gap-1 mb-1.5">
<span className="text-[10px] text-krona-secondary uppercase font-semibold">
{item.category}
</span>
{isStop ? (
<Badge variant="danger" size="sm">Стоп-лист</Badge>
) : inCart ? (
<Badge variant="green" size="sm">{inCart.quantity} в чеке</Badge>
) : null}
</div>
<h4 className="text-xs font-bold text-krona-primary leading-snug line-clamp-2">
{item.name}
</h4>
{item.description && (
<p className="text-[11px] text-krona-secondary line-clamp-2 mt-1">
{item.description}
</p>
)}
</div>
<div className="mt-3 pt-2.5 border-t border-krona-border/40 flex items-baseline justify-between">
<span className="text-sm font-bold text-krona-primary">
{formatCurrency(item.price)}
</span>
<span className="text-[10px] text-krona-secondary">
Себест: {item.cost} ₽ ({item.marginPercent}%)
</span>
</div>
</div>
);
})}
</div>
</div>
{/* Right: Cart & Order Register Panel */}
<div className="w-full md:w-80 lg:w-96 flex flex-col bg-krona-surface border border-krona-border rounded-xl shadow-krona-soft overflow-hidden">
{/* Order settings header */}
<div className="p-3 border-b border-krona-border bg-krona-surface/80 space-y-2">
<div className="flex items-center justify-between">
<span className="text-xs font-bold text-krona-primary uppercase tracking-wider">
Текущий чек
</span>
<span className="text-xs text-krona-secondary">{activeLocation}</span>
</div>
<div className="grid grid-cols-3 gap-1 bg-krona-workspace p-1 rounded-lg border border-krona-border text-xs">
<button
onClick={() => setPosOrderType('hall')}
className={`py-1 rounded font-medium transition-colors ${
posOrderType === 'hall' ? 'bg-[#2F684C] text-white shadow-sm' : 'text-krona-secondary'
}`}
>
В зале
</button>
<button
onClick={() => setPosOrderType('takeaway')}
className={`py-1 rounded font-medium transition-colors ${
posOrderType === 'takeaway' ? 'bg-[#2F684C] text-white shadow-sm' : 'text-krona-secondary'
}`}
>
Навынос
</button>
<button
onClick={() => setPosOrderType('delivery')}
className={`py-1 rounded font-medium transition-colors ${
posOrderType === 'delivery' ? 'bg-[#2F684C] text-white shadow-sm' : 'text-krona-secondary'
}`}
>
Доставка
</button>
</div>
<div className="flex items-center gap-2">
<div className="flex-1 flex items-center gap-1.5 px-2.5 py-1 bg-krona-workspace border border-krona-border rounded-md text-xs">
<User className="w-3.5 h-3.5 text-krona-secondary" />
<input
type="text"
value={posCustomerName}
onChange={(e) => setPosCustomer(e.target.value)}
placeholder="Клиент / Номер карты"
className="w-full bg-transparent focus:outline-none text-xs text-krona-primary"
/>
</div>
{posOrderType === 'hall' && (
<div className="w-24">
<input
type="text"
value={posTableNumber}
onChange={(e) => setPosTableNumber(e.target.value)}
placeholder="Стол..."
className="w-full px-2 py-1 bg-krona-workspace border border-krona-border rounded-md text-xs text-krona-primary text-center font-medium focus:outline-none"
/>
</div>
)}
</div>
</div>
{/* Cart items list */}
<div className="flex-1 overflow-y-auto p-3 divide-y divide-krona-border/40">
{posCart.length === 0 ? (
<div className="py-16 text-center text-xs text-krona-secondary">
<Receipt className="w-8 h-8 mx-auto mb-2 opacity-30" />
Чек пуст. Добавьте позиции из каталога слева.
</div>
) : (
posCart.map((item) => (
<div key={item.id} className="py-2.5 first:pt-0 last:pb-0 flex items-center justify-between gap-2 text-xs">
<div className="flex-1 min-w-0">
<div className="font-semibold text-krona-primary truncate">{item.name}</div>
<div className="text-[11px] text-krona-secondary">
{formatCurrency(item.price)} × {item.quantity} = {formatCurrency(item.price * item.quantity)}
</div>
</div>
<div className="flex items-center gap-1 shrink-0">
<button
onClick={() => updateCartQuantity(item.id, -1)}
className="w-6 h-6 rounded bg-krona-workspace border border-krona-border flex items-center justify-center hover:bg-krona-border/50 text-krona-primary"
>
<Minus className="w-3 h-3" />
</button>
<span className="w-6 text-center font-semibold text-xs text-krona-primary">
{item.quantity}
</span>
<button
onClick={() => updateCartQuantity(item.id, 1)}
className="w-6 h-6 rounded bg-krona-workspace border border-krona-border flex items-center justify-center hover:bg-krona-border/50 text-krona-primary"
>
<Plus className="w-3 h-3" />
</button>
<button
onClick={() => removeFromCart(item.id)}
className="p-1 text-krona-secondary hover:text-krona-danger ml-1"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
</div>
</div>
))
)}
</div>
{/* Discount & Totals */}
<div className="p-3.5 border-t border-krona-border bg-krona-surface/80 space-y-2 text-xs">
{/* Quick Discounts */}
<div className="flex items-center justify-between gap-1 text-[11px]">
<span className="text-krona-secondary">Скидка:</span>
<div className="flex items-center gap-1">
{[0, 5, 10, 15].map((pct) => (
<button
key={pct}
onClick={() => setPosDiscount(pct)}
className={`px-2 py-0.5 rounded border transition-colors ${
posDiscountPercent === pct
? 'bg-krona-gold text-white font-bold border-krona-gold'
: 'bg-krona-workspace border-krona-border text-krona-secondary'
}`}
>
{pct}%
</button>
))}
</div>
</div>
<div className="space-y-1 pt-1">
<div className="flex justify-between text-krona-secondary">
<span>Сумма позиций:</span>
<span>{formatCurrency(subtotal)}</span>
</div>
{posDiscountPercent > 0 && (
<div className="flex justify-between text-krona-danger">
<span>Скидка ({posDiscountPercent}%):</span>
<span>-{formatCurrency(discountAmount)}</span>
</div>
)}
<div className="flex justify-between text-base font-bold text-krona-primary pt-1 border-t border-krona-border">
<span>К оплате:</span>
<span className="text-krona-green">{formatCurrency(totalAmount)}</span>
</div>
</div>
<div className="pt-2 flex gap-2">
<Button
variant="secondary"
size="md"
onClick={clearCart}
disabled={posCart.length === 0}
className="px-3"
title="Очистить чек"
>
Сброс
</Button>
<Button
variant="primary"
size="md"
onClick={handlePayClick}
disabled={posCart.length === 0}
className="flex-1 text-sm font-semibold"
>
Оплатить {formatCurrency(totalAmount)}
</Button>
</div>
</div>
</div>
{/* Modal: Payment Method Selection */}
<Modal
isOpen={posPaymentModalOpen}
onClose={() => setPosPaymentModalOpen(false)}
title="Оплата заказа"
subtitle={`Сумма к оплате: ${formatCurrency(totalAmount)}`}
maxWidth="md"
>
<div className="space-y-3">
<p className="text-xs text-krona-secondary">
Выберите способ расчета клиента на кассе точки «{activeLocation}»:
</p>
<div className="grid grid-cols-2 gap-3 pt-1">
<button
onClick={() => handlePaymentSelect('card')}
className="p-4 rounded-xl border border-krona-border hover:border-krona-green hover:bg-krona-green/5 text-left flex flex-col items-center justify-center gap-2 group transition-all"
>
<CreditCard className="w-8 h-8 text-krona-green group-hover:scale-110 transition-transform" />
<div className="text-xs font-bold text-krona-primary">Банковская карта</div>
<div className="text-[10px] text-krona-secondary">Терминал эквайринга</div>
</button>
<button
onClick={() => handlePaymentSelect('cash')}
className="p-4 rounded-xl border border-krona-border hover:border-krona-green hover:bg-krona-green/5 text-left flex flex-col items-center justify-center gap-2 group transition-all"
>
<Banknote className="w-8 h-8 text-krona-green group-hover:scale-110 transition-transform" />
<div className="text-xs font-bold text-krona-primary">Наличные</div>
<div className="text-[10px] text-krona-secondary">С выдачей сдачи</div>
</button>
<button
onClick={() => handlePaymentSelect('qr')}
className="p-4 rounded-xl border border-krona-border hover:border-krona-green hover:bg-krona-green/5 text-left flex flex-col items-center justify-center gap-2 group transition-all"
>
<QrCode className="w-8 h-8 text-krona-gold group-hover:scale-110 transition-transform" />
<div className="text-xs font-bold text-krona-primary">СБП / QR-код</div>
<div className="text-[10px] text-krona-secondary">Мгновенное зачисление</div>
</button>
<button
onClick={() => handlePaymentSelect('split')}
className="p-4 rounded-xl border border-krona-border hover:border-krona-green hover:bg-krona-green/5 text-left flex flex-col items-center justify-center gap-2 group transition-all"
>
<Receipt className="w-8 h-8 text-krona-secondary group-hover:scale-110 transition-transform" />
<div className="text-xs font-bold text-krona-primary">Смешанная оплата</div>
<div className="text-[10px] text-krona-secondary">Часть нал + часть карта</div>
</button>
</div>
</div>
</Modal>
{/* Modal: Fiscal Receipt Details */}
{showReceiptModal && posLastReceipt && (
<Modal
isOpen={showReceiptModal}
onClose={() => setShowReceiptModal(false)}
title="Чек успешно пробит!"
subtitle={`Заказ ${posLastReceipt.orderNumber} • ${posLastReceipt.timestamp}`}
maxWidth="sm"
footer={
<Button variant="primary" onClick={() => setShowReceiptModal(false)} className="w-full">
Закрыть чек
</Button>
}
>
<div className="font-mono text-xs bg-krona-workspace p-3.5 rounded-lg border border-krona-border space-y-2">
<div className="text-center font-bold pb-1 border-b border-dashed border-krona-border">
{profile.companyName}
<div className="text-[10px] font-normal text-krona-secondary">{posLastReceipt.location}</div>
</div>
<div className="space-y-1 py-1">
{posLastReceipt.items.map((it: any, i: number) => (
<div key={i} className="flex justify-between">
<span>{it.name} × {it.quantity}</span>
<span>{formatCurrency(it.price * it.quantity)}</span>
</div>
))}
</div>
<div className="pt-2 border-t border-dashed border-krona-border space-y-1">
<div className="flex justify-between font-bold text-sm">
<span>ИТОГ:</span>
<span>{formatCurrency(posLastReceipt.total)}</span>
</div>
<div className="flex justify-between text-[11px] text-krona-secondary">
<span>Способ:</span>
<span className="uppercase font-semibold">{posLastReceipt.paymentType}</span>
</div>
<div className="flex justify-between text-[10px] text-krona-secondary">
<span>Фискальный признак (mock):</span>
<span>ФП: 492049102</span>
</div>
</div>
<div className="pt-2 text-center text-[10px] text-krona-secondary">
Спасибо за покупку в «{profile.companyName}»!
</div>
</div>
</Modal>
)}
</div>
);
};

View file

@ -0,0 +1,360 @@
import React, { useState } from 'react';
import { Badge } from '../../shared/components/Badge';
import { Button } from '../../shared/components/Button';
import { StatCard } from '../../shared/components/StatCard';
import { Modal } from '../../shared/components/Modal';
import { Input } from '../../shared/components/Input';
import { formatCurrency } from '../../shared/utils/formatters';
import {
Truck,
Plus,
Search,
FileText,
DollarSign,
Clock,
CheckCircle2,
Package,
Building,
Phone,
} from 'lucide-react';
interface Supplier {
id: string;
name: string;
category: string;
phone: string;
contactPerson: string;
debt: number;
deliveryDays: string;
}
interface PurchaseOrder {
id: string;
supplierName: string;
date: string;
itemsCount: number;
totalAmount: number;
status: 'draft' | 'sent' | 'in_transit' | 'received';
deliveryEta: string;
}
const INITIAL_SUPPLIERS: Supplier[] = [
{
id: 'sup-1',
name: 'СибРыбТорг',
category: 'Рыба и морепродукты',
phone: '+7 (383) 310-20-40',
contactPerson: 'Олег Васильев',
debt: 48500,
deliveryDays: 'Пн, Ср, Пт (до 11:00)',
},
{
id: 'sup-2',
name: 'Хорека Логистик',
category: 'Сыры, молочная продукция',
phone: '+7 (383) 220-11-88',
contactPerson: 'Елена Белова',
debt: 12400,
deliveryDays: 'Вторник, Четверг',
},
{
id: 'sup-3',
name: 'Восточный Экспресс',
category: 'Рис, соусы, водоросли, специи',
phone: '+7 (383) 299-44-33',
contactPerson: 'Алексей Чэнь',
debt: 0,
deliveryDays: 'По заявке (1 день)',
},
{
id: 'sup-4',
name: 'ЭкоУпаковка Сибирь',
category: 'Контейнеры, пакеты, палочки',
phone: '+7 (383) 345-67-89',
contactPerson: 'Мария Семенова',
debt: 3200,
deliveryDays: 'Среда',
},
];
const INITIAL_PURCHASE_ORDERS: PurchaseOrder[] = [
{
id: 'PO-901',
supplierName: 'СибРыбТорг',
date: '10.09.2026',
itemsCount: 3,
totalAmount: 48500,
status: 'in_transit',
deliveryEta: 'Сегодня к 15:30',
},
{
id: 'PO-900',
supplierName: 'Хорека Логистик',
date: '09.09.2026',
itemsCount: 2,
totalAmount: 23600,
status: 'received',
deliveryEta: 'Получен 09.09',
},
{
id: 'PO-899',
supplierName: 'Восточный Экспресс',
date: '08.09.2026',
itemsCount: 5,
totalAmount: 38400,
status: 'received',
deliveryEta: 'Получен 08.09',
},
{
id: 'PO-902',
supplierName: 'ЭкоУпаковка Сибирь',
date: '10.09.2026',
itemsCount: 4,
totalAmount: 14200,
status: 'sent',
deliveryEta: 'Завтра к 12:00',
},
];
export const PurchasingView: React.FC = () => {
const [suppliers, setSuppliers] = useState<Supplier[]>(INITIAL_SUPPLIERS);
const [orders, setOrders] = useState<PurchaseOrder[]>(INITIAL_PURCHASE_ORDERS);
const [activeTab, setActiveTab] = useState<'orders' | 'suppliers'>('orders');
const [isNewOrderModalOpen, setIsNewOrderModalOpen] = useState(false);
// New order form
const [selectedSupplier, setSelectedSupplier] = useState<string>(INITIAL_SUPPLIERS[0].name);
const [orderAmount, setOrderAmount] = useState<string>('25000');
const [itemsCount, setItemsCount] = useState<string>('3');
const [deliveryEta, setDeliveryEta] = useState<string>('Завтра к 11:00');
const totalDebt = suppliers.reduce((sum, s) => sum + s.debt, 0);
const handleCreateOrder = (e: React.FormEvent) => {
e.preventDefault();
const newPO: PurchaseOrder = {
id: `PO-${Math.floor(903 + Math.random() * 90)}`,
supplierName: selectedSupplier,
date: new Date().toLocaleDateString('ru-RU'),
itemsCount: Number(itemsCount) || 1,
totalAmount: Number(orderAmount) || 0,
status: 'sent',
deliveryEta,
};
setOrders([newPO, ...orders]);
setIsNewOrderModalOpen(false);
};
const getStatusBadge = (status: PurchaseOrder['status']) => {
switch (status) {
case 'received':
return <Badge variant="green" size="sm">Принят на склад</Badge>;
case 'in_transit':
return <Badge variant="info" size="sm">В пути / Доставка</Badge>;
case 'sent':
return <Badge variant="gold" size="sm">Отправлен поставщику</Badge>;
case 'draft':
default:
return <Badge variant="neutral" size="sm">Черновик</Badge>;
}
};
return (
<div className="space-y-4 animate-fade-in">
{/* Top Bar */}
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3 bg-krona-surface p-3.5 rounded-xl border border-krona-border shadow-krona-soft">
<div>
<h2 className="text-sm font-bold text-krona-primary">
Закупки, заказы поставщикам и приходные накладные
</h2>
<p className="text-xs text-krona-secondary">
Управление контрагентами, графиками поставок сырья и расчетами
</p>
</div>
<div className="flex items-center gap-2">
<div className="flex items-center bg-krona-workspace p-1 rounded-lg border border-krona-border text-xs">
<button
onClick={() => setActiveTab('orders')}
className={`px-3 py-1 rounded font-medium transition-colors ${
activeTab === 'orders' ? 'bg-[#2F684C] text-white shadow-sm' : 'text-krona-secondary'
}`}
>
Заказы ({orders.length})
</button>
<button
onClick={() => setActiveTab('suppliers')}
className={`px-3 py-1 rounded font-medium transition-colors ${
activeTab === 'suppliers' ? 'bg-[#2F684C] text-white shadow-sm' : 'text-krona-secondary'
}`}
>
Поставщики ({suppliers.length})
</button>
</div>
<Button variant="primary" size="sm" onClick={() => setIsNewOrderModalOpen(true)}>
<Plus className="w-3.5 h-3.5 mr-1" />
Новый заказ поставщику
</Button>
</div>
</div>
{/* Summary KPI Cards */}
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3.5">
<StatCard
title="Задолженность перед поставщиками"
value={formatCurrency(totalDebt)}
change={{ value: '-12%', trend: 'neutral' }}
subtitle="Срок оплаты: до 15.09"
icon={<DollarSign className="w-4 h-4 text-krona-gold" />}
/>
<StatCard
title="Заказов в пути"
value={`${orders.filter((o) => o.status === 'in_transit').length} поставки`}
subtitle="Ожидаются сегодня"
icon={<Truck className="w-4 h-4 text-krona-info" />}
/>
<StatCard
title="Активных поставщиков"
value={`${suppliers.length} контрагента`}
subtitle="Регулярные поставки сырья"
icon={<Building className="w-4 h-4 text-krona-green" />}
/>
</div>
{activeTab === 'orders' ? (
/* Purchase Orders Table */
<div className="bg-krona-surface border border-krona-border rounded-xl shadow-krona-soft overflow-hidden p-4 space-y-3">
<h4 className="text-xs font-bold text-krona-primary uppercase tracking-wider">
Журнал заказов поставщикам
</h4>
<div className="overflow-x-auto">
<table className="w-full text-left text-xs border-collapse">
<thead className="bg-krona-workspace/80 text-krona-secondary border-b border-krona-border">
<tr>
<th className="p-3 font-semibold">Номер заказа</th>
<th className="p-3 font-semibold">Поставщик</th>
<th className="p-3 font-semibold">Дата</th>
<th className="p-3 font-semibold">Позиций сырья</th>
<th className="p-3 font-semibold">Сумма заказа</th>
<th className="p-3 font-semibold">Ожидаемая поставка</th>
<th className="p-3 font-semibold">Статус</th>
</tr>
</thead>
<tbody className="divide-y divide-krona-border/50">
{orders.map((order) => (
<tr key={order.id} className="hover:bg-krona-workspace/50 transition-colors">
<td className="p-3 font-mono font-bold text-krona-primary">{order.id}</td>
<td className="p-3 font-medium text-krona-primary">{order.supplierName}</td>
<td className="p-3 text-krona-secondary">{order.date}</td>
<td className="p-3 text-krona-secondary">{order.itemsCount} наим.</td>
<td className="p-3 font-bold text-krona-primary">{formatCurrency(order.totalAmount)}</td>
<td className="p-3 text-krona-secondary">{order.deliveryEta}</td>
<td className="p-3">{getStatusBadge(order.status)}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
) : (
/* Suppliers Directory */
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{suppliers.map((sup) => (
<div
key={sup.id}
className="p-4 rounded-xl border border-krona-border bg-krona-surface shadow-krona-soft space-y-3"
>
<div className="flex items-center justify-between">
<div>
<h4 className="text-sm font-bold text-krona-primary">{sup.name}</h4>
<span className="text-xs text-krona-secondary">{sup.category}</span>
</div>
{sup.debt > 0 ? (
<Badge variant="warning" size="sm">Долг: {formatCurrency(sup.debt)}</Badge>
) : (
<Badge variant="green" size="sm">Сверено (0 ₽)</Badge>
)}
</div>
<div className="space-y-1 text-xs text-krona-secondary pt-1 border-t border-krona-border/40">
<div className="flex justify-between">
<span>Контактное лицо:</span>
<span className="font-medium text-krona-primary">{sup.contactPerson}</span>
</div>
<div className="flex justify-between">
<span>Телефон:</span>
<span className="font-mono text-krona-primary">{sup.phone}</span>
</div>
<div className="flex justify-between">
<span>Дни доставки:</span>
<span className="text-krona-primary">{sup.deliveryDays}</span>
</div>
</div>
</div>
))}
</div>
)}
{/* New Purchase Order Modal */}
{isNewOrderModalOpen && (
<Modal
isOpen={isNewOrderModalOpen}
onClose={() => setIsNewOrderModalOpen(false)}
title="Новый заказ поставщику"
subtitle="Формирование заявки на поставку сырья и материалов"
maxWidth="sm"
>
<form onSubmit={handleCreateOrder} className="space-y-3 text-xs">
<div className="flex flex-col gap-1.5 text-left">
<label className="text-xs font-medium text-krona-secondary">Поставщик</label>
<select
value={selectedSupplier}
onChange={(e) => setSelectedSupplier(e.target.value)}
className="w-full bg-krona-surface border border-krona-border rounded-md px-3 py-2 text-xs text-krona-primary"
>
{suppliers.map((s) => (
<option key={s.id} value={s.name}>{s.name} ({s.category})</option>
))}
</select>
</div>
<Input
label="Количество позиций в заявке"
type="number"
value={itemsCount}
onChange={(e) => setItemsCount(e.target.value)}
required
/>
<Input
label="Плановая сумма заказа (₽)"
type="number"
value={orderAmount}
onChange={(e) => setOrderAmount(e.target.value)}
required
/>
<Input
label="Желаемый срок доставки"
value={deliveryEta}
onChange={(e) => setDeliveryEta(e.target.value)}
placeholder="Завтра к 10:00"
required
/>
<div className="pt-2 flex justify-end gap-2">
<Button type="button" variant="secondary" onClick={() => setIsNewOrderModalOpen(false)}>
Отмена
</Button>
<Button type="submit" variant="primary">
Отправить заказ
</Button>
</div>
</form>
</Modal>
)}
</div>
);
};

View file

@ -0,0 +1,131 @@
import React, { useState } from 'react';
import { INITIAL_RECIPES, RecipeItem } from '../../mock/mockData';
import { Badge } from '../../shared/components/Badge';
import { Button } from '../../shared/components/Button';
import { formatCurrency, formatPercent } from '../../shared/utils/formatters';
import { ChefHat, DollarSign, Layers, Plus } from 'lucide-react';
import { Modal } from '../../shared/components/Modal';
export const RecipesView: React.FC = () => {
const [recipes, setRecipes] = useState<RecipeItem[]>(INITIAL_RECIPES);
const [selectedRecipe, setSelectedRecipe] = useState<RecipeItem | null>(null);
return (
<div className="space-y-4 animate-fade-in">
<div className="flex items-center justify-between bg-krona-surface p-3.5 rounded-xl border border-krona-border shadow-krona-soft">
<div>
<h2 className="text-sm font-bold text-krona-primary">
Технологические карты & Калькуляция (BOM)
</h2>
<p className="text-xs text-krona-secondary">
Нормы расхода сырья, расчет food cost %, плановая себестоимость и маржинальность
</p>
</div>
<Button variant="primary" size="sm" onClick={() => setSelectedRecipe(recipes[0])}>
<ChefHat className="w-3.5 h-3.5 mr-1.5" />
Посмотреть калькуляцию
</Button>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{recipes.map((rec) => (
<div
key={rec.id}
onClick={() => setSelectedRecipe(rec)}
className="p-4 rounded-xl border border-krona-border bg-krona-surface hover:border-krona-border-strong shadow-krona-soft cursor-pointer transition-all space-y-3"
>
<div className="flex items-center justify-between">
<div>
<h4 className="text-sm font-bold text-krona-primary">{rec.name}</h4>
<span className="text-xs text-krona-secondary">Выход: {rec.yieldWeight} • {rec.category}</span>
</div>
<Badge variant="green" size="sm">Food Cost: {rec.foodCostPercent}%</Badge>
</div>
<div className="grid grid-cols-3 gap-2 p-2.5 rounded-lg bg-krona-workspace text-xs">
<div>
<span className="text-krona-secondary block text-[10px]">Себестоимость:</span>
<span className="font-bold text-krona-primary">{formatCurrency(rec.primeCost)}</span>
</div>
<div>
<span className="text-krona-secondary block text-[10px]">Цена продажи:</span>
<span className="font-bold text-krona-green">{formatCurrency(rec.sellingPrice)}</span>
</div>
<div>
<span className="text-krona-secondary block text-[10px]">Маржа (руб):</span>
<span className="font-bold text-krona-gold">{formatCurrency(rec.marginRub)}</span>
</div>
</div>
<div className="text-xs text-krona-secondary">
<span className="font-medium text-krona-primary">Ингредиенты ({rec.ingredients.length}): </span>
{rec.ingredients.map((i) => i.name).join(', ')}
</div>
</div>
))}
</div>
{/* Recipe Detail Modal */}
{selectedRecipe && (
<Modal
isOpen={Boolean(selectedRecipe)}
onClose={() => setSelectedRecipe(null)}
title={`Техкарта: ${selectedRecipe.name}`}
subtitle={`Выход готового блюда: ${selectedRecipe.yieldWeight}`}
maxWidth="lg"
footer={
<Button variant="secondary" onClick={() => setSelectedRecipe(null)}>
Закрыть
</Button>
}
>
<div className="space-y-4 text-xs">
<div className="grid grid-cols-4 gap-2 p-3 bg-krona-workspace rounded-lg text-center">
<div>
<span className="text-krona-secondary block text-[10px]">Цена в меню:</span>
<span className="font-bold text-sm text-krona-primary">{formatCurrency(selectedRecipe.sellingPrice)}</span>
</div>
<div>
<span className="text-krona-secondary block text-[10px]">Себестоимость:</span>
<span className="font-bold text-sm text-krona-danger">{formatCurrency(selectedRecipe.primeCost)}</span>
</div>
<div>
<span className="text-krona-secondary block text-[10px]">Food Cost %:</span>
<span className="font-bold text-sm text-krona-green">{selectedRecipe.foodCostPercent}%</span>
</div>
<div>
<span className="text-krona-secondary block text-[10px]">Маржинальный доход:</span>
<span className="font-bold text-sm text-krona-gold">{formatCurrency(selectedRecipe.marginRub)}</span>
</div>
</div>
<div>
<h5 className="font-semibold text-krona-primary mb-2">Расход сырья и калькуляция:</h5>
<table className="w-full text-left border-collapse">
<thead>
<tr className="border-b border-krona-border text-krona-secondary text-[11px]">
<th className="pb-1.5 font-medium">Ингредиент</th>
<th className="pb-1.5 font-medium">Брутто</th>
<th className="pb-1.5 font-medium">Нетто</th>
<th className="pb-1.5 font-medium text-right">Стоимость</th>
</tr>
</thead>
<tbody className="divide-y divide-krona-border/40">
{selectedRecipe.ingredients.map((ing, i) => (
<tr key={i} className="py-1.5">
<td className="py-1.5 font-medium text-krona-primary">{ing.name}</td>
<td className="py-1.5 text-krona-secondary">{ing.gross}</td>
<td className="py-1.5 text-krona-secondary">{ing.net}</td>
<td className="py-1.5 font-semibold text-right text-krona-primary">{formatCurrency(ing.cost)}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</Modal>
)}
</div>
);
};

View file

@ -0,0 +1,181 @@
import React, { useState } from 'react';
import { useDemoStore, WorkOrder } from '../../mock/store';
import { Badge } from '../../shared/components/Badge';
import { Button } from '../../shared/components/Button';
import { Modal } from '../../shared/components/Modal';
import { formatCurrency } from '../../shared/utils/formatters';
import { Car, Wrench, User, Phone, CheckCircle2, Play, Plus, Clock } from 'lucide-react';
export const WorkOrdersView: React.FC = () => {
const { workOrders, updateWorkOrderStatus, setIsQuickActionsOpen } = useDemoStore();
const [selectedOrder, setSelectedOrder] = useState<WorkOrder | null>(null);
const getStatusBadge = (status: WorkOrder['status']) => {
switch (status) {
case 'in_progress':
return <Badge variant="warning" size="sm">В работе на посту</Badge>;
case 'ready':
return <Badge variant="gold" size="sm">Готов к выдаче</Badge>;
case 'paid':
return <Badge variant="green" size="sm">Оплачен и выдан</Badge>;
case 'draft':
default:
return <Badge variant="neutral" size="sm">Черновик / Заезд</Badge>;
}
};
return (
<div className="space-y-4 animate-fade-in">
<div className="flex items-center justify-between bg-krona-surface p-3.5 rounded-xl border border-krona-border shadow-krona-soft">
<div>
<h2 className="text-sm font-bold text-krona-primary">
Заказ-наряды автокомплекса и постов
</h2>
<p className="text-xs text-krona-secondary">
Учет автомобилей, выполненных работ, списанных запчастей и нормо-часов
</p>
</div>
<Button variant="primary" size="sm" onClick={() => setIsQuickActionsOpen(true)}>
<Plus className="w-3.5 h-3.5 mr-1.5" />
Новый заказ-наряд
</Button>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{workOrders.map((wo) => {
const totalAmount = wo.partsAmount + wo.laborAmount;
return (
<div
key={wo.id}
onClick={() => setSelectedOrder(wo)}
className="p-4 rounded-xl border border-krona-border bg-krona-surface hover:border-krona-border-strong shadow-krona-soft cursor-pointer transition-all space-y-3"
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<div className="p-2 rounded-lg bg-krona-workspace text-krona-info border border-krona-border">
<Car className="w-4 h-4" />
</div>
<div>
<h4 className="text-sm font-bold text-krona-primary">{wo.vehicle}</h4>
<span className="text-xs text-krona-secondary font-mono">{wo.id} • {wo.bay}</span>
</div>
</div>
{getStatusBadge(wo.status)}
</div>
<div className="grid grid-cols-2 gap-2 text-xs py-1">
<div>
<span className="text-krona-secondary block text-[11px]">Владелец:</span>
<span className="font-medium text-krona-primary">{wo.ownerName}</span>
</div>
<div>
<span className="text-krona-secondary block text-[11px]">Мастер поста:</span>
<span className="font-medium text-krona-primary">{wo.masterName}</span>
</div>
</div>
<div className="p-2.5 rounded-lg bg-krona-workspace text-xs space-y-1">
<span className="text-[11px] font-semibold text-krona-secondary uppercase">
Работы и услуги:
</span>
<ul className="list-disc list-inside space-y-0.5 text-krona-primary">
{wo.services.map((srv, i) => (
<li key={i} className="truncate">{srv}</li>
))}
</ul>
</div>
<div className="pt-2 border-t border-krona-border/60 flex items-center justify-between text-xs">
<div>
<span className="text-krona-secondary block text-[10px]">Итоговая стоимость:</span>
<span className="text-sm font-bold text-krona-green">{formatCurrency(totalAmount)}</span>
</div>
<div className="flex gap-2">
{wo.status === 'in_progress' && (
<Button
variant="primary"
size="sm"
onClick={(e) => {
e.stopPropagation();
updateWorkOrderStatus(wo.id, 'ready');
}}
>
Готово
</Button>
)}
{wo.status === 'ready' && (
<Button
variant="gold"
size="sm"
onClick={(e) => {
e.stopPropagation();
updateWorkOrderStatus(wo.id, 'paid');
}}
>
Принять оплату
</Button>
)}
</div>
</div>
</div>
);
})}
</div>
{/* Work Order Detail Modal */}
{selectedOrder && (
<Modal
isOpen={Boolean(selectedOrder)}
onClose={() => setSelectedOrder(null)}
title={`Заказ-наряд ${selectedOrder.id}`}
subtitle={`${selectedOrder.vehicle} • ${selectedOrder.bay}`}
maxWidth="md"
footer={
<Button variant="secondary" onClick={() => setSelectedOrder(null)}>
Закрыть
</Button>
}
>
<div className="space-y-4 text-xs">
<div className="grid grid-cols-2 gap-3 p-3 rounded-lg bg-krona-workspace">
<div>
<span className="text-krona-secondary block">Владелец:</span>
<span className="font-semibold text-krona-primary">{selectedOrder.ownerName}</span>
<span className="text-krona-secondary block text-[11px]">{selectedOrder.phone}</span>
</div>
<div>
<span className="text-krona-secondary block">Ответственный специалист:</span>
<span className="font-semibold text-krona-primary">{selectedOrder.masterName}</span>
<span className="text-krona-secondary block text-[11px]">{selectedOrder.bay}</span>
</div>
</div>
<div>
<span className="font-semibold text-krona-primary block mb-1">Калькуляция:</span>
<div className="p-3 border border-krona-border rounded-lg space-y-1.5">
<div className="flex justify-between">
<span className="text-krona-secondary">Работы мастера:</span>
<span className="font-medium">{formatCurrency(selectedOrder.laborAmount)}</span>
</div>
<div className="flex justify-between">
<span className="text-krona-secondary">Материалы и запчасти:</span>
<span className="font-medium">{formatCurrency(selectedOrder.partsAmount)}</span>
</div>
<div className="flex justify-between pt-1 border-t border-krona-border font-bold text-sm">
<span>ИТОГО К ОПЛАТЕ:</span>
<span className="text-krona-green">
{formatCurrency(selectedOrder.laborAmount + selectedOrder.partsAmount)}
</span>
</div>
</div>
</div>
</div>
</Modal>
)}
</div>
);
};

View file

@ -0,0 +1,104 @@
import React, { useState } from 'react';
import { formatCurrency } from '../../shared/utils/formatters';
import { Badge } from '../../shared/components/Badge';
import { Button } from '../../shared/components/Button';
import { ShoppingBag, Search, Filter, ArrowUpRight } from 'lucide-react';
interface SalesOrder {
id: string;
source: 'зал' | 'доставка' | 'навынос' | 'сайт' | 'Telegram' | 'агрегатор';
customer: string;
itemsCount: number;
amount: number;
status: 'completed' | 'in_progress' | 'cancelled';
time: string;
paymentMethod: string;
}
const ORDERS_LIST: SalesOrder[] = [
{ id: '#ORD-104', source: 'зал', customer: 'Стол 4 (Александр)', itemsCount: 3, amount: 4200, status: 'in_progress', time: '13:40', paymentMethod: 'Карта' },
{ id: '#ORD-103', source: 'доставка', customer: 'Максим Белов', itemsCount: 4, amount: 2450, status: 'in_progress', time: '13:22', paymentMethod: 'Карта курьеру' },
{ id: '#ORD-102', source: 'Telegram', customer: 'Константин Игнатов', itemsCount: 2, amount: 1380, status: 'completed', time: '12:55', paymentMethod: 'СБП онлайн' },
{ id: '#ORD-101', source: 'навынос', customer: 'Ольга Романова', itemsCount: 1, amount: 690, status: 'completed', time: '12:30', paymentMethod: 'Наличные' },
{ id: '#ORD-100', source: 'сайт', customer: 'Екатерина Морозова', itemsCount: 3, amount: 1890, status: 'completed', time: '11:45', paymentMethod: 'Карта онлайн' },
{ id: '#ORD-099', source: 'агрегатор', customer: 'Яндекс Еда #891', itemsCount: 2, amount: 1540, status: 'completed', time: '11:15', paymentMethod: 'Безнал' },
];
export const SalesModule: React.FC = () => {
const [orders, setOrders] = useState<SalesOrder[]>(ORDERS_LIST);
const [filterSource, setFilterSource] = useState<string>('все');
const filteredOrders = filterSource === 'все' ? orders : orders.filter(o => o.source === filterSource);
return (
<div className="space-y-4 animate-fade-in">
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3 bg-krona-surface p-3.5 rounded-xl border border-krona-border shadow-krona-soft">
<div>
<h2 className="text-sm font-bold text-krona-primary">
Продажи & Заказы по всем источникам
</h2>
<p className="text-xs text-krona-secondary">
Омниканальный учет: зал, доставка, сайт, Telegram бот и агрегаторы
</p>
</div>
{/* Source filter */}
<div className="flex items-center gap-1 overflow-x-auto text-xs">
{['все', 'зал', 'доставка', 'навынос', 'сайт', 'Telegram', 'агрегатор'].map((src) => (
<button
key={src}
onClick={() => setFilterSource(src)}
className={`px-2.5 py-1 rounded-md capitalize font-medium transition-colors ${
filterSource === src
? 'bg-[#2F684C] text-white shadow-sm'
: 'bg-krona-workspace text-krona-secondary hover:text-krona-primary border border-krona-border'
}`}
>
{src}
</button>
))}
</div>
</div>
<div className="bg-krona-surface border border-krona-border rounded-xl shadow-krona-soft overflow-hidden">
<table className="w-full text-left text-xs border-collapse">
<thead className="bg-krona-workspace/80 text-krona-secondary border-b border-krona-border">
<tr>
<th className="p-3 font-semibold">Номер заказа</th>
<th className="p-3 font-semibold">Источник канала</th>
<th className="p-3 font-semibold">Клиент / Получатель</th>
<th className="p-3 font-semibold">Позиций</th>
<th className="p-3 font-semibold">Сумма</th>
<th className="p-3 font-semibold">Оплата</th>
<th className="p-3 font-semibold">Время</th>
<th className="p-3 font-semibold">Статус</th>
</tr>
</thead>
<tbody className="divide-y divide-krona-border/50">
{filteredOrders.map((order) => (
<tr key={order.id} className="hover:bg-krona-workspace/50 transition-colors">
<td className="p-3 font-bold font-mono text-krona-primary">{order.id}</td>
<td className="p-3">
<Badge variant="gold" size="sm">{order.source}</Badge>
</td>
<td className="p-3 font-medium text-krona-primary">{order.customer}</td>
<td className="p-3 text-krona-secondary">{order.itemsCount} поз.</td>
<td className="p-3 font-bold text-krona-green">{formatCurrency(order.amount)}</td>
<td className="p-3 text-krona-secondary">{order.paymentMethod}</td>
<td className="p-3 text-krona-secondary font-mono">{order.time}</td>
<td className="p-3">
<Badge
variant={order.status === 'completed' ? 'green' : 'warning'}
size="sm"
>
{order.status === 'completed' ? 'Выполнен' : 'В процессе'}
</Badge>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
};

View file

@ -0,0 +1,157 @@
import React from 'react';
import { useDemoStore } from '../../mock/store';
import { CAPABILITIES_LIST } from '../../core/capabilities/types';
import { BUSINESS_PROFILES } from '../../core/profiles/profiles';
import { SYSTEM_ROLES, SystemRoleId } from '../../core/permissions/types';
import { Badge } from '../../shared/components/Badge';
import { Button } from '../../shared/components/Button';
import { Settings, Sliders, Shield, Building, MapPin, RefreshCw, CheckCircle2 } from 'lucide-react';
export const SettingsModule: React.FC = () => {
const {
currentProfileId,
setProfileId,
currentRoleId,
setRoleId,
customCapabilities,
toggleCapability,
resetCapabilitiesToProfile,
activeLocation,
getActiveProfile,
} = useDemoStore();
const profile = getActiveProfile();
return (
<div className="space-y-4 animate-fade-in">
{/* Header */}
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3 bg-krona-surface p-3.5 rounded-xl border border-krona-border shadow-krona-soft">
<div>
<h2 className="text-sm font-bold text-krona-primary">
Krona Configuration Center & Capability Studio
</h2>
<p className="text-xs text-krona-secondary">
Управление архитектурой экосистемы, переключение бизнес-профилей и тонкая настройка Capabilities
</p>
</div>
<Button variant="secondary" size="sm" onClick={resetCapabilitiesToProfile}>
<RefreshCw className="w-3.5 h-3.5 mr-1" />
Сбросить к дефолту профиля
</Button>
</div>
{/* 1. Business Profile Selector */}
<div className="bg-krona-surface border border-krona-border rounded-xl p-4 shadow-krona-soft space-y-3">
<div className="flex items-center gap-2">
<Building className="w-4 h-4 text-krona-gold" />
<h3 className="text-xs font-bold text-krona-primary uppercase tracking-wider">
Активный бизнес-профиль
</h3>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-2.5">
{Object.values(BUSINESS_PROFILES).map((p) => (
<button
key={p.id}
onClick={() => setProfileId(p.id)}
className={`p-3 rounded-xl border text-left flex flex-col justify-between transition-all ${
p.id === currentProfileId
? 'border-krona-gold bg-krona-gold/10 ring-2 ring-krona-gold/30'
: 'border-krona-border bg-krona-workspace hover:border-krona-border-strong'
}`}
>
<div>
<div className="flex items-center justify-between mb-1">
<span className="text-xs font-bold text-krona-primary">{p.name}</span>
<Badge variant="neutral" size="sm">{p.category}</Badge>
</div>
<div className="text-[11px] text-krona-secondary">{p.companyName}</div>
</div>
<div className="text-[10px] text-krona-gold-text font-semibold pt-2">
{p.id === currentProfileId ? '✓ Активный профиль' : 'Выбрать →'}
</div>
</button>
))}
</div>
</div>
{/* 2. Capability Engine Studio (Live interactive toggles for all 21 capabilities!) */}
<div className="bg-krona-surface border border-krona-border rounded-xl p-4 shadow-krona-soft space-y-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Sliders className="w-4 h-4 text-krona-green" />
<h3 className="text-xs font-bold text-krona-primary uppercase tracking-wider">
Capability Engine (21 системная возможность платформы)
</h3>
</div>
<span className="text-xs text-krona-secondary">
Тумблеры мгновенно перестраивают меню, разделы и табы
</span>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-2.5">
{CAPABILITIES_LIST.map((cap) => {
const isEnabled = customCapabilities[cap.id];
return (
<div
key={cap.id}
onClick={() => toggleCapability(cap.id)}
className={`p-3 rounded-lg border cursor-pointer select-none transition-all flex items-center justify-between gap-2 ${
isEnabled
? 'border-krona-green/60 bg-krona-green/5'
: 'border-krona-border bg-krona-workspace opacity-60'
}`}
>
<div className="min-w-0">
<div className="font-bold text-xs text-krona-primary truncate">{cap.name}</div>
<div className="text-[10px] text-krona-secondary truncate">{cap.description}</div>
</div>
<div
className={`w-10 h-5 rounded-full p-0.5 transition-colors duration-200 ease-in-out shrink-0 ${
isEnabled ? 'bg-krona-green' : 'bg-[#D8D5CB] dark:bg-[#3A3D35]'
}`}
>
<div
className={`w-4 h-4 rounded-full bg-white transition-transform duration-200 ease-in-out shadow-sm ${
isEnabled ? 'translate-x-5' : 'translate-x-0'
}`}
/>
</div>
</div>
);
})}
</div>
</div>
{/* 3. Role & RBAC selector */}
<div className="bg-krona-surface border border-krona-border rounded-xl p-4 shadow-krona-soft space-y-3">
<div className="flex items-center gap-2">
<Shield className="w-4 h-4 text-krona-info" />
<h3 className="text-xs font-bold text-krona-primary uppercase tracking-wider">
Матрица прав и ролей (RBAC)
</h3>
</div>
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-5 gap-2">
{Object.values(SYSTEM_ROLES).map((role) => (
<button
key={role.id}
onClick={() => setRoleId(role.id as SystemRoleId)}
className={`p-2.5 rounded-lg border text-left transition-all ${
role.id === currentRoleId
? 'border-krona-green bg-krona-green/10 text-krona-green-text font-bold'
: 'border-krona-border bg-krona-workspace text-krona-secondary hover:text-krona-primary'
}`}
>
<div className="text-xs font-semibold">{role.label}</div>
<div className="text-[10px] opacity-75">{role.badge}</div>
</button>
))}
</div>
</div>
</div>
);
};

View file

@ -0,0 +1,351 @@
import React, { useState } from 'react';
import { INITIAL_TASKS, TaskEntity } from '../../mock/mockData';
import { Badge } from '../../shared/components/Badge';
import { Button } from '../../shared/components/Button';
import { Modal } from '../../shared/components/Modal';
import { Input } from '../../shared/components/Input';
import {
CheckSquare,
Clock,
Plus,
User,
CheckCircle2,
MessageSquare,
Send,
Link,
Check,
Calendar,
} from 'lucide-react';
interface SubtaskItem {
id: string;
text: string;
completed: boolean;
}
interface CommentItem {
id: string;
author: string;
text: string;
time: string;
}
export const TasksModule: React.FC = () => {
const [tasks, setTasks] = useState<TaskEntity[]>(INITIAL_TASKS);
const [activeView, setActiveView] = useState<'kanban' | 'list'>('kanban');
// Selected task detail state
const [selectedTask, setSelectedTask] = useState<TaskEntity | null>(null);
const [subtasks, setSubtasks] = useState<SubtaskItem[]>([
{ id: 'st-1', text: 'Сверить расчетную себестоимость и маржинальность', completed: true },
{ id: 'st-2', text: 'Согласовать тайминг подачи с шеф-поваром', completed: false },
{ id: 'st-3', text: 'Выставить предварительный счет клиенту', completed: false },
]);
const [comments, setComments] = useState<CommentItem[]>([
{ id: 'c-1', author: 'Ольга Власова', text: 'Клиент подтвердил количество 60 персон. Ждем аванс.', time: 'Вчера, 16:20' },
{ id: 'c-2', author: 'Артур Ким (Шеф)', text: 'Продукты зарезервированы на основном складе.', time: 'Сегодня, 10:15' },
]);
const [newComment, setNewComment] = useState('');
const handleStatusChange = (taskId: string, nextStatus: TaskEntity['status']) => {
setTasks(tasks.map(t => t.id === taskId ? { ...t, status: nextStatus } : t));
if (selectedTask && selectedTask.id === taskId) {
setSelectedTask({ ...selectedTask, status: nextStatus });
}
};
const handleToggleSubtask = (id: string) => {
setSubtasks((prev) =>
prev.map((s) => (s.id === id ? { ...s, completed: !s.completed } : s))
);
};
const handleAddComment = (e: React.FormEvent) => {
e.preventDefault();
if (!newComment.trim()) return;
setComments((prev) => [
...prev,
{
id: `c-${Date.now()}`,
author: 'Вы (Текущий пользователь)',
text: newComment,
time: 'Только что',
},
]);
setNewComment('');
};
return (
<div className="space-y-4 animate-fade-in">
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3 bg-krona-surface p-3.5 rounded-xl border border-krona-border shadow-krona-soft">
<div>
<h2 className="text-sm font-bold text-krona-primary">
Задачи, поручения и проекты команды
</h2>
<p className="text-xs text-krona-secondary">
Канбан-доска исполнения, дедлайны, привязка к CRM и ответственные
</p>
</div>
<div className="flex items-center gap-2">
<div className="flex items-center bg-krona-workspace p-1 rounded-lg border border-krona-border text-xs">
<button
onClick={() => setActiveView('kanban')}
className={`px-3 py-1 rounded font-medium transition-colors ${
activeView === 'kanban' ? 'bg-[#2F684C] text-white shadow-sm' : 'text-krona-secondary'
}`}
>
Канбан
</button>
<button
onClick={() => setActiveView('list')}
className={`px-3 py-1 rounded font-medium transition-colors ${
activeView === 'list' ? 'bg-[#2F684C] text-white shadow-sm' : 'text-krona-secondary'
}`}
>
Список
</button>
</div>
<Button variant="primary" size="sm">
<Plus className="w-3.5 h-3.5 mr-1" />
Создать задачу
</Button>
</div>
</div>
{activeView === 'kanban' ? (
/* Kanban Board */
<div className="grid grid-cols-1 md:grid-cols-4 gap-3.5">
{(['todo', 'in_progress', 'review', 'done'] as const).map((st) => {
const colTasks = tasks.filter(t => t.status === st);
const colTitles: Record<string, string> = {
todo: 'К выполнению',
in_progress: 'В работе',
review: 'На проверке',
done: 'Выполнено',
};
return (
<div
key={st}
className="bg-krona-workspace/80 border border-krona-border rounded-xl p-3 flex flex-col min-h-[420px]"
>
<div className="flex items-center justify-between pb-2 mb-2 border-b border-krona-border">
<span className="text-xs font-bold text-krona-primary">{colTitles[st]}</span>
<span className="text-xs font-mono font-bold text-krona-secondary">
{colTasks.length}
</span>
</div>
<div className="space-y-2.5 flex-1 overflow-y-auto">
{colTasks.map((t) => (
<div
key={t.id}
onClick={() => setSelectedTask(t)}
className="p-3 rounded-lg border border-krona-border bg-krona-surface shadow-krona-soft space-y-2 cursor-pointer hover:border-krona-green/70 transition-all group"
>
<div className="flex items-center justify-between text-[10px] text-krona-secondary">
<span className="font-semibold uppercase">{t.project}</span>
<Badge
variant={t.priority === 'high' ? 'danger' : 'neutral'}
size="sm"
>
{t.priority === 'high' ? 'Срочно' : 'Обычный'}
</Badge>
</div>
<h5 className="text-xs font-bold text-krona-primary leading-snug group-hover:text-krona-green transition-colors">
{t.title}
</h5>
<div className="flex items-center justify-between pt-1 border-t border-krona-border/60 text-[11px] text-krona-secondary">
<span>{t.assignee}</span>
<span className="flex items-center gap-1 font-mono">
<Clock className="w-3 h-3" />
{t.dueDate}
</span>
</div>
{st !== 'done' && (
<div className="pt-1 flex justify-end">
<button
onClick={(e) => {
e.stopPropagation();
handleStatusChange(t.id, st === 'todo' ? 'in_progress' : st === 'in_progress' ? 'review' : 'done');
}}
className="text-[11px] text-krona-green font-medium hover:underline"
>
Сдвинуть вперед →
</button>
</div>
)}
</div>
))}
</div>
</div>
);
})}
</div>
) : (
/* List View */
<div className="bg-krona-surface border border-krona-border rounded-xl shadow-krona-soft p-4">
<table className="w-full text-left text-xs border-collapse">
<thead className="bg-krona-workspace/80 text-krona-secondary border-b border-krona-border">
<tr>
<th className="p-3 font-semibold">Название задачи</th>
<th className="p-3 font-semibold">Проект</th>
<th className="p-3 font-semibold">Ответственный</th>
<th className="p-3 font-semibold">Срок</th>
<th className="p-3 font-semibold">Приоритет</th>
<th className="p-3 font-semibold">Статус</th>
</tr>
</thead>
<tbody className="divide-y divide-krona-border/50">
{tasks.map((t) => (
<tr
key={t.id}
onClick={() => setSelectedTask(t)}
className="hover:bg-krona-workspace/50 transition-colors cursor-pointer"
>
<td className="p-3 font-medium text-krona-primary">{t.title}</td>
<td className="p-3 text-krona-secondary">{t.project}</td>
<td className="p-3 font-medium text-krona-primary">{t.assignee}</td>
<td className="p-3 text-krona-secondary font-mono">{t.dueDate}</td>
<td className="p-3">
<Badge variant={t.priority === 'high' ? 'danger' : 'neutral'} size="sm">
{t.priority === 'high' ? 'Высокий' : 'Обычный'}
</Badge>
</td>
<td className="p-3">
<Badge variant="gold" size="sm">{t.status}</Badge>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{/* Task Detail Modal (GAP 5) */}
{selectedTask && (
<Modal
isOpen={Boolean(selectedTask)}
onClose={() => setSelectedTask(null)}
title={`Задача: ${selectedTask.title}`}
subtitle={`Проект: ${selectedTask.project} • Исполнитель: ${selectedTask.assignee}`}
maxWidth="lg"
footer={
<div className="flex items-center justify-between w-full">
<div className="flex gap-2">
<Button
variant="primary"
size="sm"
onClick={() => handleStatusChange(selectedTask.id, 'done')}
>
<CheckCircle2 className="w-3.5 h-3.5 mr-1" />
Отметить выполненной
</Button>
</div>
<Button variant="secondary" size="sm" onClick={() => setSelectedTask(null)}>
Закрыть
</Button>
</div>
}
>
<div className="space-y-4 text-xs">
{/* Meta attributes */}
<div className="grid grid-cols-3 gap-3 p-3 rounded-lg bg-krona-workspace border border-krona-border">
<div>
<span className="text-krona-secondary block text-[10px]">Ответственный:</span>
<span className="font-semibold text-krona-primary">{selectedTask.assignee}</span>
</div>
<div>
<span className="text-krona-secondary block text-[10px]">Срок завершения:</span>
<span className="font-semibold text-krona-primary font-mono">{selectedTask.dueDate}</span>
</div>
<div>
<span className="text-krona-secondary block text-[10px]">Приоритет:</span>
<Badge variant={selectedTask.priority === 'high' ? 'danger' : 'neutral'} size="sm">
{selectedTask.priority === 'high' ? 'Высокий (Срочно)' : 'Обычный'}
</Badge>
</div>
</div>
{/* Linked CRM Entity */}
<div className="p-3 rounded-lg border border-krona-border bg-krona-surface flex items-center justify-between">
<div className="flex items-center gap-2">
<Link className="w-4 h-4 text-krona-gold shrink-0" />
<div>
<span className="font-bold text-krona-primary">Связанный CRM контрагент:</span>
<div className="text-[11px] text-krona-secondary">ООО "Сибирский Ритейл" (Сделка: Банкет на 60 персон)</div>
</div>
</div>
<Badge variant="gold" size="sm">CRM Link</Badge>
</div>
{/* Checklist of Subtasks */}
<div className="space-y-2">
<span className="text-[10px] font-bold uppercase tracking-wider text-krona-secondary block">
Чек-лист подзадач ({subtasks.filter(s => s.completed).length} из {subtasks.length}):
</span>
<div className="space-y-1.5 p-3 rounded-lg border border-krona-border bg-krona-workspace">
{subtasks.map((st) => (
<div
key={st.id}
onClick={() => handleToggleSubtask(st.id)}
className="flex items-center gap-2 cursor-pointer p-1 rounded hover:bg-krona-surface/60 transition-colors select-none"
>
<div
className={`w-4 h-4 rounded border flex items-center justify-center transition-colors ${
st.completed
? 'bg-krona-green border-krona-green text-white'
: 'border-krona-border bg-krona-surface'
}`}
>
{st.completed && <Check className="w-3 h-3 stroke-[3]" />}
</div>
<span className={`text-xs ${st.completed ? 'line-through text-krona-secondary' : 'text-krona-primary'}`}>
{st.text}
</span>
</div>
))}
</div>
</div>
{/* Comments Stream & Input */}
<div className="space-y-2">
<span className="text-[10px] font-bold uppercase tracking-wider text-krona-secondary block">
Обсуждение задачи ({comments.length}):
</span>
<div className="space-y-2 max-h-40 overflow-y-auto p-2 rounded-lg bg-krona-workspace border border-krona-border">
{comments.map((cm) => (
<div key={cm.id} className="p-2 rounded bg-krona-surface border border-krona-border/60 text-[11px] space-y-0.5">
<div className="flex items-center justify-between text-krona-secondary">
<span className="font-semibold text-krona-primary">{cm.author}</span>
<span className="text-[10px]">{cm.time}</span>
</div>
<p className="text-krona-primary">{cm.text}</p>
</div>
))}
</div>
<form onSubmit={handleAddComment} className="flex gap-2 pt-1">
<input
type="text"
value={newComment}
onChange={(e) => setNewComment(e.target.value)}
placeholder="Добавить комментарий к задаче..."
className="flex-1 bg-krona-workspace border border-krona-border rounded-lg px-3 py-1.5 text-xs text-krona-primary placeholder:text-krona-secondary focus:outline-none focus:border-krona-green"
/>
<Button type="submit" variant="primary" size="sm">
<Send className="w-3.5 h-3.5" />
</Button>
</form>
</div>
</div>
</Modal>
)}
</div>
);
};

270
src/index.css Normal file
View file

@ -0,0 +1,270 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
:root, [data-theme="soft-light"] {
/* Soft Light (Default theme) */
--bg-app: #EFEEE8;
--bg-workspace: #F3F1EB;
--bg-section: #F0EEE8;
--surface-default: #F8F6F0;
--surface-raised: #FBF9F3;
--surface-muted: #E9E7E0;
--surface-sunken: #E5E3DC;
--surface-hover: #EEECE5;
--surface-selected: #E2E9E1;
--surface-disabled: #E7E5DF;
--sidebar-bg: #252722;
--sidebar-deep: #20221E;
--sidebar-surface: #2B2D27;
--sidebar-hover: #30332C;
--sidebar-active: #34382F;
--sidebar-border: #3A3D35;
--sidebar-text: #D8D8D0;
--sidebar-text-secondary: #A9ACA3;
--sidebar-text-muted: #858980;
--sidebar-text-active: #F5F0E5;
--sidebar-icon: #BFC1B9;
--sidebar-icon-active: #D4B16D;
--sidebar-indicator: #B78335;
--text-primary: #252824;
--text-secondary: #686C65;
--text-tertiary: #7C8078;
--text-muted: #8A8D86;
--text-disabled: #AAACA6;
--text-inverse: #F4F2EB;
--text-brand: #805923;
--text-link: #2F684C;
--text-link-hover: #214B37;
--border-subtle: #E3E0D7;
--border-default: #D8D5CB;
--border-strong: #C6C3B9;
--border-focus: #5D8D6D;
--color-green: #2F684C;
--color-green-hover: #285A42;
--color-green-active: #214B37;
--color-green-soft: #DDE9DF;
--color-green-border: #BDD4C3;
--color-green-text: #315F46;
--color-gold: #B78335;
--color-gold-hover: #9E702D;
--color-gold-active: #805923;
--color-gold-soft: #EFE3CD;
--color-gold-border: #DFC79C;
--color-gold-text: #805923;
--color-danger: #A6534E;
--color-danger-soft: #F1DFDC;
--color-danger-border: #DAB6B1;
--color-danger-text: #873F3B;
--color-warning: #A8752F;
--color-warning-soft: #F2E7D3;
--color-warning-border: #DEC59B;
--color-warning-text: #80571F;
--color-info: #527286;
--color-info-soft: #DFE7EA;
--color-info-border: #BACBD1;
--color-info-text: #405F70;
}
[data-theme="light"] {
--bg-app: #F5F4EF;
--bg-workspace: #F8F7F2;
--bg-section: #F2F0E8;
--surface-default: #FCFAF5;
--surface-raised: #FFFFFF;
--surface-muted: #EFEEE8;
--surface-sunken: #E7E5DD;
--surface-hover: #F2EFE8;
--surface-selected: #E2EAE1;
--surface-disabled: #EBE9E2;
--sidebar-bg: #ECEAE3;
--sidebar-deep: #E4E1D8;
--sidebar-surface: #F2EFE8;
--sidebar-hover: #DFDDD4;
--sidebar-active: #D6D2C7;
--sidebar-border: #D3CFCA;
--sidebar-text: #3C4039;
--sidebar-text-secondary: #686C65;
--sidebar-text-muted: #8A8D86;
--sidebar-text-active: #1D201C;
--sidebar-icon: #555951;
--sidebar-icon-active: #B78335;
--sidebar-indicator: #B78335;
--text-primary: #252824;
--text-secondary: #686C65;
--text-tertiary: #7C8078;
--text-muted: #8A8D86;
--text-disabled: #AAACA6;
--text-inverse: #F4F2EB;
--text-brand: #805923;
--text-link: #2F684C;
--text-link-hover: #214B37;
--border-subtle: #E8E5DD;
--border-default: #DDDAD1;
--border-strong: #C8C5BB;
--border-focus: #5D8D6D;
--color-green: #2F684C;
--color-green-hover: #285A42;
--color-green-active: #214B37;
--color-green-soft: #E1EBE2;
--color-green-border: #BDD4C3;
--color-green-text: #315F46;
--color-gold: #B78335;
--color-gold-hover: #9E702D;
--color-gold-active: #805923;
--color-gold-soft: #F4EAD8;
--color-gold-border: #DFC79C;
--color-gold-text: #805923;
--color-danger: #A6534E;
--color-danger-soft: #F5E5E3;
--color-danger-border: #DAB6B1;
--color-danger-text: #873F3B;
--color-warning: #A8752F;
--color-warning-soft: #F7EEDB;
--color-warning-border: #DEC59B;
--color-warning-text: #80571F;
--color-info: #527286;
--color-info-soft: #E3EBEF;
--color-info-border: #BACBD1;
--color-info-text: #405F70;
}
[data-theme="dark"] {
--bg-app: #1D1F1B;
--bg-workspace: #22241F;
--bg-section: #252822;
--surface-default: #292B26;
--surface-raised: #30322C;
--surface-muted: #252722;
--surface-sunken: #1F211D;
--surface-hover: #343730;
--surface-selected: #2D3A30;
--surface-disabled: #2B2E28;
--sidebar-bg: #191B18;
--sidebar-deep: #151714;
--sidebar-surface: #20221E;
--sidebar-hover: #272A24;
--sidebar-active: #2F332C;
--sidebar-border: #2D302A;
--sidebar-text: #B2B4AC;
--sidebar-text-secondary: #888C83;
--sidebar-text-muted: #6E726A;
--sidebar-text-active: #E8E6DE;
--sidebar-icon: #9FA29A;
--sidebar-icon-active: #C99A4D;
--sidebar-indicator: #C99A4D;
--text-primary: #E8E6DE;
--text-secondary: #B2B4AC;
--text-tertiary: #888C83;
--text-muted: #72766D;
--text-disabled: #585B53;
--text-inverse: #1D1F1B;
--text-brand: #C99A4D;
--text-link: #6F9E7C;
--text-link-hover: #8AB796;
--border-subtle: #34372F;
--border-default: #3C3F37;
--border-strong: #4D5146;
--border-focus: #6F9E7C;
--color-green: #3D7A58;
--color-green-hover: #488E67;
--color-green-active: #2F684C;
--color-green-soft: #233529;
--color-green-border: #2D4C3A;
--color-green-text: #8FC9A6;
--color-gold: #C99A4D;
--color-gold-hover: #D7AB61;
--color-gold-active: #B78335;
--color-gold-soft: #382F1E;
--color-gold-border: #56472D;
--color-gold-text: #E5C384;
--color-danger: #B85853;
--color-danger-soft: #382423;
--color-danger-border: #59312F;
--color-danger-text: #E58D89;
--color-warning: #B78335;
--color-warning-soft: #382F1E;
--color-warning-border: #56472D;
--color-warning-text: #DEAF62;
--color-info: #5B8096;
--color-info-soft: #212F38;
--color-info-border: #304856;
--color-info-text: #8EAFC2;
}
body {
margin: 0;
padding: 0;
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background-color: var(--bg-app);
color: var(--text-primary);
min-height: 100vh;
overflow-x: hidden;
}
/* Custom scrollbars according to design system */
::-webkit-scrollbar {
width: 6px;
height: 6px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: #C6C4BC;
border-radius: 3px;
}
::-webkit-scrollbar-thumb:hover {
background: #AAA9A2;
}
[data-theme="dark"] ::-webkit-scrollbar-thumb {
background: #444740;
}
[data-theme="dark"] ::-webkit-scrollbar-thumb:hover {
background: #5A5D54;
}
/* Animations */
@keyframes fadeIn {
from { opacity: 0; transform: translateY(4px); }
to { opacity: 1; transform: translateY(0); }
}
.animate-fade-in {
animation: fadeIn 0.2s cubic-bezier(0.16, 1, 0.3, 1) forwards;
}
/* Glass & modal overlay */
.krona-overlay {
background-color: rgba(30, 31, 27, 0.42);
backdrop-filter: blur(2px);
}

10
src/main.tsx Normal file
View file

@ -0,0 +1,10 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
)

166
src/mock/mockApi.ts Normal file
View file

@ -0,0 +1,166 @@
import {
INITIAL_CLIENTS,
INITIAL_DEALS,
INITIAL_CATALOG,
INITIAL_INVENTORY,
INITIAL_RECIPES,
INITIAL_EMPLOYEES,
INITIAL_TICKETS,
INITIAL_CONVERSATIONS,
INITIAL_FINANCE,
INITIAL_TASKS,
ClientEntity,
DealEntity,
CatalogItemEntity,
InventoryItemEntity,
RecipeItem,
EmployeeEntity,
TicketEntity,
ConversationItem,
FinanceEntry,
TaskEntity,
} from './mockData';
const delay = (ms = 250) => new Promise((resolve) => setTimeout(resolve, ms));
class MockApiService {
private clients: ClientEntity[] = [...INITIAL_CLIENTS];
private deals: DealEntity[] = [...INITIAL_DEALS];
private catalog: CatalogItemEntity[] = [...INITIAL_CATALOG];
private inventory: InventoryItemEntity[] = [...INITIAL_INVENTORY];
private recipes: RecipeItem[] = [...INITIAL_RECIPES];
private employees: EmployeeEntity[] = [...INITIAL_EMPLOYEES];
private tickets: TicketEntity[] = [...INITIAL_TICKETS];
private conversations: ConversationItem[] = [...INITIAL_CONVERSATIONS];
private finance: FinanceEntry[] = [...INITIAL_FINANCE];
private tasks: TaskEntity[] = [...INITIAL_TASKS];
// Customers / Clients
async getCustomers(): Promise<ClientEntity[]> {
await delay(200);
return [...this.clients];
}
async createCustomer(data: Omit<ClientEntity, 'id'>): Promise<ClientEntity> {
await delay(300);
const newClient: ClientEntity = {
...data,
id: `cl-${Date.now()}`,
};
this.clients = [newClient, ...this.clients];
return newClient;
}
// Deals
async getDeals(): Promise<DealEntity[]> {
await delay(200);
return [...this.deals];
}
async updateDealStage(id: string, stage: DealEntity['stage']): Promise<void> {
await delay(150);
this.deals = this.deals.map((d) => (d.id === id ? { ...d, stage } : d));
}
// Catalog
async getCatalog(): Promise<CatalogItemEntity[]> {
await delay(200);
return [...this.catalog];
}
async toggleStopList(id: string): Promise<boolean> {
await delay(150);
let updated = false;
this.catalog = this.catalog.map((item) => {
if (item.id === id) {
updated = !item.isStopListed;
return { ...item, isStopListed: updated };
}
return item;
});
return updated;
}
// Inventory
async getInventory(): Promise<InventoryItemEntity[]> {
await delay(200);
return [...this.inventory];
}
// Recipes
async getRecipes(): Promise<RecipeItem[]> {
await delay(200);
return [...this.recipes];
}
// Employees
async getEmployees(): Promise<EmployeeEntity[]> {
await delay(200);
return [...this.employees];
}
// HelpDesk Tickets
async getTickets(): Promise<TicketEntity[]> {
await delay(200);
return [...this.tickets];
}
// Communications / Conversations
async getMessages(): Promise<ConversationItem[]> {
await delay(200);
return [...this.conversations];
}
async sendMessage(convId: string, text: string): Promise<void> {
await delay(150);
this.conversations = this.conversations.map((c) => {
if (c.id === convId) {
return {
...c,
lastMessage: text,
time: new Date().toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit' }),
messages: [
...c.messages,
{
id: `msg-${Date.now()}`,
sender: 'agent',
senderName: 'Вы (Оператор)',
text,
time: new Date().toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit' }),
},
],
};
}
return c;
});
}
// Finance
async getFinance(): Promise<FinanceEntry[]> {
await delay(200);
return [...this.finance];
}
async addFinanceEntry(entry: Omit<FinanceEntry, 'id'>): Promise<FinanceEntry> {
await delay(250);
const newEntry: FinanceEntry = {
...entry,
id: `fin-${Date.now()}`,
};
this.finance = [newEntry, ...this.finance];
return newEntry;
}
// Tasks
async getTasks(): Promise<TaskEntity[]> {
await delay(200);
return [...this.tasks];
}
async updateTaskStatus(taskId: string, status: TaskEntity['status']): Promise<void> {
await delay(150);
this.tasks = this.tasks.map((t) => (t.id === taskId ? { ...t, status } : t));
}
}
export const mockApi = new MockApiService();

635
src/mock/mockData.ts Normal file
View file

@ -0,0 +1,635 @@
export interface ClientEntity {
id: string;
name: string;
phone: string;
email: string;
category: 'VIP' | 'Постоянный' | 'Новый' | 'В зоне риска';
totalSpent: number;
ordersCount: number;
lastVisit: string;
source: string;
notes?: string;
vehicle?: string; // Car wash extension
}
export interface DealEntity {
id: string;
title: string;
clientName: string;
amount: number;
stage: 'lead' | 'qualified' | 'proposal' | 'negotiation' | 'won' | 'lost';
responsible: string;
deadline: string;
}
export interface CatalogItemEntity {
id: string;
name: string;
category: string;
price: number;
cost: number;
marginPercent: number;
stockCount: number;
unit: string;
isStopListed?: boolean;
image?: string;
description?: string;
}
export interface InventoryItemEntity {
id: string;
sku: string;
name: string;
category: string;
warehouse: string;
currentStock: number;
minStock: number;
unit: string;
unitCost: number;
supplier: string;
status: 'normal' | 'low' | 'critical';
}
export interface RecipeItem {
id: string;
name: string;
category: string;
yieldWeight: string;
foodCostPercent: number;
sellingPrice: number;
primeCost: number;
marginRub: number;
ingredients: { name: string; gross: string; net: string; cost: number }[];
}
export interface EmployeeEntity {
id: string;
name: string;
role: string;
status: 'on_shift' | 'off_shift' | 'break' | 'vacation';
phone: string;
shiftHours: string;
salesToday: number;
rating: number;
avatarInitials: string;
}
export interface TicketEntity {
id: string;
subject: string;
clientName: string;
channel: 'telegram' | 'whatsapp' | 'max' | 'web';
priority: 'urgent' | 'high' | 'medium' | 'low';
status: 'open' | 'in_progress' | 'waiting' | 'resolved';
assignee: string;
slaMinutesLeft: number;
createdTime: string;
}
export interface ConversationMessage {
id: string;
sender: 'client' | 'agent' | 'system' | 'ai';
senderName: string;
text: string;
time: string;
}
export interface ConversationItem {
id: string;
clientName: string;
channel: 'telegram' | 'whatsapp' | 'max' | 'vk' | 'internal';
lastMessage: string;
time: string;
unreadCount: number;
avatar: string;
messages: ConversationMessage[];
}
export interface FinanceEntry {
id: string;
date: string;
type: 'income' | 'expense';
category: string;
amount: number;
account: 'Касса Горский' | 'Расчетный счет (Точка)' | 'Касса Красный пр-т';
comment: string;
status: 'completed' | 'pending';
}
export interface TaskEntity {
id: string;
title: string;
assignee: string;
dueDate: string;
status: 'todo' | 'in_progress' | 'review' | 'done';
priority: 'low' | 'medium' | 'high';
project: string;
}
export const INITIAL_CLIENTS: ClientEntity[] = [
{
id: 'cl-1',
name: 'Александр Сорокин',
phone: '+7 (913) 901-22-33',
email: 'sorokin.a@gmail.com',
category: 'VIP',
totalSpent: 84200,
ordersCount: 28,
lastVisit: 'Сегодня, 13:40',
source: 'Мобильное приложение',
notes: 'Предпочитает столик у окна, аллергия на кунжут',
vehicle: 'Toyota Land Cruiser (Р777УУ 154)',
},
{
id: 'cl-2',
name: 'Мария Кузнецова',
phone: '+7 (923) 441-55-66',
email: 'mariya.k@yandex.ru',
category: 'Постоянный',
totalSpent: 42300,
ordersCount: 14,
lastVisit: 'Вчера, 19:15',
source: 'Telegram Бот',
notes: 'Любит острый Том Ям',
vehicle: 'BMW X5 (А001АА 154)',
},
{
id: 'cl-3',
name: 'ООО "Сибирский Ритейл"',
phone: '+7 (383) 210-99-88',
email: 'b2b@sibretail.ru',
category: 'VIP',
totalSpent: 480000,
ordersCount: 9,
lastVisit: '08.09.2026',
source: 'B2B Контракт',
notes: 'Оплата по безналу с НДС',
},
{
id: 'cl-4',
name: 'Дмитрий Мельников',
phone: '+7 (905) 332-11-44',
email: 'd.melnikov@mail.ru',
category: 'В зоне риска',
totalSpent: 18500,
ordersCount: 5,
lastVisit: '45 дней назад',
source: 'Сайт',
notes: 'Не заказывал более месяца, отправить промокод',
vehicle: 'Audi Q7 (О500ОК 154)',
},
{
id: 'cl-5',
name: 'Ольга Романова',
phone: '+7 (913) 887-65-43',
email: 'olga.beauty@inbox.ru',
category: 'Новый',
totalSpent: 3900,
ordersCount: 1,
lastVisit: 'Сегодня, 11:20',
source: 'VK Реклама',
notes: 'Первый заказ через кассу',
},
];
export const INITIAL_DEALS: DealEntity[] = [
{
id: 'deal-1',
title: 'Корпоративное обслуживание банкета (60 персон)',
clientName: 'ООО "Сибирский Ритейл"',
amount: 195000,
stage: 'proposal',
responsible: 'Ольга Власова',
deadline: '15.09.2026',
},
{
id: 'deal-2',
title: 'Договор на ежедневные B2B ланчи в офис',
clientName: 'ИТ-Компания "ДатаНова"',
amount: 340000,
stage: 'negotiation',
responsible: 'Михаил Захаров',
deadline: '20.09.2026',
},
{
id: 'deal-3',
title: 'Кейтеринг на закрытие выставки Экспоцентр',
clientName: 'Агентство СибЭкспо',
amount: 145000,
stage: 'qualified',
responsible: 'Ольга Власова',
deadline: '22.09.2026',
},
{
id: 'deal-4',
title: 'Оптовая поставка полуфабрикатов гёдза',
clientName: 'Сеть кофеен "Утро"',
amount: 88000,
stage: 'won',
responsible: 'Михаил Захаров',
deadline: '09.09.2026',
},
];
export const INITIAL_CATALOG: CatalogItemEntity[] = [
{
id: 'dish-1',
name: 'Филадельфия Премиум с лососем',
category: 'Роллы',
price: 690,
cost: 215,
marginPercent: 68.8,
stockCount: 84,
unit: 'порц.',
description: 'Охлажденный лосось, сливочный сыр Cremette, огурец, рис Косихикари',
},
{
id: 'dish-2',
name: 'Калифорния с камчатским крабом',
category: 'Роллы',
price: 740,
cost: 240,
marginPercent: 67.5,
stockCount: 42,
unit: 'порц.',
description: 'Натуральное мясо краба, авокадо, икра тобико, японский майонез',
},
{
id: 'dish-3',
name: 'Гёдза со свининой и пекинской капустой',
category: 'Горячее',
price: 460,
cost: 110,
marginPercent: 76.1,
stockCount: 95,
unit: 'порц.',
description: 'Обжаренные японские пельмени с соусом понзу',
},
{
id: 'dish-4',
name: 'Том Ям с морепродуктами',
category: 'Супы',
price: 590,
cost: 180,
marginPercent: 69.5,
stockCount: 60,
unit: 'порц.',
description: 'Тигровые креветки, кальмары, грибы цаогу, рис жасмин',
},
{
id: 'dish-5',
name: 'Рамен Тонкоцу на наваристом бульоне',
category: 'Супы',
price: 520,
cost: 145,
marginPercent: 72.1,
stockCount: 38,
unit: 'порц.',
description: 'Свиная грудинка тясю, маринованное яйцо адзитама, нори',
},
{
id: 'dish-6',
name: 'Угорь Кабаяки с рисом',
category: 'Горячее',
price: 890,
cost: 410,
marginPercent: 53.9,
stockCount: 0,
unit: 'порц.',
isStopListed: true,
description: 'Внимание: Временно в стоп-листе (ожидается поставка угря)',
},
{
id: 'srv-1',
name: 'Комплексная мойка кузова + салон Люкс',
category: 'Автоуслуги',
price: 2200,
cost: 450,
marginPercent: 79.5,
stockCount: 999,
unit: 'услуга',
},
{
id: 'srv-2',
name: 'Замена моторного масла и фильтра',
category: 'Автосервис',
price: 1800,
cost: 300,
marginPercent: 83.3,
stockCount: 999,
unit: 'услуга',
},
];
export const INITIAL_INVENTORY: InventoryItemEntity[] = [
{
id: 'inv-1',
sku: 'RAW-001',
name: 'Лосось атлантический охлажденный (филе)',
category: 'Рыба и морепродукты',
warehouse: 'Основной склад (Горский)',
currentStock: 2.4,
minStock: 6.0,
unit: 'кг',
unitCost: 1650,
supplier: 'СибРыбТорг',
status: 'critical',
},
{
id: 'inv-2',
sku: 'RAW-002',
name: 'Сыр творожный Cremette 65%',
category: 'Молочная продукция',
warehouse: 'Основной склад (Горский)',
currentStock: 18.0,
minStock: 10.0,
unit: 'кг',
unitCost: 590,
supplier: 'Хорека Логистик',
status: 'normal',
},
{
id: 'inv-3',
sku: 'RAW-003',
name: 'Рис для суши Косихикари Extra',
category: 'Бакалея',
warehouse: 'Сухой склад',
currentStock: 65.0,
minStock: 40.0,
unit: 'кг',
unitCost: 195,
supplier: 'Восточный Экспресс',
status: 'normal',
},
{
id: 'inv-4',
sku: 'RAW-004',
name: 'Тигровые креветки 16/20 б/г',
category: 'Рыба и морепродукты',
warehouse: 'Морозильная камера',
currentStock: 4.8,
minStock: 8.0,
unit: 'кг',
unitCost: 1100,
supplier: 'СибРыбТорг',
status: 'low',
},
{
id: 'inv-5',
sku: 'RAW-005',
name: 'Угорь копченый Унаги',
category: 'Рыба и морепродукты',
warehouse: 'Морозильная камера',
currentStock: 0.0,
minStock: 5.0,
unit: 'кг',
unitCost: 1890,
supplier: 'СибРыбТорг',
status: 'critical',
},
];
export const INITIAL_RECIPES: RecipeItem[] = [
{
id: 'rec-1',
name: 'Филадельфия Премиум (8 шт)',
category: 'Суши / Роллы',
yieldWeight: '270 г',
foodCostPercent: 31.2,
sellingPrice: 690,
primeCost: 215,
marginRub: 475,
ingredients: [
{ name: 'Рис вареный заправленный', gross: '140 г', net: '140 г', cost: 28 },
{ name: 'Лосось охлажденный с/с', gross: '110 г', net: '90 г', cost: 148 },
{ name: 'Сыр сливочный Cremette', gross: '40 г', net: '40 г', cost: 24 },
{ name: 'Огурец свежий очищенный', gross: '15 г', net: '12 г', cost: 4 },
{ name: 'Нори водоросли', gross: '1/2 листа', net: '1.5 г', cost: 11 },
],
},
{
id: 'rec-2',
name: 'Том Ям с морепродуктами',
category: 'Супы',
yieldWeight: '450/100 г',
foodCostPercent: 30.5,
sellingPrice: 590,
primeCost: 180,
marginRub: 410,
ingredients: [
{ name: 'Бульон кокосово-креветочный', gross: '350 мл', net: '350 мл', cost: 48 },
{ name: 'Тигровые креветки', gross: '60 г', net: '50 г', cost: 55 },
{ name: 'Кальмар филе', gross: '40 г', net: '35 г', cost: 22 },
{ name: 'Грибы цаогу / вешенки', gross: '40 г', net: '35 г', cost: 18 },
{ name: 'Паста том ям & лемонграсс', gross: '25 г', net: '25 г', cost: 25 },
{ name: 'Рис паровой жасмин', gross: '100 г', net: '100 г', cost: 12 },
],
},
];
export const INITIAL_EMPLOYEES: EmployeeEntity[] = [
{
id: 'emp-1',
name: 'Артур Ким',
role: 'Шеф-повар',
status: 'on_shift',
phone: '+7 (913) 111-22-33',
shiftHours: '10:00 – 22:00',
salesToday: 142000,
rating: 4.95,
avatarInitials: 'АК',
},
{
id: 'emp-2',
name: 'Алина Соколова',
role: 'Старший кассир / Администратор',
status: 'on_shift',
phone: '+7 (923) 555-66-77',
shiftHours: '09:30 – 21:30',
salesToday: 89400,
rating: 4.9,
avatarInitials: 'АС',
},
{
id: 'emp-3',
name: 'Алексей Смирнов',
role: 'Курьер на автомобиле',
status: 'on_shift',
phone: '+7 (913) 456-78-90',
shiftHours: '11:00 – 23:00',
salesToday: 24500,
rating: 4.88,
avatarInitials: 'АС',
},
{
id: 'emp-4',
name: 'Иван Кузнецов',
role: 'Мастер-диагност / Механик',
status: 'on_shift',
phone: '+7 (903) 777-88-99',
shiftHours: '09:00 – 20:00',
salesToday: 32000,
rating: 4.92,
avatarInitials: 'ИК',
},
{
id: 'emp-5',
name: 'Дарья Рожкова',
role: 'Специалист поддержки',
status: 'on_shift',
phone: '+7 (923) 333-44-11',
shiftHours: '09:00 – 18:00',
salesToday: 0,
rating: 5.0,
avatarInitials: 'ДР',
},
];
export const INITIAL_TICKETS: TicketEntity[] = [
{
id: 'TCK-401',
subject: 'Не привезли соевый соус и васаби к заказу #102',
clientName: 'Константин Игнатов',
channel: 'telegram',
priority: 'urgent',
status: 'in_progress',
assignee: 'Дарья Рожкова',
slaMinutesLeft: 12,
createdTime: '18 минут назад',
},
{
id: 'TCK-402',
subject: 'Вопрос по начислению бонусных баллов за банкет',
clientName: 'ООО "Сибирский Ритейл"',
channel: 'whatsapp',
priority: 'medium',
status: 'open',
assignee: 'Дарья Рожкова',
slaMinutesLeft: 45,
createdTime: '34 минуты назад',
},
{
id: 'TCK-403',
subject: 'Запрос акта сверки взаиморасчетов за август 2026',
clientName: 'ИП Григорьев В.В.',
channel: 'max',
priority: 'low',
status: 'waiting',
assignee: 'Бухгалтерия',
slaMinutesLeft: 180,
createdTime: '2 часа назад',
},
];
export const INITIAL_CONVERSATIONS: ConversationItem[] = [
{
id: 'conv-1',
clientName: 'Александр Сорокин',
channel: 'telegram',
lastMessage: 'Добрый день! Подскажите, стол 4 у окна сегодня свободен к 19:30?',
time: '13:42',
unreadCount: 1,
avatar: 'АС',
messages: [
{ id: 'm1', sender: 'client', senderName: 'Александр Сорокин', text: 'Добрый день! Подскажите, стол 4 у окна сегодня свободен к 19:30?', time: '13:42' },
],
},
{
id: 'conv-2',
clientName: 'Екатерина Морозова',
channel: 'whatsapp',
lastMessage: 'Спасибо за быструю доставку! Курьер был очень вежливым.',
time: '12:15',
unreadCount: 0,
avatar: 'ЕМ',
messages: [
{ id: 'm2', sender: 'agent', senderName: 'Крона Support', text: 'Ваш заказ #ORD-2038 передан курьеру и будет у вас через 25 минут.', time: '11:45' },
{ id: 'm3', sender: 'client', senderName: 'Екатерина Морозова', text: 'Спасибо за быструю доставку! Курьер был очень вежливым.', time: '12:15' },
],
},
{
id: 'conv-3',
clientName: 'Команда: Кухня и Зал',
channel: 'internal',
lastMessage: 'Артур: Заканчивается лосось, переключите калифорнию и филадельфию на контроль',
time: '11:10',
unreadCount: 2,
avatar: 'КК',
messages: [
{ id: 'm4', sender: 'agent', senderName: 'Артур (Шеф)', text: 'Заканчивается лосось, переключите калифорнию и филадельфию на контроль', time: '11:10' },
],
},
];
export const INITIAL_FINANCE: FinanceEntry[] = [
{
id: 'fin-1',
date: '10.09.2026',
type: 'income',
category: 'Выручка зала (касса)',
amount: 112400,
account: 'Касса Горский',
comment: 'Дневная выручка наличные + терминал',
status: 'completed',
},
{
id: 'fin-2',
date: '10.09.2026',
type: 'income',
category: 'Выручка доставки (эквайринг)',
amount: 72100,
account: 'Расчетный счет (Точка)',
comment: 'Оплата онлайн-заказов на сайте и в боте',
status: 'completed',
},
{
id: 'fin-3',
date: '10.09.2026',
type: 'expense',
category: 'Закупка сырья (Рыба/морепродукты)',
amount: 48500,
account: 'Расчетный счет (Точка)',
comment: 'Оплата поставщику СибРыбТорг по счету №842',
status: 'completed',
},
{
id: 'fin-4',
date: '10.09.2026',
type: 'expense',
category: 'Хозяйственные расходы',
amount: 3200,
account: 'Касса Горский',
comment: 'Упаковка и контейнеры для суши',
status: 'completed',
},
];
export const INITIAL_TASKS: TaskEntity[] = [
{
id: 'tsk-1',
title: 'Подготовить спецификацию банкета для ООО "Сибирский Ритейл"',
assignee: 'Ольга Власова',
dueDate: 'Сегодня, 17:00',
status: 'in_progress',
priority: 'high',
project: 'B2B Мероприятия',
},
{
id: 'tsk-2',
title: 'Провести сверку остатков по морозильной камере',
assignee: 'Артур Ким',
dueDate: 'Сегодня, 21:00',
status: 'todo',
priority: 'medium',
project: 'Складской контроль',
},
{
id: 'tsk-3',
title: 'Запустить рекламную кампанию сезонного сета в Telegram',
assignee: 'Маркетолог',
dueDate: '12.09.2026',
status: 'review',
priority: 'high',
project: 'Осеннее промо',
},
];

532
src/mock/store.ts Normal file
View file

@ -0,0 +1,532 @@
import { create } from 'zustand';
import { CapabilityId } from '../core/capabilities/types';
import { SystemRoleId } from '../core/permissions/types';
import { BUSINESS_PROFILES } from '../core/profiles/profiles';
import { BusinessProfile } from '../core/profiles/types';
export type ThemeMode = 'soft-light' | 'light' | 'dark';
export type ViewportMode = 'desktop' | 'tablet-landscape' | 'tablet-portrait' | 'mobile';
export interface PosCartItem {
id: string;
name: string;
price: number;
quantity: number;
category: string;
modifiers?: string[];
}
export interface KdsTicketItem {
name: string;
quantity: number;
station: 'Кухня' | 'Бар' | 'Суши-цех' | 'Гриль';
comment?: string;
}
export interface KdsTicket {
id: string;
orderNumber: string;
tableOrChannel: string;
status: 'new' | 'cooking' | 'ready' | 'served';
elapsedMinutes: number;
items: KdsTicketItem[];
isUrgent?: boolean;
}
export interface DeliveryItem {
id: string;
orderId: string;
recipientName: string;
phone: string;
address: string;
courierName: string;
status: 'pending' | 'in_transit' | 'delivered';
amount: number;
paymentMethod: 'cash' | 'card' | 'online';
etaMinutes: number;
}
export interface BookingSlot {
id: string;
resourceName: string; // "Стол 4", "Подъёмник 2", "Мастер Алина"
time: string;
clientName: string;
clientPhone: string;
guestsOrCar?: string;
status: 'confirmed' | 'seated' | 'completed' | 'cancelled';
deposit: number;
}
export interface WorkOrder {
id: string;
vehicle: string; // "Toyota Camry (О777ОО 154)"
ownerName: string;
phone: string;
bay: string; // "Пост 2"
masterName: string;
services: string[];
partsAmount: number;
laborAmount: number;
status: 'draft' | 'in_progress' | 'ready' | 'paid';
}
export interface AppNotification {
id: string;
title: string;
message: string;
type: 'info' | 'warning' | 'success' | 'danger';
timestamp: string;
actionUrl?: string;
read: boolean;
}
interface DemoStore {
// Theme & Viewport
theme: ThemeMode;
setTheme: (t: ThemeMode) => void;
viewportMode: ViewportMode;
setViewportMode: (v: ViewportMode) => void;
// Profile & Role
currentProfileId: string;
setProfileId: (id: string) => void;
currentRoleId: SystemRoleId;
setRoleId: (r: SystemRoleId) => void;
activeLocation: string;
setActiveLocation: (loc: string) => void;
// Capabilities customization per session
customCapabilities: Record<CapabilityId, boolean>;
toggleCapability: (id: CapabilityId) => void;
resetCapabilitiesToProfile: () => void;
// Modals & Panels
isCommandPaletteOpen: boolean;
setIsCommandPaletteOpen: (open: boolean) => void;
isQuickActionsOpen: boolean;
setIsQuickActionsOpen: (open: boolean) => void;
isNotificationsOpen: boolean;
setIsNotificationsOpen: (open: boolean) => void;
isAiDrawerOpen: boolean;
setIsAiDrawerOpen: (open: boolean) => void;
isRightPanelCollapsed: boolean;
setIsRightPanelCollapsed: (collapsed: boolean) => void;
// POS State
posCart: PosCartItem[];
posDiscountPercent: number;
posCustomerName: string;
posOrderType: 'hall' | 'takeaway' | 'delivery';
posTableNumber: string;
posPaymentModalOpen: boolean;
posLastReceipt: any | null;
addToCart: (item: Omit<PosCartItem, 'quantity'>) => void;
removeFromCart: (itemId: string) => void;
updateCartQuantity: (itemId: string, delta: number) => void;
clearCart: () => void;
setPosDiscount: (percent: number) => void;
setPosCustomer: (name: string) => void;
setPosOrderType: (type: 'hall' | 'takeaway' | 'delivery') => void;
setPosTableNumber: (table: string) => void;
setPosPaymentModalOpen: (open: boolean) => void;
completeCheckout: (paymentType: 'cash' | 'card' | 'qr' | 'split') => void;
// KDS State
kdsTickets: KdsTicket[];
updateKdsTicketStatus: (ticketId: string, nextStatus: KdsTicket['status']) => void;
// Deliveries State
deliveries: DeliveryItem[];
updateDeliveryStatus: (deliveryId: string, status: DeliveryItem['status']) => void;
// Bookings State
bookings: BookingSlot[];
addBooking: (booking: Omit<BookingSlot, 'id'>) => void;
updateBookingStatus: (id: string, status: BookingSlot['status']) => void;
// Work Orders State
workOrders: WorkOrder[];
updateWorkOrderStatus: (id: string, status: WorkOrder['status']) => void;
addWorkOrder: (order: Omit<WorkOrder, 'id'>) => void;
// Notifications
notifications: AppNotification[];
dismissNotification: (id: string) => void;
markAllNotificationsRead: () => void;
// Helper getters
getActiveProfile: () => BusinessProfile;
getActiveCapabilities: () => Record<CapabilityId, boolean>;
}
const INITIAL_KDS: KdsTicket[] = [
{
id: 'kds-1',
orderNumber: '#104',
tableOrChannel: 'Стол 4',
status: 'cooking',
elapsedMinutes: 8,
isUrgent: false,
items: [
{ name: 'Филадельфия Премиум', quantity: 2, station: 'Суши-цех' },
{ name: 'Том Ям с морепродуктами', quantity: 1, station: 'Кухня' },
],
},
{
id: 'kds-2',
orderNumber: '#105',
tableOrChannel: 'Доставка #42',
status: 'new',
elapsedMinutes: 2,
isUrgent: true,
items: [
{ name: 'Калифорния с крабом', quantity: 1, station: 'Суши-цех' },
{ name: 'Гёдза со свининой', quantity: 2, station: 'Кухня' },
{ name: 'Морс клюквенный 0.5', quantity: 2, station: 'Бар' },
],
},
{
id: 'kds-3',
orderNumber: '#103',
tableOrChannel: 'Стол 2',
status: 'ready',
elapsedMinutes: 14,
items: [
{ name: 'Рамен Тонкоцу', quantity: 1, station: 'Кухня' },
{ name: 'Эби Темпура', quantity: 1, station: 'Гриль' },
],
},
];
const INITIAL_DELIVERIES: DeliveryItem[] = [
{
id: 'del-1',
orderId: '#ORD-2041',
recipientName: 'Максим Белов',
phone: '+7 (913) 456-78-90',
address: 'ул. Ленина 21, кв. 44, подъезд 2, эт. 5',
courierName: 'Алексей Смирнов (Авто)',
status: 'in_transit',
amount: 2450,
paymentMethod: 'card',
etaMinutes: 18,
},
{
id: 'del-2',
orderId: '#ORD-2042',
recipientName: 'Екатерина Морозова',
phone: '+7 (923) 112-33-44',
address: 'Красный проспект 99, офис 305',
courierName: 'Дмитрий Волков (Самокат)',
status: 'pending',
amount: 1890,
paymentMethod: 'online',
etaMinutes: 35,
},
{
id: 'del-3',
orderId: '#ORD-2039',
recipientName: 'Олег Савельев',
phone: '+7 (905) 998-21-12',
address: 'ул. Кирова 48, кв. 12',
courierName: 'Алексей Смирнов (Авто)',
status: 'delivered',
amount: 3200,
paymentMethod: 'cash',
etaMinutes: 0,
},
];
const INITIAL_BOOKINGS: BookingSlot[] = [
{
id: 'book-1',
resourceName: 'Стол 4 (у окна, 4 чел)',
time: '19:00',
clientName: 'Анна Воронина',
clientPhone: '+7 (913) 777-12-34',
guestsOrCar: '3 гостя',
status: 'confirmed',
deposit: 1000,
},
{
id: 'book-2',
resourceName: 'Стол 1 (VIP, 6 чел)',
time: '20:30',
clientName: 'Виктор Платонов',
clientPhone: '+7 (923) 888-99-00',
guestsOrCar: '5 гостей',
status: 'confirmed',
deposit: 2000,
},
{
id: 'book-3',
resourceName: 'Пост 1 (Мойка + Детейлинг)',
time: '14:00',
clientName: 'Сергей Ковалев',
clientPhone: '+7 (903) 445-56-67',
guestsOrCar: 'BMW X5 (А001АА 154)',
status: 'seated',
deposit: 500,
},
];
const INITIAL_WORK_ORDERS: WorkOrder[] = [
{
id: 'WO-881',
vehicle: 'Toyota Land Cruiser (Р777УУ 154)',
ownerName: 'Андрей Романов',
phone: '+7 (913) 222-33-44',
bay: 'Пост 2 (Подъёмник)',
masterName: 'Иван Кузнецов',
services: ['Замена масла ДВС', 'Диагностика ходовой', 'Замена передних колодок'],
partsAmount: 8600,
laborAmount: 4200,
status: 'in_progress',
},
{
id: 'WO-882',
vehicle: 'Porsche Cayenne (В500ВВ 154)',
ownerName: 'Елена Соколова',
phone: '+7 (923) 444-11-22',
bay: 'Пост 4 (Детейлинг бокс)',
masterName: 'Артем Васильев',
services: ['Комплексная мойка Люкс', 'Керамическое покрытие кузова', 'Консервация кожи'],
partsAmount: 3500,
laborAmount: 14000,
status: 'ready',
},
];
const INITIAL_NOTIFICATIONS: AppNotification[] = [
{
id: 'notif-1',
title: 'Критический остаток на складе',
message: 'Лосось охлажденный (филе): осталось 2.4 кг (порог 6 кг). Оформите заявку поставщику.',
type: 'danger',
timestamp: '12 минут назад',
actionUrl: '/operations/inventory',
read: false,
},
{
id: 'notif-2',
title: 'Долгий заказ в обработке',
message: 'Заказ #104 на Стол 4 готовится уже более 18 минут. Требуется внимание шефа.',
type: 'warning',
timestamp: '5 минут назад',
actionUrl: '/operations/kds',
read: false,
},
{
id: 'notif-3',
title: 'Дневная цель по выручке выполнена',
message: 'Выручка за сегодня достигла 184 500 ₽ (108% от плана).',
type: 'success',
timestamp: '40 минут назад',
read: false,
},
];
export const useDemoStore = create<DemoStore>((set, get) => ({
// Theme & Viewport defaults
theme: 'soft-light',
setTheme: (theme) => {
document.documentElement.setAttribute('data-theme', theme);
set({ theme });
},
viewportMode: 'desktop',
setViewportMode: (viewportMode) => set({ viewportMode }),
// Profile & Role defaults
currentProfileId: 'restaurant_hall_delivery',
setProfileId: (currentProfileId) => {
const profile = BUSINESS_PROFILES[currentProfileId] || BUSINESS_PROFILES.restaurant_hall_delivery;
set({
currentProfileId,
customCapabilities: { ...profile.capabilities },
activeLocation: profile.locations[0] || 'Главная точка',
});
},
currentRoleId: 'owner',
setRoleId: (currentRoleId) => set({ currentRoleId }),
activeLocation: 'Горский 1/1',
setActiveLocation: (activeLocation) => set({ activeLocation }),
// Capabilities
customCapabilities: { ...BUSINESS_PROFILES.restaurant_hall_delivery.capabilities },
toggleCapability: (id) =>
set((state) => ({
customCapabilities: {
...state.customCapabilities,
[id]: !state.customCapabilities[id],
},
})),
resetCapabilitiesToProfile: () => {
const state = get();
const profile = BUSINESS_PROFILES[state.currentProfileId] || BUSINESS_PROFILES.restaurant_hall_delivery;
set({ customCapabilities: { ...profile.capabilities } });
},
// Panels
isCommandPaletteOpen: false,
setIsCommandPaletteOpen: (isCommandPaletteOpen) => set({ isCommandPaletteOpen }),
isQuickActionsOpen: false,
setIsQuickActionsOpen: (isQuickActionsOpen) => set({ isQuickActionsOpen }),
isNotificationsOpen: false,
setIsNotificationsOpen: (isNotificationsOpen) => set({ isNotificationsOpen }),
isAiDrawerOpen: false,
setIsAiDrawerOpen: (isAiDrawerOpen) => set({ isAiDrawerOpen }),
isRightPanelCollapsed: false,
setIsRightPanelCollapsed: (isRightPanelCollapsed) => set({ isRightPanelCollapsed }),
// POS State
posCart: [
{ id: 'pos-1', name: 'Филадельфия Премиум', price: 690, quantity: 1, category: 'Роллы' },
{ id: 'pos-2', name: 'Том Ям с креветками', price: 540, quantity: 1, category: 'Супы' },
],
posDiscountPercent: 0,
posCustomerName: 'Александр (постоянный гость)',
posOrderType: 'hall',
posTableNumber: 'Стол 4',
posPaymentModalOpen: false,
posLastReceipt: null,
addToCart: (item) =>
set((state) => {
const existing = state.posCart.find((c) => c.id === item.id);
if (existing) {
return {
posCart: state.posCart.map((c) =>
c.id === item.id ? { ...c, quantity: c.quantity + 1 } : c
),
};
}
return { posCart: [...state.posCart, { ...item, quantity: 1 }] };
}),
removeFromCart: (itemId) =>
set((state) => ({
posCart: state.posCart.filter((c) => c.id !== itemId),
})),
updateCartQuantity: (itemId, delta) =>
set((state) => ({
posCart: state.posCart
.map((c) => (c.id === itemId ? { ...c, quantity: Math.max(1, c.quantity + delta) } : c))
.filter((c) => c.quantity > 0),
})),
clearCart: () => set({ posCart: [], posDiscountPercent: 0 }),
setPosDiscount: (posDiscountPercent) => set({ posDiscountPercent }),
setPosCustomer: (posCustomerName) => set({ posCustomerName }),
setPosOrderType: (posOrderType) => set({ posOrderType }),
setPosTableNumber: (posTableNumber) => set({ posTableNumber }),
setPosPaymentModalOpen: (posPaymentModalOpen) => set({ posPaymentModalOpen }),
completeCheckout: (paymentType) => {
const state = get();
const subtotal = state.posCart.reduce((acc, item) => acc + item.price * item.quantity, 0);
const discount = (subtotal * state.posDiscountPercent) / 100;
const total = subtotal - discount;
const receipt = {
orderNumber: `#${Math.floor(100 + Math.random() * 900)}`,
timestamp: new Date().toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit' }),
items: [...state.posCart],
subtotal,
discount,
total,
paymentType,
customer: state.posCustomerName,
location: state.activeLocation,
};
// Add to KDS if KDS is active
if (state.customCapabilities.kds) {
const newTicket: KdsTicket = {
id: `kds-${Date.now()}`,
orderNumber: receipt.orderNumber,
tableOrChannel: state.posOrderType === 'hall' ? state.posTableNumber : 'Навынос / Касса',
status: 'new',
elapsedMinutes: 0,
items: state.posCart.map((c) => ({
name: c.name,
quantity: c.quantity,
station: c.category.includes('Роллы') || c.category.includes('Суши') ? 'Суши-цех' : 'Кухня',
})),
};
set({ kdsTickets: [newTicket, ...state.kdsTickets] });
}
set({
posLastReceipt: receipt,
posCart: [],
posPaymentModalOpen: false,
});
},
// KDS
kdsTickets: INITIAL_KDS,
updateKdsTicketStatus: (ticketId, nextStatus) =>
set((state) => ({
kdsTickets: state.kdsTickets.map((t) =>
t.id === ticketId ? { ...t, status: nextStatus } : t
),
})),
// Deliveries
deliveries: INITIAL_DELIVERIES,
updateDeliveryStatus: (deliveryId, status) =>
set((state) => ({
deliveries: state.deliveries.map((d) =>
d.id === deliveryId ? { ...d, status } : d
),
})),
// Bookings
bookings: INITIAL_BOOKINGS,
addBooking: (booking) =>
set((state) => ({
bookings: [
...state.bookings,
{ ...booking, id: `book-${Date.now()}` },
],
})),
updateBookingStatus: (id, status) =>
set((state) => ({
bookings: state.bookings.map((b) => (b.id === id ? { ...b, status } : b)),
})),
// Work Orders
workOrders: INITIAL_WORK_ORDERS,
updateWorkOrderStatus: (id, status) =>
set((state) => ({
workOrders: state.workOrders.map((wo) => (wo.id === id ? { ...wo, status } : wo)),
})),
addWorkOrder: (order) =>
set((state) => ({
workOrders: [
{ ...order, id: `WO-${Math.floor(800 + Math.random() * 200)}` },
...state.workOrders,
],
})),
// Notifications
notifications: INITIAL_NOTIFICATIONS,
dismissNotification: (id) =>
set((state) => ({
notifications: state.notifications.filter((n) => n.id !== id),
})),
markAllNotificationsRead: () =>
set((state) => ({
notifications: state.notifications.map((n) => ({ ...n, read: true })),
})),
// Getters
getActiveProfile: () => {
const s = get();
return BUSINESS_PROFILES[s.currentProfileId] || BUSINESS_PROFILES.restaurant_hall_delivery;
},
getActiveCapabilities: () => {
return get().customCapabilities;
},
}));

View file

@ -0,0 +1,38 @@
import React from 'react';
import { clsx } from 'clsx';
import { twMerge } from 'tailwind-merge';
export interface BadgeProps extends React.HTMLAttributes<HTMLSpanElement> {
variant?: 'neutral' | 'green' | 'gold' | 'warning' | 'danger' | 'info';
size?: 'sm' | 'md';
}
export const Badge: React.FC<BadgeProps> = ({
children,
className,
variant = 'neutral',
size = 'md',
...props
}) => {
const baseStyles = 'inline-flex items-center font-medium rounded-full border border-solid';
const variantStyles = {
neutral: 'bg-[#E9E7E0] dark:bg-[#32352E] text-[#555951] dark:text-[#C5C8C0] border-[#D8D5CB] dark:border-[#40433A]',
green: 'bg-[#DDE9DF] dark:bg-[#203426] text-[#315F46] dark:text-[#8DC49D] border-[#BDD4C3] dark:border-[#2C4835]',
gold: 'bg-[#EFE3CD] dark:bg-[#382F1E] text-[#805923] dark:text-[#E2BD75] border-[#DFC79C] dark:border-[#524429]',
warning: 'bg-[#F2E7D3] dark:bg-[#382B18] text-[#80571F] dark:text-[#E5B562] border-[#DEC59B] dark:border-[#544123]',
danger: 'bg-[#F1DFDC] dark:bg-[#382121] text-[#873F3B] dark:text-[#E68884] border-[#DAB6B1] dark:border-[#573030]',
info: 'bg-[#DFE7EA] dark:bg-[#1E2E38] text-[#405F70] dark:text-[#8BB4C9] border-[#BACBD1] dark:border-[#2E4756]',
};
const sizeStyles = {
sm: 'text-[11px] px-2 py-0.5 leading-tight',
md: 'text-xs px-2.5 py-1 leading-normal',
};
return (
<span className={twMerge(clsx(baseStyles, variantStyles[variant], sizeStyles[size], className))} {...props}>
{children}
</span>
);
};

View file

@ -0,0 +1,53 @@
import React from 'react';
import { clsx } from 'clsx';
import { twMerge } from 'tailwind-merge';
export interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
variant?: 'primary' | 'secondary' | 'gold' | 'danger' | 'ghost' | 'outline';
size?: 'sm' | 'md' | 'lg' | 'icon';
loading?: boolean;
}
export const Button: React.FC<ButtonProps> = ({
children,
className,
variant = 'primary',
size = 'md',
disabled,
loading,
...props
}) => {
const baseStyles = 'inline-flex items-center justify-center font-medium transition-all duration-150 rounded-md focus:outline-none focus:ring-2 focus:ring-krona-green/30 disabled:opacity-50 disabled:pointer-events-none active:scale-[0.98] select-none';
const variantStyles = {
primary: 'bg-[#2F684C] hover:bg-[#285A42] active:bg-[#214B37] text-[#F8F6F0] shadow-sm',
secondary: 'bg-[#E9E7E0] dark:bg-[#30322C] hover:bg-[#DFDDD5] dark:hover:bg-[#393C34] text-krona-primary border border-krona-border',
gold: 'bg-[#B78335] hover:bg-[#9E702D] active:bg-[#805923] text-[#FBF8F0] shadow-sm',
danger: 'bg-[#A6534E] hover:bg-[#873F3B] active:bg-[#6D2F2C] text-white shadow-sm',
ghost: 'bg-transparent hover:bg-black/5 dark:hover:bg-white/5 text-krona-primary',
outline: 'bg-transparent border border-krona-border hover:bg-black/5 dark:hover:bg-white/5 text-krona-primary',
};
const sizeStyles = {
sm: 'text-xs px-2.5 py-1.5 gap-1.5',
md: 'text-sm px-3.5 py-2 gap-2',
lg: 'text-base px-5 py-2.5 gap-2.5',
icon: 'p-2',
};
return (
<button
className={twMerge(clsx(baseStyles, variantStyles[variant], sizeStyles[size], className))}
disabled={disabled || loading}
{...props}
>
{loading && (
<svg className="animate-spin -ml-0.5 mr-2 h-4 w-4 text-current" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
)}
{children}
</button>
);
};

View file

@ -0,0 +1,78 @@
import React, { useEffect } from 'react';
import { X } from 'lucide-react';
import { Button } from './Button';
export interface DrawerProps {
isOpen: boolean;
onClose: () => void;
title: string;
subtitle?: string;
children: React.ReactNode;
footer?: React.ReactNode;
width?: 'sm' | 'md' | 'lg' | 'xl';
}
export const Drawer: React.FC<DrawerProps> = ({
isOpen,
onClose,
title,
subtitle,
children,
footer,
width = 'md',
}) => {
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape' && isOpen) onClose();
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [isOpen, onClose]);
if (!isOpen) return null;
const widthStyles = {
sm: 'max-w-sm',
md: 'max-w-md',
lg: 'max-w-lg',
xl: 'max-w-xl',
};
return (
<div className="fixed inset-0 z-50 overflow-hidden">
{/* Backdrop */}
<div
className="fixed inset-0 krona-overlay transition-opacity"
onClick={onClose}
/>
{/* Drawer panel */}
<div className="fixed inset-y-0 right-0 max-w-full flex pl-10">
<div className={`w-screen ${widthStyles[width]} bg-krona-raised border-l border-krona-border shadow-krona-modal flex flex-col animate-fade-in`}>
{/* Header */}
<div className="px-5 py-4 border-b border-krona-border flex items-center justify-between bg-krona-surface/60">
<div>
<h3 className="text-sm font-semibold text-krona-primary">{title}</h3>
{subtitle && <p className="text-xs text-krona-secondary mt-0.5">{subtitle}</p>}
</div>
<Button variant="ghost" size="icon" onClick={onClose}>
<X className="w-4 h-4" />
</Button>
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto p-5 text-sm">
{children}
</div>
{/* Footer */}
{footer && (
<div className="p-4 border-t border-krona-border bg-krona-surface/40 flex justify-end gap-2">
{footer}
</div>
)}
</div>
</div>
</div>
);
};

View file

@ -0,0 +1,34 @@
import React from 'react';
import { LucideIcon, Inbox } from 'lucide-react';
import { Button } from './Button';
export interface EmptyStateProps {
title: string;
description: string;
icon?: LucideIcon;
actionText?: string;
onAction?: () => void;
}
export const EmptyState: React.FC<EmptyStateProps> = ({
title,
description,
icon: Icon = Inbox,
actionText,
onAction,
}) => {
return (
<div className="flex flex-col items-center justify-center p-8 text-center bg-krona-surface border border-dashed border-krona-border rounded-lg my-4">
<div className="p-3 bg-krona-workspace rounded-full text-krona-secondary mb-3">
<Icon className="w-6 h-6" />
</div>
<h4 className="text-sm font-semibold text-krona-primary mb-1">{title}</h4>
<p className="text-xs text-krona-secondary max-w-sm mb-4">{description}</p>
{actionText && onAction && (
<Button variant="primary" size="sm" onClick={onAction}>
{actionText}
</Button>
)}
</div>
);
};

View file

@ -0,0 +1,50 @@
import React from 'react';
import { clsx } from 'clsx';
import { twMerge } from 'tailwind-merge';
export interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {
label?: string;
error?: string;
helperText?: string;
icon?: React.ReactNode;
}
export const Input = React.forwardRef<HTMLInputElement, InputProps>(
({ label, error, helperText, icon, className, id, ...props }, ref) => {
const inputId = id || label?.toLowerCase().replace(/\s+/g, '-');
return (
<div className="w-full flex flex-col gap-1.5 text-left">
{label && (
<label htmlFor={inputId} className="text-xs font-medium text-krona-secondary select-none">
{label}
</label>
)}
<div className="relative flex items-center">
{icon && (
<div className="absolute left-3 text-krona-secondary pointer-events-none flex items-center">
{icon}
</div>
)}
<input
ref={ref}
id={inputId}
className={twMerge(
clsx(
'w-full bg-[#F8F6F0] dark:bg-[#252822] text-krona-primary border border-krona-border rounded-md px-3 py-2 text-sm placeholder:text-[#92958E] dark:placeholder:text-[#6E726A] transition-colors focus:outline-none focus:border-[#5D8D6D] focus:ring-2 focus:ring-[#5D8D6D]/20 disabled:opacity-50',
icon && 'pl-9',
error && 'border-[#A6534E] focus:border-[#A6534E] focus:ring-[#A6534E]/20',
className
)
)}
{...props}
/>
</div>
{error && <span className="text-xs text-krona-danger">{error}</span>}
{helperText && !error && <span className="text-xs text-krona-secondary">{helperText}</span>}
</div>
);
}
);
Input.displayName = 'Input';

View file

@ -0,0 +1,85 @@
import React, { useEffect } from 'react';
import { X } from 'lucide-react';
import { Button } from './Button';
export interface ModalProps {
isOpen: boolean;
onClose: () => void;
title: string;
subtitle?: string;
children: React.ReactNode;
footer?: React.ReactNode;
maxWidth?: 'sm' | 'md' | 'lg' | 'xl' | '2xl' | '3xl' | '4xl';
}
export const Modal: React.FC<ModalProps> = ({
isOpen,
onClose,
title,
subtitle,
children,
footer,
maxWidth = 'md',
}) => {
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape' && isOpen) {
onClose();
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [isOpen, onClose]);
if (!isOpen) return null;
const maxWidthStyles = {
sm: 'max-w-sm',
md: 'max-w-md',
lg: 'max-w-lg',
xl: 'max-w-xl',
'2xl': 'max-w-2xl',
'3xl': 'max-w-3xl',
'4xl': 'max-w-4xl',
};
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
{/* Backdrop */}
<div
className="fixed inset-0 krona-overlay transition-opacity duration-200"
onClick={onClose}
/>
{/* Modal Dialog */}
<div
className={`relative w-full ${maxWidthStyles[maxWidth]} 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`}
role="dialog"
aria-modal="true"
>
{/* Header */}
<div className="flex items-center justify-between px-6 py-4 border-b border-krona-border/60 bg-krona-surface/50">
<div>
<h3 className="text-base font-semibold text-krona-primary">{title}</h3>
{subtitle && <p className="text-xs text-krona-secondary mt-0.5">{subtitle}</p>}
</div>
<Button variant="ghost" size="icon" onClick={onClose} aria-label="Закрыть">
<X className="w-4 h-4" />
</Button>
</div>
{/* Body */}
<div className="px-6 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">
{footer}
</div>
)}
</div>
</div>
);
};

View file

@ -0,0 +1,81 @@
import React from 'react';
import { clsx } from 'clsx';
import { TrendingUp, TrendingDown, Minus } from 'lucide-react';
export interface StatCardProps {
title: string;
value: string | number;
change?: {
value: string;
trend: 'up' | 'down' | 'neutral';
label?: string;
};
subtitle?: string;
icon?: React.ReactNode;
actionText?: string;
onAction?: () => void;
className?: string;
}
export const StatCard: React.FC<StatCardProps> = ({
title,
value,
change,
subtitle,
icon,
actionText,
onAction,
className,
}) => {
return (
<div
className={clsx(
'bg-krona-surface border border-krona-border rounded-lg p-4 shadow-krona-soft flex flex-col justify-between transition-all duration-150 hover:border-krona-border-strong',
className
)}
>
<div className="flex items-start justify-between gap-3">
<div className="flex-1 min-w-0">
<p className="text-xs font-medium text-krona-secondary truncate">{title}</p>
<div className="mt-1.5 flex items-baseline gap-2">
<span className="text-2xl font-semibold text-krona-primary tracking-tight">{value}</span>
{change && (
<span
className={clsx(
'inline-flex items-center text-xs font-medium px-1.5 py-0.5 rounded',
change.trend === 'up' && 'text-[#315F46] bg-[#DDE9DF] dark:text-[#8DC49D] dark:bg-[#203426]',
change.trend === 'down' && 'text-[#873F3B] bg-[#F1DFDC] dark:text-[#E68884] dark:bg-[#382121]',
change.trend === 'neutral' && 'text-krona-secondary bg-krona-muted'
)}
>
{change.trend === 'up' && <TrendingUp className="w-3 h-3 mr-0.5" />}
{change.trend === 'down' && <TrendingDown className="w-3 h-3 mr-0.5" />}
{change.trend === 'neutral' && <Minus className="w-3 h-3 mr-0.5" />}
{change.value}
</span>
)}
</div>
</div>
{icon && (
<div className="p-2.5 rounded-lg bg-krona-workspace border border-krona-border/60 text-krona-secondary shrink-0">
{icon}
</div>
)}
</div>
{(subtitle || actionText) && (
<div className="mt-3 pt-2.5 border-t border-krona-border/40 flex items-center justify-between text-xs text-krona-secondary">
{subtitle && <span>{subtitle}</span>}
{actionText && (
<button
onClick={onAction}
className="text-[#2F684C] dark:text-[#8FC9A6] font-medium hover:underline focus:outline-none ml-auto"
>
{actionText} →
</button>
)}
</div>
)}
</div>
);
};

View file

@ -0,0 +1,15 @@
export function formatCurrency(amount: number): string {
return new Intl.NumberFormat('ru-RU', {
style: 'currency',
currency: 'RUB',
maximumFractionDigits: 0,
}).format(amount);
}
export function formatNumber(value: number): string {
return new Intl.NumberFormat('ru-RU').format(value);
}
export function formatPercent(value: number): string {
return `${value.toFixed(1)}%`;
}

88
tailwind.config.js Normal file
View file

@ -0,0 +1,88 @@
/** @type {import('tailwindcss').Config} */
export default {
content: [
"./index.html",
"./src/**/*.{js,ts,jsx,tsx}",
],
theme: {
extend: {
colors: {
krona: {
app: 'var(--bg-app)',
workspace: 'var(--bg-workspace)',
section: 'var(--bg-section)',
surface: 'var(--surface-default)',
raised: 'var(--surface-raised)',
muted: 'var(--surface-muted)',
sunken: 'var(--surface-sunken)',
hover: 'var(--surface-hover)',
selected: 'var(--surface-selected)',
border: 'var(--border-default)',
'border-subtle': 'var(--border-subtle)',
'border-strong': 'var(--border-strong)',
primary: 'var(--text-primary)',
secondary: 'var(--text-secondary)',
tertiary: 'var(--text-tertiary)',
disabled: 'var(--text-disabled)',
green: {
DEFAULT: 'var(--color-green)',
hover: 'var(--color-green-hover)',
active: 'var(--color-green-active)',
soft: 'var(--color-green-soft)',
border: 'var(--color-green-border)',
text: 'var(--color-green-text)',
},
gold: {
DEFAULT: 'var(--color-gold)',
hover: 'var(--color-gold-hover)',
active: 'var(--color-gold-active)',
soft: 'var(--color-gold-soft)',
border: 'var(--color-gold-border)',
text: 'var(--color-gold-text)',
},
danger: {
DEFAULT: 'var(--color-danger)',
soft: 'var(--color-danger-soft)',
border: 'var(--color-danger-border)',
text: 'var(--color-danger-text)',
},
warning: {
DEFAULT: 'var(--color-warning)',
soft: 'var(--color-warning-soft)',
border: 'var(--color-warning-border)',
text: 'var(--color-warning-text)',
},
info: {
DEFAULT: 'var(--color-info)',
soft: 'var(--color-info-soft)',
border: 'var(--color-info-border)',
text: 'var(--color-info-text)',
},
},
},
fontFamily: {
sans: [
'Inter',
'-apple-system',
'BlinkMacSystemFont',
'"Segoe UI"',
'Roboto',
'Helvetica',
'Arial',
'sans-serif',
],
mono: [
'"JetBrains Mono"',
'Consolas',
'monospace',
]
},
boxShadow: {
'krona-soft': '0 1px 3px rgba(54, 50, 40, 0.05), 0 1px 2px rgba(54, 50, 40, 0.03)',
'krona-raised': '0 4px 12px rgba(54, 50, 40, 0.08), 0 2px 4px rgba(54, 50, 40, 0.04)',
'krona-modal': '0 12px 32px rgba(42, 39, 31, 0.16), 0 4px 8px rgba(42, 39, 31, 0.08)',
}
},
},
plugins: [],
}

29
tsconfig.app.json Normal file
View file

@ -0,0 +1,29 @@
{
"compilerOptions": {
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"types": ["vite/client"],
/* Bundler mode */
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
/* Paths */
"paths": {
"@/*": ["./src/*"]
},
/* Linting */
"strict": true,
"noUnusedLocals": false,
"noUnusedParameters": false,
"noFallthroughCasesInSwitch": true
},
"include": ["src"]
}

1
tsconfig.app.tsbuildinfo Normal file
View file

@ -0,0 +1 @@
{"root":["./src/app.tsx","./src/main.tsx","./src/app/layouts/appshell.tsx","./src/app/router/index.tsx","./src/app/shell/bottomnav.tsx","./src/app/shell/commandpalette.tsx","./src/app/shell/notificationsdrawer.tsx","./src/app/shell/quickactionsmodal.tsx","./src/app/shell/rightcommandpanel.tsx","./src/app/shell/sidebar.tsx","./src/app/shell/topbar.tsx","./src/core/capabilities/types.ts","./src/core/modules/registry.ts","./src/core/modules/types.ts","./src/core/permissions/types.ts","./src/core/profiles/profiles.ts","./src/core/profiles/types.ts","./src/features/ai/aicentermodule.tsx","./src/features/analytics/analyticsmodule.tsx","./src/features/apps/appscatalogmodule.tsx","./src/features/automation/automationmodule.tsx","./src/features/cms/cmsmodule.tsx","./src/features/communications/communicationsmodule.tsx","./src/features/crm/crmmodule.tsx","./src/features/dashboard/dynamicdashboard.tsx","./src/features/employees/employeesmodule.tsx","./src/features/finance/financemodule.tsx","./src/features/helpdesk/helpdeskmodule.tsx","./src/features/inventory/inventorymodule.tsx","./src/features/marketing/marketingmodule.tsx","./src/features/operations/baysview.tsx","./src/features/operations/bookingcalendarview.tsx","./src/features/operations/deliveryview.tsx","./src/features/operations/diningview.tsx","./src/features/operations/kdsview.tsx","./src/features/operations/operationsmodule.tsx","./src/features/operations/posview.tsx","./src/features/operations/purchasingview.tsx","./src/features/operations/recipesview.tsx","./src/features/operations/workordersview.tsx","./src/features/sales/salesmodule.tsx","./src/features/settings/settingsmodule.tsx","./src/features/tasks/tasksmodule.tsx","./src/mock/mockapi.ts","./src/mock/mockdata.ts","./src/mock/store.ts","./src/shared/components/badge.tsx","./src/shared/components/button.tsx","./src/shared/components/drawer.tsx","./src/shared/components/emptystate.tsx","./src/shared/components/input.tsx","./src/shared/components/modal.tsx","./src/shared/components/statcard.tsx","./src/shared/utils/formatters.ts"],"version":"6.0.3"}

7
tsconfig.json Normal file
View file

@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

23
tsconfig.node.json Normal file
View file

@ -0,0 +1,23 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "es2023",
"lib": ["ES2023"],
"types": ["node"],
"skipLibCheck": true,
/* Bundler mode */
"module": "nodenext",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true
},
"include": ["vite.config.ts"]
}

19
vite.config.ts Normal file
View file

@ -0,0 +1,19 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import path from 'path'
import { fileURLToPath } from 'url'
const currentDir = path.dirname(fileURLToPath(import.meta.url))
// https://vite.dev/config/
export default defineConfig({
plugins: [react()],
resolve: {
alias: {
'@': path.resolve(currentDir, './src'),
},
},
build: {
chunkSizeWarningLimit: 1200,
},
})