feat: initial commit of Krona Universal Business OS interactive prototype
24
.gitignore
vendored
Normal 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
|
|
@ -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
|
|
@ -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
|
|
@ -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
35
package.json
Normal 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
|
|
@ -0,0 +1,6 @@
|
||||||
|
export default {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
}
|
||||||
BIN
public/branding/krona-logo.png
Normal file
|
After Width: | Height: | Size: 2 MiB |
BIN
public/branding/krona-symbol.png
Normal file
|
After Width: | Height: | Size: 1.4 MiB |
1
public/favicon.svg
Normal file
|
After Width: | Height: | Size: 9.3 KiB |
24
public/icons.svg
Normal 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
|
|
@ -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;
|
||||||
133
src/app/layouts/AppShell.tsx
Normal 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
|
|
@ -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
|
|
@ -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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
202
src/app/shell/CommandPalette.tsx
Normal 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>Навигация: ↑ ↓ | Выбор: Enter | Закрыть: Esc</span>
|
||||||
|
<span className="font-medium text-krona-gold">Крона AGI Universal OS</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
108
src/app/shell/NotificationsDrawer.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
220
src/app/shell/QuickActionsModal.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
411
src/app/shell/RightCommandPanel.tsx
Normal 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
|
|
@ -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
|
|
@ -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
|
After Width: | Height: | Size: 13 KiB |
1
src/assets/react.svg
Normal 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
|
After Width: | Height: | Size: 8.5 KiB |
53
src/core/capabilities/types.ts
Normal 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' },
|
||||||
|
];
|
||||||
237
src/core/modules/registry.ts
Normal 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
|
|
@ -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[];
|
||||||
|
}
|
||||||
103
src/core/permissions/types.ts
Normal 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',
|
||||||
|
},
|
||||||
|
};
|
||||||
635
src/core/profiles/profiles.ts
Normal 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'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
31
src/core/profiles/types.ts
Normal 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[];
|
||||||
|
};
|
||||||
|
}
|
||||||
182
src/features/ai/AiCenterModule.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
152
src/features/analytics/AnalyticsModule.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
64
src/features/apps/AppsCatalogModule.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
109
src/features/automation/AutomationModule.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
357
src/features/cms/CmsModule.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
159
src/features/communications/CommunicationsModule.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
668
src/features/crm/CrmModule.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
728
src/features/dashboard/DynamicDashboard.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
79
src/features/employees/EmployeesModule.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
205
src/features/finance/FinanceModule.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
133
src/features/helpdesk/HelpDeskModule.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
169
src/features/inventory/InventoryModule.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
91
src/features/marketing/MarketingModule.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
123
src/features/operations/BaysView.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
127
src/features/operations/BookingCalendarView.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
205
src/features/operations/DeliveryView.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
205
src/features/operations/DiningView.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
200
src/features/operations/KdsView.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
186
src/features/operations/OperationsModule.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
433
src/features/operations/PosView.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
360
src/features/operations/PurchasingView.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
131
src/features/operations/RecipesView.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
181
src/features/operations/WorkOrdersView.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
104
src/features/sales/SalesModule.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
157
src/features/settings/SettingsModule.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
351
src/features/tasks/TasksModule.tsx
Normal 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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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;
|
||||||
|
},
|
||||||
|
}));
|
||||||
38
src/shared/components/Badge.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
53
src/shared/components/Button.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
78
src/shared/components/Drawer.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
34
src/shared/components/EmptyState.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
50
src/shared/components/Input.tsx
Normal 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';
|
||||||
85
src/shared/components/Modal.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
81
src/shared/components/StatCard.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
15
src/shared/utils/formatters.ts
Normal 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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -0,0 +1,7 @@
|
||||||
|
{
|
||||||
|
"files": [],
|
||||||
|
"references": [
|
||||||
|
{ "path": "./tsconfig.app.json" },
|
||||||
|
{ "path": "./tsconfig.node.json" }
|
||||||
|
]
|
||||||
|
}
|
||||||
23
tsconfig.node.json
Normal 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
|
|
@ -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,
|
||||||
|
},
|
||||||
|
})
|
||||||