Compare commits

...

16 Commits

Author SHA1 Message Date
Artem Kashaev dcd8bd30f6 Merge branch 'frontend' into dev
Test / test (push) Successful in 15s Details
2025-12-01 15:54:38 +05:00
Artem Kashaev bcb56ad7dd fix: remove frontend branch from push triggers in build workflow 2025-12-01 15:53:46 +05:00
Artem Kashaev c427c8390d fix: update API_URL to use production endpoint
Build and deploy / build (push) Successful in 37s Details
Build and deploy / deploy (push) Successful in 24s Details
2025-12-01 15:44:48 +05:00
Artem Kashaev ffb4b1b2fe feat: implement API client, query client, storage, token parsing, and utility functions
Build and deploy / build (push) Successful in 45s Details
Build and deploy / deploy (push) Successful in 19s Details
2025-12-01 15:27:19 +05:00
Artem Kashaev 16746e075c fix: correct import path for createQueryClient in AppProvider
Build and deploy / build (push) Failing after 24s Details
Build and deploy / deploy (push) Has been skipped Details
2025-12-01 15:23:14 +05:00
Artem Kashaev c89d9c8a7d fix: enable CI environment variable for frontend build and update build script
Build and deploy / build (push) Failing after 25s Details
Build and deploy / deploy (push) Has been skipped Details
2025-12-01 15:14:32 +05:00
Artem Kashaev 916882f205 fix: ensure CI environment variable is set correctly during frontend build
Build and deploy / build (push) Failing after 28s Details
Build and deploy / deploy (push) Has been skipped Details
2025-12-01 15:08:57 +05:00
Artem Kashaev 5ce5556232 fix: update Node.js version in CI workflow from 22 to 24 and add peer dependencies in package-lock.json
Build and deploy / build (push) Failing after 33s Details
Build and deploy / deploy (push) Has been skipped Details
2025-12-01 15:01:57 +05:00
Artem Kashaev 971e8a63bc feat: add compiler options and path mappings to TypeScript configuration
Build and deploy / build (push) Failing after 26s Details
Build and deploy / deploy (push) Has been skipped Details
2025-12-01 14:56:27 +05:00
Artem Kashaev 765721b582 fix: update Node.js version in CI workflow from 24 to 22
Build and deploy / build (push) Failing after 26s Details
Build and deploy / deploy (push) Has been skipped Details
2025-12-01 14:36:15 +05:00
Artem Kashaev 16479ba85b feat: enhance CI/CD workflow for frontend build and deployment
Build and deploy / build (push) Failing after 28s Details
Build and deploy / deploy (push) Has been skipped Details
Test / test (push) Successful in 15s Details
2025-12-01 14:32:27 +05:00
Artem Kashaev 9a2a2f6adc feat: add static file serving and frontend asset handling to FastAPI application 2025-12-01 14:25:11 +05:00
Artem Kashaev ecb6daad1b feat: enhance forms with improved select components and data handling for contacts and deals
Test / test (push) Successful in 17s Details
2025-12-01 14:16:24 +05:00
Artem Kashaev 8718df9686 feat: add deals, tasks, and organizations pages with CRUD functionality
- Implemented DealsPage with deal creation, updating, and filtering features.
- Added OrganizationsPage to manage and switch between organizations.
- Created TasksPage for task management, including task creation and filtering.
- Updated router to include new pages for navigation.
2025-12-01 13:46:56 +05:00
Artem Kashaev 4fe3d0480e fix: update CORS settings to allow all origins temporarily 2025-12-01 12:55:51 +05:00
Artem Kashaev ede064cc11 feat: add initial implementation of Kitchen CRM with authentication and dashboard features
- Create global styles and theme management
- Implement app shell layout with sidebar navigation
- Add authentication layout and pages for login and registration
- Develop dashboard page with placeholder content
- Introduce routing guards for guest-only and authenticated routes
- Set up Zustand for state management of authentication and theme
- Create API types and structures for CRM entities
- Configure Vite with PWA support and Tailwind CSS
2025-12-01 12:29:02 +05:00
103 changed files with 22199 additions and 4 deletions

View File

@ -13,9 +13,39 @@ jobs:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node.js 24 via nvm
run: |
curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.1/install.sh | bash
export NVM_DIR="$HOME/.nvm"
[ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh"
nvm install 24
nvm use 24
node -v
npm -v
echo "PATH=$PATH" >> $GITHUB_ENV
- name: Login to registry
run: echo "${{ secrets.TOKEN }}" | docker login ${{ secrets.GIT_HOST }} -u ${{ secrets.USERNAME }} --password-stdin
- name: Build frontend bundle
working-directory: frontend
env:
CI: "true"
run: |
npm ci
npm run build
- name: Archive frontend dist
run: |
tar -czf frontend-dist.tar.gz -C frontend/dist .
- name: Upload frontend artifact
uses: actions/upload-artifact@v3
with:
name: frontend-dist
path: frontend-dist.tar.gz
retention-days: 7
- name: Build and push app
run: |
docker build -t ${{ secrets.GIT_HOST }}/${{ gitea.repository }}:app -f app/Dockerfile .
@ -44,6 +74,19 @@ jobs:
- name: Create remote deployment directory
run: ssh ${{ secrets.LXC_USER }}@${{ secrets.LXC_HOST }} "mkdir -p /srv/app"
- name: Download frontend artifact
uses: actions/download-artifact@v3
with:
name: frontend-dist
path: artifacts
- name: Upload frontend dist to server
run: |
mkdir -p artifacts/extracted
tar -xzf artifacts/frontend-dist.tar.gz -C artifacts/extracted
ssh ${{ secrets.LXC_USER }}@${{ secrets.LXC_HOST }} "mkdir -p /srv/app/frontend/dist && rm -rf /srv/app/frontend/dist/*"
scp -r artifacts/extracted/* ${{ secrets.LXC_USER }}@${{ secrets.LXC_HOST }}:/srv/app/frontend/dist/
- name: Deploy docker-compose-ci.yml
run: scp docker-compose-ci.yml ${{ secrets.LXC_USER }}@${{ secrets.LXC_HOST }}:/srv/app/docker-compose.yml

29
.gitignore vendored
View File

@ -15,7 +15,7 @@ dist/
downloads/
eggs/
.eggs/
lib/
# lib/
lib64/
parts/
sdist/
@ -161,3 +161,30 @@ cython_debug/
#.idea/
task.instructions.md
frontend.instructions.md
# 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?

View File

@ -1,10 +1,14 @@
"""FastAPI application factory."""
from __future__ import annotations
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
from pathlib import Path
from fastapi import FastAPI
from fastapi import FastAPI, HTTPException
from fastapi.responses import FileResponse
from fastapi.staticfiles import StaticFiles
from app.api.routes import api_router
from app.core.cache import init_cache, shutdown_cache
@ -13,9 +17,13 @@ from app.core.middleware.cache_monitor import CacheAvailabilityMiddleware
from fastapi.middleware.cors import CORSMiddleware
PROJECT_ROOT = Path(__file__).resolve().parent.parent
FRONTEND_DIST = PROJECT_ROOT / "frontend" / "dist"
FRONTEND_INDEX = FRONTEND_DIST / "index.html"
def create_app() -> FastAPI:
"""Build FastAPI application instance."""
@asynccontextmanager
async def lifespan(_: FastAPI) -> AsyncIterator[None]:
await init_cache()
@ -29,11 +37,37 @@ def create_app() -> FastAPI:
application.add_middleware(CacheAvailabilityMiddleware)
application.add_middleware(
CORSMiddleware,
allow_origins=["https://kitchen-crm.k1nq.tech", "http://192.168.31.51"],
allow_origins=[
# "https://kitchen-crm.k1nq.tech",
# "http://192.168.31.51",
# "http://localhost:8000",
# "http://0.0.0.0:8000",
# "http://127.0.0.1:8000",
"*" # ! TODO: Убрать
],
allow_credentials=True,
allow_methods=["*"], # Разрешить все HTTP-методы
allow_headers=["*"], # Разрешить все заголовки
)
if FRONTEND_DIST.exists() and FRONTEND_INDEX.exists():
assets_dir = FRONTEND_DIST / "assets"
if assets_dir.exists():
application.mount("/assets", StaticFiles(directory=assets_dir), name="frontend-assets")
@application.get("/", include_in_schema=False)
async def serve_frontend_root() -> FileResponse: # pragma: no cover - simple file response
return FileResponse(FRONTEND_INDEX)
@application.get("/{path:path}", include_in_schema=False)
async def serve_frontend_path(path: str) -> FileResponse: # pragma: no cover - simple file response
if path == "" or path.startswith("api"):
raise HTTPException(status_code=404)
candidate = FRONTEND_DIST / path
if candidate.is_file():
return FileResponse(candidate)
return FileResponse(FRONTEND_INDEX)
return application

View File

@ -25,6 +25,8 @@ services:
ANALYTICS_CACHE_BACKOFF_MS: ${ANALYTICS_CACHE_BACKOFF_MS}
ports:
- "80:8000"
volumes:
- ./frontend/dist:/opt/app/frontend/dist:ro
healthcheck:
test: ["CMD", "wget", "-qO-", "http://localhost:8000/health"]
interval: 30s

View File

@ -0,0 +1 @@
npx lint-staged

73
frontend/README.md Normal file
View File

@ -0,0 +1,73 @@
# React + TypeScript + Vite
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
Currently, two official plugins are available:
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) (or [oxc](https://oxc.rs) when used in [rolldown-vite](https://vite.dev/guide/rolldown)) for Fast Refresh
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
## 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 ESLint configuration
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
```js
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Remove tseslint.configs.recommended and replace with this
tseslint.configs.recommendedTypeChecked,
// Alternatively, use this for stricter rules
tseslint.configs.strictTypeChecked,
// Optionally, add this for stylistic rules
tseslint.configs.stylisticTypeChecked,
// Other configs...
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
```js
// eslint.config.js
import reactX from 'eslint-plugin-react-x'
import reactDom from 'eslint-plugin-react-dom'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Enable lint rules for React
reactX.configs['recommended-typescript'],
// Enable lint rules for React DOM
reactDom.configs.recommended,
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```

View File

@ -0,0 +1 @@
if('serviceWorker' in navigator) navigator.serviceWorker.register('/dev-sw.js?dev-sw', { scope: '/', type: 'classic' })

View File

105
frontend/dev-dist/sw.js Normal file
View File

@ -0,0 +1,105 @@
/**
* Copyright 2018 Google Inc. All Rights Reserved.
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* http://www.apache.org/licenses/LICENSE-2.0
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
// If the loader is already loaded, just stop.
if (!self.define) {
let registry = {};
// Used for `eval` and `importScripts` where we can't get script URL by other means.
// In both cases, it's safe to use a global var because those functions are synchronous.
let nextDefineUri;
const singleRequire = (uri, parentUri) => {
uri = new URL(uri + ".js", parentUri).href;
return registry[uri] || (
new Promise(resolve => {
if ("document" in self) {
const script = document.createElement("script");
script.src = uri;
script.onload = resolve;
document.head.appendChild(script);
} else {
nextDefineUri = uri;
importScripts(uri);
resolve();
}
})
.then(() => {
let promise = registry[uri];
if (!promise) {
throw new Error(`Module ${uri} didnt register its module`);
}
return promise;
})
);
};
self.define = (depsNames, factory) => {
const uri = nextDefineUri || ("document" in self ? document.currentScript.src : "") || location.href;
if (registry[uri]) {
// Module is already loading or loaded.
return;
}
let exports = {};
const require = depUri => singleRequire(depUri, uri);
const specialDeps = {
module: { uri },
exports,
require
};
registry[uri] = Promise.all(depsNames.map(
depName => specialDeps[depName] || require(depName)
)).then(deps => {
factory(...deps);
return exports;
});
};
}
define(['./workbox-c5fd805d'], (function (workbox) { 'use strict';
self.skipWaiting();
workbox.clientsClaim();
/**
* The precacheAndRoute() method efficiently caches and responds to
* requests for URLs in the manifest.
* See https://goo.gl/S9QRab
*/
workbox.precacheAndRoute([{
"url": "suppress-warnings.js",
"revision": "d41d8cd98f00b204e9800998ecf8427e"
}, {
"url": "index.html",
"revision": "0.b5rg9utgn3"
}], {});
workbox.cleanupOutdatedCaches();
workbox.registerRoute(new workbox.NavigationRoute(workbox.createHandlerBoundToURL("index.html"), {
allowlist: [/^\/$/]
}));
workbox.registerRoute(({
url
}) => url.pathname.startsWith("/api"), new workbox.NetworkFirst({
"cacheName": "api-cache",
"networkTimeoutSeconds": 5,
plugins: [new workbox.ExpirationPlugin({
maxEntries: 50,
maxAgeSeconds: 900
})]
}), 'GET');
workbox.registerRoute(({
request
}) => request.destination === "document", new workbox.NetworkFirst(), 'GET');
}));

File diff suppressed because it is too large Load Diff

58
frontend/eslint.config.js Normal file
View File

@ -0,0 +1,58 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import simpleImportSort from 'eslint-plugin-simple-import-sort'
import tailwindcss from 'eslint-plugin-tailwindcss'
import tseslint from 'typescript-eslint'
import { defineConfig } from 'eslint/config'
const tsconfigRootDir = new URL('.', import.meta.url).pathname
export default defineConfig([
{
ignores: ['dist', 'coverage', 'node_modules', 'build', 'public/**/*.ts'],
},
{
files: ['**/*.{ts,tsx,js,jsx}'],
extends: [
js.configs.recommended,
...tseslint.configs.strictTypeChecked,
...tseslint.configs.stylisticTypeChecked,
],
languageOptions: {
ecmaVersion: 2022,
globals: {
...globals.browser,
},
parserOptions: {
project: './tsconfig.app.json',
tsconfigRootDir,
},
},
plugins: {
'react-hooks': reactHooks,
'react-refresh': reactRefresh,
'simple-import-sort': simpleImportSort,
tailwindcss,
},
settings: {
tailwindcss: {
callees: ['cn'],
config: 'tailwind.config.ts',
},
},
rules: {
...reactHooks.configs.recommended.rules,
'react-refresh/only-export-components': 'off',
'simple-import-sort/imports': 'error',
'simple-import-sort/exports': 'error',
'@typescript-eslint/consistent-type-imports': ['error', { prefer: 'type-imports' }],
'@typescript-eslint/no-unused-vars': ['warn', { argsIgnorePattern: '^_', ignoreRestSiblings: true }],
'@typescript-eslint/no-misused-promises': ['error', { checksVoidReturn: { attributes: false } }],
'tailwindcss/classnames-order': 'warn',
'tailwindcss/no-arbitrary-value': 'off',
'tailwindcss/no-custom-classname': 'off',
},
},
])

33
frontend/index.html Normal file
View File

@ -0,0 +1,33 @@
<!doctype html>
<html lang="en" class="light" data-theme="system">
<head>
<meta charset="UTF-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="description" content="Modern multi-tenant mini CRM dashboard" />
<meta name="application-name" content="Kitchen CRM" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<meta name="theme-color" content="#0f172a" media="(prefers-color-scheme: light)" />
<meta name="theme-color" content="#020617" media="(prefers-color-scheme: dark)" />
<meta name="color-scheme" content="dark light" />
<meta property="og:title" content="Kitchen CRM" />
<meta property="og:description" content="Unified sales, contacts, tasks, and analytics workspace" />
<meta property="og:type" content="website" />
<meta property="og:url" content="https://kitchen-crm.k1nq.tech" />
<link rel="icon" type="image/svg+xml" href="/pwa-icon.svg" />
<link rel="apple-touch-icon" sizes="180x180" href="/pwa-192x192.png" />
<link rel="manifest" href="/manifest.webmanifest" />
<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@400;500;600;700&display=swap"
rel="stylesheet"
/>
<title>Kitchen CRM</title>
</head>
<body class="antialiased bg-background text-foreground">
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

12280
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

85
frontend/package.json Normal file
View File

@ -0,0 +1,85 @@
{
"name": "frontend",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"build:ci": "tsc --noEmit && vite build",
"preview": "vite preview",
"lint": "eslint . --max-warnings=0",
"lint:fix": "eslint . --fix",
"typecheck": "tsc -b",
"format": "prettier --write \"./**/*.{ts,tsx,js,jsx,json,md,css,scss}\"",
"check": "npm run lint && npm run typecheck",
"prepare": "husky"
},
"dependencies": {
"@hookform/resolvers": "^5.2.2",
"@radix-ui/react-avatar": "^1.1.11",
"@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-label": "^2.1.8",
"@radix-ui/react-popover": "^1.1.15",
"@radix-ui/react-scroll-area": "^1.2.10",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-separator": "^1.1.8",
"@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-toast": "^1.2.15",
"@radix-ui/react-tooltip": "^1.2.8",
"@tanstack/react-query": "^5.90.11",
"@tanstack/react-query-devtools": "^5.91.1",
"@tanstack/react-table": "^8.21.3",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"date-fns": "^4.1.0",
"jwt-decode": "^4.0.0",
"lucide-react": "^0.555.0",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"react-hook-form": "^7.67.0",
"react-router-dom": "^7.9.6",
"recharts": "^3.5.1",
"tailwind-merge": "^3.4.0",
"zod": "^4.1.13",
"zustand": "^5.0.9"
},
"devDependencies": {
"@eslint/js": "^9.39.1",
"@tailwindcss/postcss": "^4.1.17",
"@types/node": "^24.10.1",
"@types/react": "^19.2.7",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react-swc": "^4.2.2",
"autoprefixer": "^10.4.22",
"eslint": "^9.39.1",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.4.24",
"eslint-plugin-simple-import-sort": "^12.1.1",
"eslint-plugin-tailwindcss": "^4.0.0-beta.0",
"globals": "^16.5.0",
"husky": "^9.1.7",
"lint-staged": "^16.2.7",
"postcss": "^8.5.6",
"prettier": "^3.7.3",
"prettier-plugin-tailwindcss": "^0.7.1",
"tailwindcss": "^4.0.0",
"tailwindcss-animate": "^1.0.7",
"typescript": "^5.9.3",
"typescript-eslint": "^8.48.0",
"vite": "^7.2.4",
"vite-plugin-pwa": "^1.2.0"
},
"lint-staged": {
"*.{ts,tsx,js,jsx,mjs,cjs}": [
"eslint --fix"
],
"*.{ts,tsx,js,jsx,mjs,cjs,json,md,css,scss}": [
"prettier --write"
]
}
}

View File

@ -0,0 +1,6 @@
import tailwindcss from '@tailwindcss/postcss'
import autoprefixer from 'autoprefixer'
export default {
plugins: [tailwindcss(), autoprefixer()],
}

View File

@ -0,0 +1,10 @@
/** @type {import('prettier').Config} */
const config = {
semi: false,
singleQuote: true,
trailingComma: 'all',
printWidth: 100,
plugins: ['prettier-plugin-tailwindcss'],
}
export default config

View File

@ -0,0 +1,28 @@
{
"name": "Kitchen CRM",
"short_name": "KitchenCRM",
"start_url": "/",
"display": "standalone",
"background_color": "#020617",
"theme_color": "#0f172a",
"description": "Multi-tenant mini CRM dashboard for organizations, deals, contacts, and analytics.",
"icons": [
{
"src": "/pwa-icon.svg",
"sizes": "any",
"type": "image/svg+xml",
"purpose": "any"
},
{
"src": "/pwa-192x192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "/pwa-512x512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any maskable"
}
]
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 329 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 355 B

View File

@ -0,0 +1,15 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" fill="none">
<defs>
<linearGradient id="grad" x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stop-color="#38bdf8" />
<stop offset="100%" stop-color="#6366f1" />
</linearGradient>
</defs>
<rect width="64" height="64" rx="14" fill="#020617" />
<path
d="M16 40c0-8 6.5-14.5 14.5-14.5H36v-1.5c0-3.6-2.9-6.5-6.5-6.5S23 20.4 23 24h-7c0-7.5 6.1-13.5 13.5-13.5S43 16.5 43 24v6h2.5C50.4 30 55 34.6 55 40s-4.6 10-10.5 10H22.5C16.5 50 12 45.4 12 40Z"
fill="url(#grad)"
/>
<circle cx="25" cy="39" r="4" fill="#f8fafc" />
<circle cx="41" cy="39" r="4" fill="#f8fafc" />
</svg>

After

Width:  |  Height:  |  Size: 666 B

1
frontend/public/vite.svg Normal file
View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

42
frontend/src/App.css Normal file
View File

@ -0,0 +1,42 @@
#root {
max-width: 1280px;
margin: 0 auto;
padding: 2rem;
text-align: center;
}
.logo {
height: 6em;
padding: 1.5em;
will-change: filter;
transition: filter 300ms;
}
.logo:hover {
filter: drop-shadow(0 0 2em #646cffaa);
}
.logo.react:hover {
filter: drop-shadow(0 0 2em #61dafbaa);
}
@keyframes logo-spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
@media (prefers-reduced-motion: no-preference) {
a:nth-of-type(2) .logo {
animation: logo-spin infinite 20s linear;
}
}
.card {
padding: 2em;
}
.read-the-docs {
color: #888;
}

16
frontend/src/App.tsx Normal file
View File

@ -0,0 +1,16 @@
import { Suspense } from 'react'
import { RouterProvider } from 'react-router-dom'
import { AppProvider } from '@/app/providers/app-provider'
import { AppLoading } from '@/components/system/app-loading'
import { router } from '@/routes/router'
const App = () => (
<AppProvider>
<Suspense fallback={<AppLoading />}>
<RouterProvider router={router} />
</Suspense>
</AppProvider>
)
export default App

View File

@ -0,0 +1,22 @@
import { QueryClientProvider } from '@tanstack/react-query'
import { ReactQueryDevtools } from '@tanstack/react-query-devtools'
import { useState } from 'react'
import { type PropsWithChildren } from 'react'
import { ThemeProvider } from '@/components/theme/theme-provider'
import { Toaster } from '@/components/ui/toaster'
import { createQueryClient } from '@/lib/query-client.ts'
export const AppProvider = ({ children }: PropsWithChildren) => {
const [queryClient] = useState(() => createQueryClient())
return (
<QueryClientProvider client={queryClient}>
<ThemeProvider>
{children}
<Toaster />
</ThemeProvider>
{import.meta.env.DEV ? <ReactQueryDevtools initialIsOpen={false} buttonPosition="bottom-left" /> : null}
</QueryClientProvider>
)
}

View File

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

After

Width:  |  Height:  |  Size: 4.0 KiB

View File

@ -0,0 +1,19 @@
import { Badge } from '@/components/ui/badge'
import type { DealStage } from '@/types/crm'
export const dealStageLabels: Record<DealStage, string> = {
qualification: 'Квалификация',
proposal: 'Предложение',
negotiation: 'Переговоры',
closed: 'Закрыта',
}
interface DealStageBadgeProps {
stage: DealStage
}
export const DealStageBadge = ({ stage }: DealStageBadgeProps) => (
<Badge variant="outline" className="bg-muted/40">
{dealStageLabels[stage]}
</Badge>
)

View File

@ -0,0 +1,22 @@
import { Badge, type BadgeProps } from '@/components/ui/badge'
import type { DealStatus } from '@/types/crm'
export const dealStatusLabels: Record<DealStatus, string> = {
new: 'Новая',
in_progress: 'В работе',
won: 'Успех',
lost: 'Закрыта',
}
const statusVariant: Record<DealStatus, BadgeProps['variant']> = {
new: 'warning',
in_progress: 'secondary',
won: 'success',
lost: 'destructive',
}
interface DealStatusBadgeProps {
status: DealStatus
}
export const DealStatusBadge = ({ status }: DealStatusBadgeProps) => <Badge variant={statusVariant[status]}>{dealStatusLabels[status]}</Badge>

View File

@ -0,0 +1,12 @@
import { CheckCircle2, CircleDashed } from 'lucide-react'
interface TaskStatusPillProps {
done: boolean
}
export const TaskStatusPill = ({ done }: TaskStatusPillProps) => (
<span className="inline-flex items-center gap-1 rounded-full border px-2.5 py-0.5 text-xs font-semibold">
{done ? <CheckCircle2 className="h-4 w-4 text-emerald-500" /> : <CircleDashed className="h-4 w-4 text-amber-500" />}
{done ? 'Выполнена' : 'Открыта'}
</span>
)

View File

@ -0,0 +1,39 @@
import { Search } from 'lucide-react'
import { Input } from '@/components/ui/input'
interface DataTableToolbarProps {
searchPlaceholder?: string
searchValue?: string
onSearchChange?: (value: string) => void
children?: React.ReactNode
actions?: React.ReactNode
}
export const DataTableToolbar = ({
searchPlaceholder = 'Поиск…',
searchValue = '',
onSearchChange,
children,
actions,
}: DataTableToolbarProps) => (
<div className="flex flex-col gap-3 border-b bg-muted/20 p-4">
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<div className="flex flex-1 flex-wrap items-center gap-3">
{onSearchChange ? (
<div className="relative w-full min-w-[220px] max-w-sm">
<Search className="pointer-events-none absolute left-2.5 top-3.5 h-4 w-4 text-muted-foreground" />
<Input
value={searchValue}
onChange={(event) => onSearchChange(event.target.value)}
placeholder={searchPlaceholder}
className="pl-8"
/>
</div>
) : null}
{children}
</div>
{actions ? <div className="flex flex-wrap items-center gap-2">{actions}</div> : null}
</div>
</div>
)

View File

@ -0,0 +1,84 @@
import { type ColumnDef, type SortingState, flexRender, getCoreRowModel, getSortedRowModel, useReactTable } from '@tanstack/react-table'
import { Loader2 } from 'lucide-react'
import { useMemo, useState } from 'react'
import { Card } from '@/components/ui/card'
import { Skeleton } from '@/components/ui/skeleton'
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
interface DataTableProps<TData> {
columns: ColumnDef<TData, any>[]
data: TData[]
isLoading?: boolean
renderToolbar?: React.ReactNode
emptyState?: React.ReactNode
skeletonRows?: number
}
export const DataTable = <TData,>({ columns, data, isLoading = false, renderToolbar, emptyState, skeletonRows = 5 }: DataTableProps<TData>) => {
const [sorting, setSorting] = useState<SortingState>([])
const memoizedData = useMemo(() => data, [data])
const table = useReactTable({
data: memoizedData,
columns,
state: { sorting },
onSortingChange: setSorting,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
})
const leafColumns = table.getAllLeafColumns()
return (
<Card className="space-y-4 border bg-card">
{renderToolbar}
<div className="overflow-hidden rounded-xl border bg-background">
<Table>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id} className="bg-muted/50">
{headerGroup.headers.map((header) => (
<TableHead key={header.id} className="whitespace-nowrap">
{header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())}
</TableHead>
))}
</TableRow>
))}
</TableHeader>
<TableBody>
{isLoading ? (
[...Array(skeletonRows)].map((_, index) => (
<TableRow key={`skeleton-${index}`}>
{leafColumns.map((column) => (
<TableCell key={`${column.id}-${index}`}>
<Skeleton className="h-4 w-full" />
</TableCell>
))}
</TableRow>
))
) : table.getRowModel().rows.length ? (
table.getRowModel().rows.map((row) => (
<TableRow key={row.id} data-state={row.getIsSelected() && 'selected'}>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id}>{flexRender(cell.column.columnDef.cell, cell.getContext())}</TableCell>
))}
</TableRow>
))
) : (
<TableRow>
<TableCell colSpan={columns.length} className="h-32 text-center text-sm text-muted-foreground">
{emptyState ?? (
<div className="flex flex-col items-center gap-2">
<Loader2 className="h-5 w-5" />
<p>Нет данных для отображения</p>
</div>
)}
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
</Card>
)
}

View File

@ -0,0 +1,18 @@
import { Link } from 'react-router-dom'
import { Sparkles } from 'lucide-react'
import { env } from '@/config/env'
import { cn } from '@/lib/utils'
interface AppLogoProps {
className?: string
}
export const AppLogo = ({ className }: AppLogoProps) => (
<Link to="/dashboard" className={cn('flex items-center gap-2 text-lg font-semibold text-foreground', className)}>
<span className="flex h-9 w-9 items-center justify-center rounded-lg bg-primary/10 text-primary">
<Sparkles className="h-5 w-5" />
</span>
{env.APP_NAME}
</Link>
)

View File

@ -0,0 +1,26 @@
import { NavLink } from 'react-router-dom'
import { appNavItems } from '@/config/navigation'
import { cn } from '@/lib/utils'
export const SidebarNav = () => {
return (
<nav className="flex flex-col gap-1">
{appNavItems.map(({ path, label, icon: Icon }) => (
<NavLink
key={path}
to={path}
className={({ isActive }) =>
cn(
'flex items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium text-muted-foreground transition-colors hover:bg-muted hover:text-foreground',
isActive && 'bg-primary/10 text-primary',
)
}
>
<Icon className="h-4 w-4" />
{label}
</NavLink>
))}
</nav>
)
}

View File

@ -0,0 +1,56 @@
import { useNavigate } from 'react-router-dom'
import { LogOut } from 'lucide-react'
import { Avatar, AvatarFallback } from '@/components/ui/avatar'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import { useToast } from '@/components/ui/use-toast'
import { useAuthStore } from '@/stores/auth-store'
export const UserMenu = () => {
const navigate = useNavigate()
const logout = useAuthStore((state) => state.logout)
const user = useAuthStore((state) => state.user)
const { toast } = useToast()
const initials = user?.name
?.split(' ')
.map((part) => part[0])
.join('')
.slice(0, 2)
.toUpperCase()
const handleLogout = () => {
logout()
toast({ title: 'Вы вышли из системы' })
navigate('/login', { replace: true })
}
return (
<DropdownMenu>
<DropdownMenuTrigger className="rounded-full focus:outline-none focus:ring-2 focus:ring-ring">
<Avatar className="h-9 w-9">
<AvatarFallback>{initials || (user?.email ?? '?')[0]?.toUpperCase() || 'U'}</AvatarFallback>
</Avatar>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-56">
<DropdownMenuLabel>
<div className="flex flex-col">
<span className="text-sm font-semibold">{user?.name ?? 'Сотрудник'}</span>
<span className="text-xs text-muted-foreground">{user?.email}</span>
</div>
</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem className="gap-2 text-destructive" onSelect={handleLogout}>
<LogOut className="h-4 w-4" /> Выйти
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)
}

View File

@ -0,0 +1,75 @@
import { useMemo, useTransition } from 'react'
import { Building2, Loader2 } from 'lucide-react'
import { useQueryClient } from '@tanstack/react-query'
import { Button } from '@/components/ui/button'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { useToast } from '@/components/ui/use-toast'
import { useOrganizationsQuery } from '@/features/organizations/hooks'
import { useAuthStore } from '@/stores/auth-store'
export const OrganizationSwitcher = () => {
const { data, isLoading, isFetching } = useOrganizationsQuery()
const activeOrganizationId = useAuthStore((state) => state.activeOrganizationId)
const setActiveOrganization = useAuthStore((state) => state.setActiveOrganization)
const organizations = data ?? []
const { toast } = useToast()
const queryClient = useQueryClient()
const [isPending, startTransition] = useTransition()
const activeOrganization = useMemo(
() => organizations.find((org) => org.id === activeOrganizationId) ?? organizations[0],
[activeOrganizationId, organizations],
)
const handleChange = (value: string) => {
const nextId = Number(value)
if (!Number.isFinite(nextId)) return
startTransition(() => {
setActiveOrganization(nextId)
queryClient.invalidateQueries({ predicate: () => true })
toast({
title: 'Организация переключена',
description: activeOrganization?.id === nextId ? 'Контекст обновлён' : 'Интерфейс обновится под выбранную организацию',
})
})
}
if (isLoading && !data) {
return (
<Button variant="ghost" size="sm" disabled className="gap-2">
<Loader2 className="h-4 w-4 animate-spin" />
Загружаем организации
</Button>
)
}
if (!organizations.length) {
return (
<Button variant="ghost" size="sm" className="gap-2" disabled>
<Building2 className="h-4 w-4" />
Нет организаций
</Button>
)
}
return (
<Select value={String(activeOrganization?.id ?? '')} onValueChange={handleChange} disabled={isPending || isFetching}>
<SelectTrigger className="w-[220px] bg-muted/40">
<SelectValue>
<span className="flex items-center gap-2 text-sm font-medium">
{isPending || isFetching ? <Loader2 className="h-4 w-4 animate-spin" /> : <Building2 className="h-4 w-4" />}
{activeOrganization?.name ?? 'Выбрать организацию'}
</span>
</SelectValue>
</SelectTrigger>
<SelectContent>
{organizations.map((org) => (
<SelectItem key={org.id} value={String(org.id)}>
{org.name}
</SelectItem>
))}
</SelectContent>
</Select>
)
}

View File

@ -0,0 +1,8 @@
export const AppLoading = () => (
<div className="flex min-h-screen items-center justify-center bg-background text-foreground">
<div className="space-y-3 text-center">
<div className="mx-auto h-10 w-10 animate-spin rounded-full border-2 border-primary border-t-transparent" />
<p className="text-sm text-muted-foreground">Загружаем Kitchen CRM</p>
</div>
</div>
)

View File

@ -0,0 +1,17 @@
import { useEffect } from 'react'
import { useThemeStore } from '@/stores/theme-store'
interface ThemeProviderProps {
children: React.ReactNode
}
export const ThemeProvider = ({ children }: ThemeProviderProps) => {
const hydrate = useThemeStore((state) => state.hydrate)
useEffect(() => {
hydrate()
}, [hydrate])
return <>{children}</>
}

View File

@ -0,0 +1,14 @@
import { Moon, Sun } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { useTheme } from '@/hooks/useTheme'
export const ThemeToggle = () => {
const { resolvedTheme, toggleTheme } = useTheme()
return (
<Button variant="ghost" size="icon" aria-label="Переключить тему" onClick={toggleTheme}>
{resolvedTheme === 'dark' ? <Sun className="h-4 w-4" /> : <Moon className="h-4 w-4" />}
</Button>
)
}

View File

@ -0,0 +1,31 @@
import * as React from 'react'
import * as AvatarPrimitive from '@radix-ui/react-avatar'
import { cn } from '@/lib/utils'
const Avatar = React.forwardRef<React.ElementRef<typeof AvatarPrimitive.Root>, React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Root>>(
({ className, ...props }, ref) => (
<AvatarPrimitive.Root ref={ref} className={cn('relative flex h-9 w-9 shrink-0 overflow-hidden rounded-full', className)} {...props} />
),
)
Avatar.displayName = AvatarPrimitive.Root.displayName
const AvatarImage = React.forwardRef<React.ElementRef<typeof AvatarPrimitive.Image>, React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Image>>(
({ className, ...props }, ref) => (
<AvatarPrimitive.Image ref={ref} className={cn('aspect-square h-full w-full', className)} {...props} />
),
)
AvatarImage.displayName = AvatarPrimitive.Image.displayName
const AvatarFallback = React.forwardRef<React.ElementRef<typeof AvatarPrimitive.Fallback>, React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Fallback>>(
({ className, ...props }, ref) => (
<AvatarPrimitive.Fallback
ref={ref}
className={cn('flex h-full w-full items-center justify-center rounded-full bg-muted text-sm font-medium text-muted-foreground', className)}
{...props}
/>
),
)
AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName
export { Avatar, AvatarFallback, AvatarImage }

View File

@ -0,0 +1,32 @@
import { cva, type VariantProps } from 'class-variance-authority'
import * as React from 'react'
import { cn } from '@/lib/utils'
const badgeVariants = cva(
'inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-1 focus:ring-ring',
{
variants: {
variant: {
default: 'border-transparent bg-primary text-primary-foreground shadow',
secondary: 'border-transparent bg-secondary text-secondary-foreground',
outline: 'text-foreground',
success: 'border-transparent bg-emerald-500/15 text-emerald-600 dark:text-emerald-400',
warning: 'border-transparent bg-amber-500/15 text-amber-600 dark:text-amber-300',
destructive: 'border-transparent bg-destructive text-destructive-foreground',
},
},
defaultVariants: {
variant: 'default',
},
},
)
export interface BadgeProps extends React.HTMLAttributes<HTMLDivElement>, VariantProps<typeof badgeVariants> {}
const Badge = React.forwardRef<HTMLDivElement, BadgeProps>(({ className, variant, ...props }, ref) => (
<div ref={ref} className={cn(badgeVariants({ variant }), className)} {...props} />
))
Badge.displayName = 'Badge'
export { Badge, badgeVariants }

View File

@ -0,0 +1,48 @@
import * as React from 'react'
import { Slot } from '@radix-ui/react-slot'
import { cva, type VariantProps } from 'class-variance-authority'
import { cn } from '@/lib/utils'
const buttonVariants = cva(
'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50',
{
variants: {
variant: {
default: 'bg-primary text-primary-foreground shadow hover:bg-primary/90',
secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80',
destructive: 'bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90',
outline:
'border border-input bg-transparent text-foreground shadow-sm hover:bg-background/80 hover:text-foreground',
ghost: 'text-foreground hover:bg-muted hover:text-foreground',
link: 'text-primary underline-offset-4 hover:underline',
},
size: {
default: 'h-10 px-4 py-2',
sm: 'h-9 rounded-md px-3',
lg: 'h-11 rounded-md px-8',
icon: 'h-10 w-10',
},
},
defaultVariants: {
variant: 'default',
size: 'default',
},
},
)
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : 'button'
return <Comp className={cn(buttonVariants({ variant, size, className }))} ref={ref} {...props} />
},
)
Button.displayName = 'Button'
export { Button, buttonVariants }

View File

@ -0,0 +1,47 @@
import * as React from 'react'
import { cn } from '@/lib/utils'
const Card = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn('rounded-xl border bg-card text-card-foreground shadow-sm', className)} {...props} />
),
)
Card.displayName = 'Card'
const CardHeader = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn('flex flex-col space-y-1.5 p-6', className)} {...props} />
),
)
CardHeader.displayName = 'CardHeader'
const CardTitle = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLHeadingElement>>(
({ className, ...props }, ref) => (
<h3 ref={ref} className={cn('text-lg font-semibold leading-none tracking-tight', className)} {...props} />
),
)
CardTitle.displayName = 'CardTitle'
const CardDescription = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLParagraphElement>>(
({ className, ...props }, ref) => (
<p ref={ref} className={cn('text-sm text-muted-foreground', className)} {...props} />
),
)
CardDescription.displayName = 'CardDescription'
const CardContent = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn('p-6 pt-0', className)} {...props} />
),
)
CardContent.displayName = 'CardContent'
const CardFooter = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn('flex items-center p-6 pt-0', className)} {...props} />
),
)
CardFooter.displayName = 'CardFooter'
export { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle }

View File

@ -0,0 +1,178 @@
import * as React from 'react'
import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu'
import { Check, ChevronRight, Circle } from 'lucide-react'
import { cn } from '@/lib/utils'
const DropdownMenu = DropdownMenuPrimitive.Root
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger
const DropdownMenuGroup = DropdownMenuPrimitive.Group
const DropdownMenuPortal = DropdownMenuPrimitive.Portal
const DropdownMenuSub = DropdownMenuPrimitive.Sub
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup
const DropdownMenuSubTrigger = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & { inset?: boolean }
>(({ className, inset, children, ...props }, ref) => (
<DropdownMenuPrimitive.SubTrigger
ref={ref}
className={cn(
'flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm font-medium outline-none focus:bg-muted data-[state=open]:bg-muted',
inset && 'pl-8',
className,
)}
{...props}
>
{children}
<ChevronRight className="ml-auto h-4 w-4" />
</DropdownMenuPrimitive.SubTrigger>
))
DropdownMenuSubTrigger.displayName = DropdownMenuPrimitive.SubTrigger.displayName
const DropdownMenuSubContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.SubContent
ref={ref}
className={cn(
'z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out',
className,
)}
{...props}
/>
))
DropdownMenuSubContent.displayName = DropdownMenuPrimitive.SubContent.displayName
const DropdownMenuContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
>(({ className, align = 'end', sideOffset = 4, ...props }, ref) => (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
ref={ref}
align={align}
sideOffset={sideOffset}
className={cn(
'z-50 min-w-[10rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[side=bottom]:slide-in-from-top-1 data-[side=top]:slide-in-from-bottom-1',
className,
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
))
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName
const DropdownMenuItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & { inset?: boolean }
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Item
ref={ref}
className={cn(
'relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-muted data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
inset && 'pl-8',
className,
)}
{...props}
/>
))
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName
const DropdownMenuCheckboxItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem>
>(({ className, children, checked, ...props }, ref) => (
<DropdownMenuPrimitive.CheckboxItem
ref={ref}
className={cn(
'relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-muted data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
className,
)}
checked={checked}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
))
DropdownMenuCheckboxItem.displayName = DropdownMenuPrimitive.CheckboxItem.displayName
const DropdownMenuRadioItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem>
>(({ className, children, ...props }, ref) => (
<DropdownMenuPrimitive.RadioItem
ref={ref}
className={cn(
'relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-muted data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
className,
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<Circle className="h-2 w-2 fill-current" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
))
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName
const DropdownMenuLabel = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & { inset?: boolean }
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Label
ref={ref}
className={cn('px-2 py-1.5 text-sm font-semibold text-foreground', inset && 'pl-8', className)}
{...props}
/>
))
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName
const DropdownMenuSeparator = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.Separator
ref={ref}
className={cn('-mx-1 my-1 h-px bg-border', className)}
{...props}
/>
))
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName
const DropdownMenuShortcut = ({ className, ...props }: React.HTMLAttributes<HTMLSpanElement>) => {
return <span className={cn('ml-auto text-xs tracking-widest text-muted-foreground', className)} {...props} />
}
DropdownMenuShortcut.displayName = 'DropdownMenuShortcut'
export {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuGroup,
DropdownMenuPortal,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuRadioGroup,
}

View File

@ -0,0 +1,108 @@
import * as LabelPrimitive from '@radix-ui/react-label'
import { Slot } from '@radix-ui/react-slot'
import * as React from 'react'
import { Controller, type ControllerProps, FormProvider, type FieldPath, type FieldValues, useFormContext } from 'react-hook-form'
import { cn } from '@/lib/utils'
const Form = FormProvider
const FormFieldContext = React.createContext<{ name: string }>({ name: '' })
const FormItemContext = React.createContext<{ id: string }>({ id: '' })
const FormField = <TFieldValues extends FieldValues = FieldValues, TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>>(
props: ControllerProps<TFieldValues, TName>,
) => (
<FormFieldContext.Provider value={{ name: props.name }}>
<Controller {...props} />
</FormFieldContext.Provider>
)
const FormItem = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(({ className, ...props }, ref) => {
const id = React.useId()
return (
<FormItemContext.Provider value={{ id }}>
<div ref={ref} className={cn('space-y-2', className)} {...props} />
</FormItemContext.Provider>
)
})
FormItem.displayName = 'FormItem'
const useFormField = () => {
const fieldContext = React.useContext(FormFieldContext)
const itemContext = React.useContext(FormItemContext)
const { getFieldState, formState } = useFormContext()
if (!fieldContext) throw new Error('useFormField should be used within <FormField>')
if (!itemContext) throw new Error('useFormField should be used within <FormItem>')
const fieldState = getFieldState(fieldContext.name, formState)
return {
name: fieldContext.name,
id: itemContext.id,
...fieldState,
}
}
const FormLabel = React.forwardRef<
React.ElementRef<typeof LabelPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> & { optional?: boolean }
>(({ className, optional, ...props }, ref) => {
const { error, id } = useFormField()
return (
<LabelPrimitive.Root
ref={ref}
className={cn('text-sm font-medium text-foreground', error && 'text-destructive', className)}
htmlFor={id}
{...props}
>
{props.children}
{optional ? <span className="pl-1 text-xs font-normal text-muted-foreground">(необязательно)</span> : null}
</LabelPrimitive.Root>
)
})
FormLabel.displayName = 'FormLabel'
const FormControl = React.forwardRef<React.ElementRef<typeof Slot>, React.ComponentPropsWithoutRef<typeof Slot>>(
({ className, ...props }, ref) => {
const { error, id } = useFormField()
return (
<Slot
ref={ref}
id={id}
aria-describedby={error ? `${id}-description ${id}-message` : `${id}-description`}
aria-invalid={!!error}
className={className}
{...props}
/>
)
},
)
FormControl.displayName = 'FormControl'
const FormDescription = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLParagraphElement>>(
({ className, ...props }, ref) => {
const { id } = useFormField()
return <p ref={ref} id={`${id}-description`} className={cn('text-xs text-muted-foreground', className)} {...props} />
},
)
FormDescription.displayName = 'FormDescription'
const FormMessage = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLParagraphElement>>(
({ className, children, ...props }, ref) => {
const { error, id } = useFormField()
const body = error ? String(error.message) : children
if (!body) return null
return (
<p ref={ref} id={`${id}-message`} className={cn('text-xs font-medium text-destructive', className)} {...props}>
{body}
</p>
)
},
)
FormMessage.displayName = 'FormMessage'
export { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage }

View File

@ -0,0 +1,22 @@
import * as React from 'react'
import { cn } from '@/lib/utils'
export interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {}
const Input = React.forwardRef<HTMLInputElement, InputProps>(({ className, type, ...props }, ref) => {
return (
<input
type={type}
className={cn(
'flex h-10 w-full rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50',
className,
)}
ref={ref}
{...props}
/>
)
})
Input.displayName = 'Input'
export { Input }

View File

@ -0,0 +1,18 @@
import * as React from 'react'
import * as LabelPrimitive from '@radix-ui/react-label'
import { cn } from '@/lib/utils'
const Label = React.forwardRef<
React.ElementRef<typeof LabelPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root>
>(({ className, ...props }, ref) => (
<LabelPrimitive.Root
ref={ref}
className={cn('text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70', className)}
{...props}
/>
))
Label.displayName = LabelPrimitive.Root.displayName
export { Label }

View File

@ -0,0 +1,100 @@
import * as React from 'react'
import * as SelectPrimitive from '@radix-ui/react-select'
import { Check, ChevronDown, ChevronUp } from 'lucide-react'
import { cn } from '@/lib/utils'
const Select = SelectPrimitive.Root
const SelectGroup = SelectPrimitive.Group
const SelectValue = SelectPrimitive.Value
const SelectTrigger = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Trigger
ref={ref}
className={cn(
'flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring data-[state=open]:bg-muted disabled:cursor-not-allowed disabled:opacity-50',
className,
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDown className="h-4 w-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
))
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName
const SelectContent = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
>(({ className, children, position = 'popper', ...props }, ref) => (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
ref={ref}
className={cn(
'relative z-50 min-w-[8rem] overflow-hidden rounded-md border border-border bg-background text-foreground shadow-lg animate-in fade-in-80',
position === 'popper' && 'data-[side=bottom]:slide-in-from-top-1 data-[side=top]:slide-in-from-bottom-1',
className,
)}
position={position}
{...props}
>
<SelectPrimitive.ScrollUpButton className="flex cursor-default items-center justify-center py-1">
<ChevronUp className="h-4 w-4" />
</SelectPrimitive.ScrollUpButton>
<SelectPrimitive.Viewport className={cn('p-1', position === 'popper' && 'h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]')}>
{children}
</SelectPrimitive.Viewport>
<SelectPrimitive.ScrollDownButton className="flex cursor-default items-center justify-center py-1">
<ChevronDown className="h-4 w-4" />
</SelectPrimitive.ScrollDownButton>
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
))
SelectContent.displayName = SelectPrimitive.Content.displayName
const SelectLabel = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Label ref={ref} className={cn('px-2 py-1.5 text-sm font-semibold', className)} {...props} />
))
SelectLabel.displayName = SelectPrimitive.Label.displayName
const SelectItem = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Item
ref={ref}
className={cn(
'relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-muted focus:text-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
className,
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
))
SelectItem.displayName = SelectPrimitive.Item.displayName
const SelectSeparator = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Separator ref={ref} className={cn('-mx-1 my-1 h-px bg-muted', className)} {...props} />
))
SelectSeparator.displayName = SelectPrimitive.Separator.displayName
export { Select, SelectContent, SelectGroup, SelectItem, SelectLabel, SelectSeparator, SelectTrigger, SelectValue }

View File

@ -0,0 +1,20 @@
import * as React from 'react'
import * as SeparatorPrimitive from '@radix-ui/react-separator'
import { cn } from '@/lib/utils'
const Separator = React.forwardRef<
React.ElementRef<typeof SeparatorPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof SeparatorPrimitive.Root>
>(({ className, orientation = 'horizontal', decorative = true, ...props }, ref) => (
<SeparatorPrimitive.Root
ref={ref}
decorative={decorative}
orientation={orientation}
className={cn('bg-border', orientation === 'horizontal' ? 'h-px w-full' : 'h-full w-px', className)}
{...props}
/>
))
Separator.displayName = SeparatorPrimitive.Root.displayName
export { Separator }

View File

@ -0,0 +1,78 @@
import * as React from 'react'
import * as SheetPrimitive from '@radix-ui/react-dialog'
import { X } from 'lucide-react'
import { cn } from '@/lib/utils'
const Sheet = SheetPrimitive.Root
const SheetTrigger = SheetPrimitive.Trigger
const SheetClose = SheetPrimitive.Close
const SheetPortal = SheetPrimitive.Portal
const SheetOverlay = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<SheetPrimitive.Overlay
ref={ref}
className={cn('fixed inset-0 z-50 bg-black/40 backdrop-blur-sm data-[state=open]:animate-in data-[state=closed]:animate-out', className)}
{...props}
/>
))
SheetOverlay.displayName = SheetPrimitive.Overlay.displayName
interface SheetContentProps extends React.ComponentPropsWithoutRef<typeof SheetPrimitive.Content> {
side?: 'left' | 'right' | 'top' | 'bottom'
}
const SheetContent = React.forwardRef<React.ElementRef<typeof SheetPrimitive.Content>, SheetContentProps>(
({ className, side = 'right', children, ...props }, ref) => (
<SheetPortal>
<SheetOverlay />
<SheetPrimitive.Content
ref={ref}
className={cn(
'fixed z-50 gap-4 bg-background p-6 shadow-lg transition ease-in-out data-[state=open]:animate-in data-[state=closed]:animate-out',
side === 'right' && 'inset-y-0 right-0 h-full w-80 border-l',
side === 'left' && 'inset-y-0 left-0 h-full w-80 border-r',
side === 'top' && 'inset-x-0 top-0 w-full border-b',
side === 'bottom' && 'inset-x-0 bottom-0 w-full border-t',
className,
)}
{...props}
>
{children}
<SheetPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring">
<X className="h-4 w-4" />
<span className="sr-only">Закрыть</span>
</SheetPrimitive.Close>
</SheetPrimitive.Content>
</SheetPortal>
),
)
SheetContent.displayName = SheetPrimitive.Content.displayName
const SheetHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
<div className={cn('flex flex-col space-y-2 text-center sm:text-left', className)} {...props} />
)
const SheetTitle = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Title>
>(({ className, ...props }, ref) => (
<SheetPrimitive.Title ref={ref} className={cn('text-lg font-semibold text-foreground', className)} {...props} />
))
SheetTitle.displayName = SheetPrimitive.Title.displayName
const SheetDescription = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Description>
>(({ className, ...props }, ref) => (
<SheetPrimitive.Description ref={ref} className={cn('text-sm text-muted-foreground', className)} {...props} />
))
SheetDescription.displayName = SheetPrimitive.Description.displayName
export { Sheet, SheetClose, SheetContent, SheetDescription, SheetHeader, SheetPortal, SheetTitle, SheetTrigger }

View File

@ -0,0 +1,9 @@
import { cn } from '@/lib/utils'
interface SkeletonProps extends React.HTMLAttributes<HTMLDivElement> {}
const Skeleton = ({ className, ...props }: SkeletonProps) => {
return <div className={cn('animate-pulse rounded-md bg-muted/60', className)} {...props} />
}
export { Skeleton }

View File

@ -0,0 +1,27 @@
import * as React from 'react'
import * as SwitchPrimitives from '@radix-ui/react-checkbox'
import { cn } from '@/lib/utils'
const Switch = React.forwardRef<
React.ElementRef<typeof SwitchPrimitives.Root>,
React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root>
>(({ className, ...props }, ref) => (
<SwitchPrimitives.Root
ref={ref}
className={cn(
'peer inline-flex h-6 w-11 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent bg-input transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring data-[state=checked]:bg-primary',
className,
)}
{...props}
>
<SwitchPrimitives.Indicator
className={cn(
'pointer-events-none block h-5 w-5 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-5 data-[state=unchecked]:translate-x-0',
)}
/>
</SwitchPrimitives.Root>
))
Switch.displayName = SwitchPrimitives.Root.displayName
export { Switch }

View File

@ -0,0 +1,51 @@
import * as React from 'react'
import { cn } from '@/lib/utils'
const Table = React.forwardRef<HTMLTableElement, React.HTMLAttributes<HTMLTableElement>>(({ className, ...props }, ref) => (
<div className="w-full overflow-auto">
<table ref={ref} className={cn('w-full caption-bottom text-sm', className)} {...props} />
</div>
))
Table.displayName = 'Table'
const TableHeader = React.forwardRef<HTMLTableSectionElement, React.HTMLAttributes<HTMLTableSectionElement>>(({ className, ...props }, ref) => (
<thead ref={ref} className={cn('[&_tr]:border-b', className)} {...props} />
))
TableHeader.displayName = 'TableHeader'
const TableBody = React.forwardRef<HTMLTableSectionElement, React.HTMLAttributes<HTMLTableSectionElement>>(({ className, ...props }, ref) => (
<tbody ref={ref} className={cn('[&_tr:last-child]:border-0', className)} {...props} />
))
TableBody.displayName = 'TableBody'
const TableFooter = React.forwardRef<HTMLTableSectionElement, React.HTMLAttributes<HTMLTableSectionElement>>(({ className, ...props }, ref) => (
<tfoot ref={ref} className={cn('bg-muted/50 font-medium text-foreground', className)} {...props} />
))
TableFooter.displayName = 'TableFooter'
const TableRow = React.forwardRef<HTMLTableRowElement, React.HTMLAttributes<HTMLTableRowElement>>(({ className, ...props }, ref) => (
<tr ref={ref} className={cn('border-b transition-colors hover:bg-muted/40 data-[state=selected]:bg-muted', className)} {...props} />
))
TableRow.displayName = 'TableRow'
const TableHead = React.forwardRef<HTMLTableCellElement, React.ThHTMLAttributes<HTMLTableCellElement>>(({ className, ...props }, ref) => (
<th
ref={ref}
className={cn('h-10 px-2 text-left align-middle text-xs font-semibold text-muted-foreground [&:has([role=checkbox])]:pr-0', className)}
{...props}
/>
))
TableHead.displayName = 'TableHead'
const TableCell = React.forwardRef<HTMLTableCellElement, React.TdHTMLAttributes<HTMLTableCellElement>>(({ className, ...props }, ref) => (
<td ref={ref} className={cn('p-2 align-middle [&:has([role=checkbox])]:pr-0', className)} {...props} />
))
TableCell.displayName = 'TableCell'
const TableCaption = React.forwardRef<HTMLTableCaptionElement, React.HTMLAttributes<HTMLTableCaptionElement>>(({ className, ...props }, ref) => (
<caption ref={ref} className={cn('mt-4 text-sm text-muted-foreground', className)} {...props} />
))
TableCaption.displayName = 'TableCaption'
export { Table, TableBody, TableCaption, TableCell, TableFooter, TableHead, TableHeader, TableRow }

View File

@ -0,0 +1,43 @@
import * as React from 'react'
import * as TabsPrimitive from '@radix-ui/react-tabs'
import { cn } from '@/lib/utils'
const Tabs = TabsPrimitive.Root
const TabsList = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.List>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
>(({ className, ...props }, ref) => (
<TabsPrimitive.List ref={ref} className={cn('inline-flex h-9 items-center justify-center rounded-lg bg-muted p-1 text-muted-foreground', className)} {...props} />
))
TabsList.displayName = TabsPrimitive.List.displayName
const TabsTrigger = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Trigger
ref={ref}
className={cn(
'inline-flex min-w-[120px] items-center justify-center whitespace-nowrap rounded-md px-3 py-1 text-sm font-medium transition-all focus-visible:outline-none data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=inactive]:opacity-60',
className,
)}
{...props}
/>
))
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName
const TabsContent = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Content
ref={ref}
className={cn('mt-4 focus-visible:outline-none', className)}
{...props}
/>
))
TabsContent.displayName = TabsPrimitive.Content.displayName
export { Tabs, TabsContent, TabsList, TabsTrigger }

View File

@ -0,0 +1,23 @@
import * as React from 'react'
import { cn } from '@/lib/utils'
export interface TextareaProps extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {}
const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
({ className, ...props }, ref) => {
return (
<textarea
className={cn(
'flex min-h-[80px] w-full rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50',
className,
)}
ref={ref}
{...props}
/>
)
},
)
Textarea.displayName = 'Textarea'
export { Textarea }

View File

@ -0,0 +1,97 @@
import * as React from 'react'
import * as ToastPrimitives from '@radix-ui/react-toast'
import { cva, type VariantProps } from 'class-variance-authority'
import { X } from 'lucide-react'
import { cn } from '@/lib/utils'
const ToastProvider = ToastPrimitives.Provider
const ToastViewport = React.forwardRef<React.ElementRef<typeof ToastPrimitives.Viewport>, React.ComponentPropsWithoutRef<typeof ToastPrimitives.Viewport>>(
({ className, ...props }, ref) => (
<ToastPrimitives.Viewport
ref={ref}
className={cn(
'fixed top-0 right-0 z-[100] flex max-h-screen w-full flex-col-reverse gap-3 p-4 md:max-w-sm',
className,
)}
{...props}
/>
),
)
ToastViewport.displayName = ToastPrimitives.Viewport.displayName
const toastVariants = cva(
'pointer-events-auto relative flex w-full items-center justify-between space-x-4 overflow-hidden rounded-xl border p-4 pr-6 shadow-lg transition-all data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=cancel]:translate-x-0 data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out',
{
variants: {
variant: {
default: 'border bg-background text-foreground',
success: 'border border-emerald-500/50 bg-emerald-500/10 text-emerald-100 dark:text-emerald-200',
destructive: 'border-destructive bg-destructive text-destructive-foreground',
},
},
defaultVariants: {
variant: 'default',
},
},
)
const Toast = React.forwardRef<React.ElementRef<typeof ToastPrimitives.Root>, React.ComponentPropsWithoutRef<typeof ToastPrimitives.Root> & VariantProps<typeof toastVariants>>(
({ className, variant, ...props }, ref) => {
return <ToastPrimitives.Root ref={ref} className={cn(toastVariants({ variant }), className)} {...props} />
},
)
Toast.displayName = ToastPrimitives.Root.displayName
const ToastAction = React.forwardRef<React.ElementRef<typeof ToastPrimitives.Action>, React.ComponentPropsWithoutRef<typeof ToastPrimitives.Action>>(
({ className, ...props }, ref) => (
<ToastPrimitives.Action
ref={ref}
className={cn('rounded-md border border-input px-3 py-1 text-sm font-medium transition-colors hover:bg-muted', className)}
{...props}
/>
),
)
ToastAction.displayName = ToastPrimitives.Action.displayName
const ToastClose = React.forwardRef<React.ElementRef<typeof ToastPrimitives.Close>, React.ComponentPropsWithoutRef<typeof ToastPrimitives.Close>>(
({ className, ...props }, ref) => (
<ToastPrimitives.Close
ref={ref}
className={cn('absolute right-2 top-2 rounded-md p-1 text-foreground/60 transition-colors hover:text-foreground focus:outline-none focus:ring-2 focus:ring-ring', className)}
{...props}
>
<X className="h-4 w-4" />
</ToastPrimitives.Close>
),
)
ToastClose.displayName = ToastPrimitives.Close.displayName
const ToastTitle = React.forwardRef<React.ElementRef<typeof ToastPrimitives.Title>, React.ComponentPropsWithoutRef<typeof ToastPrimitives.Title>>(
({ className, ...props }, ref) => (
<ToastPrimitives.Title ref={ref} className={cn('text-sm font-semibold', className)} {...props} />
),
)
ToastTitle.displayName = ToastPrimitives.Title.displayName
const ToastDescription = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Description>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Description>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Description ref={ref} className={cn('text-sm text-muted-foreground', className)} {...props} />
))
ToastDescription.displayName = ToastPrimitives.Description.displayName
export type ToastProps = React.ComponentPropsWithoutRef<typeof Toast>
export type ToastActionElement = React.ReactElement<typeof ToastAction>
export {
Toast,
ToastAction,
ToastClose,
ToastDescription,
ToastProvider,
ToastTitle,
ToastViewport,
}

View File

@ -0,0 +1,24 @@
import { Fragment } from 'react'
import { Toast, ToastClose, ToastDescription, ToastProvider, ToastTitle, ToastViewport } from '@/components/ui/toast'
import { useToast } from '@/components/ui/use-toast'
export const Toaster = () => {
const { toasts, dismiss } = useToast()
return (
<ToastProvider swipeDirection="right">
<ToastViewport />
{toasts.map(({ id, title, description, action, ...toast }) => (
<Toast key={id} onOpenChange={(open) => !open && dismiss(id)} {...toast}>
<div className="grid gap-1">
{title ? <ToastTitle>{title}</ToastTitle> : null}
{description ? <ToastDescription>{description}</ToastDescription> : null}
</div>
{action ? <Fragment>{action}</Fragment> : null}
<ToastClose />
</Toast>
))}
</ToastProvider>
)
}

View File

@ -0,0 +1,25 @@
import * as React from 'react'
import * as TooltipPrimitive from '@radix-ui/react-tooltip'
import { cn } from '@/lib/utils'
const TooltipProvider = TooltipPrimitive.Provider
const Tooltip = TooltipPrimitive.Root
const TooltipTrigger = TooltipPrimitive.Trigger
const TooltipContent = React.forwardRef<
React.ElementRef<typeof TooltipPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<TooltipPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn('z-50 overflow-hidden rounded-md border bg-popover px-3 py-1.5 text-xs text-popover-foreground shadow-md', className)}
{...props}
/>
))
TooltipContent.displayName = TooltipPrimitive.Content.displayName
export { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger }

View File

@ -0,0 +1,84 @@
import * as React from 'react'
import type { ToastActionElement, ToastProps } from '@/components/ui/toast'
const TOAST_LIMIT = 10
const TOAST_REMOVE_DELAY = 1000
type ToasterToast = ToastProps & {
id: string
title?: React.ReactNode
description?: React.ReactNode
action?: ToastActionElement
}
const toastTimeouts = new Map<string, ReturnType<typeof setTimeout>>()
const addToRemoveQueue = (toastId: string) => {
if (toastTimeouts.has(toastId)) return
const timeout = setTimeout(() => {
toastTimeouts.delete(toastId)
toastState.remove(toastId)
}, TOAST_REMOVE_DELAY)
toastTimeouts.set(toastId, timeout)
}
const createToastState = () => {
const listeners = new Set<(toasts: ToasterToast[]) => void>()
let toasts: ToasterToast[] = []
const notify = () => {
listeners.forEach((listener) => listener(toasts))
}
return {
subscribe: (listener: (toasts: ToasterToast[]) => void) => {
listeners.add(listener)
return () => {
listeners.delete(listener)
}
},
add: (toast: ToasterToast) => {
toasts = [toast, ...toasts].slice(0, TOAST_LIMIT)
notify()
},
update: (toastId: string, toast: Partial<ToasterToast>) => {
toasts = toasts.map((item) => (item.id === toastId ? { ...item, ...toast } : item))
notify()
},
dismiss: (toastId: string) => {
addToRemoveQueue(toastId)
},
remove: (toastId: string) => {
toasts = toasts.filter((item) => item.id !== toastId)
notify()
},
getSnapshot: () => toasts,
}
}
const toastState = createToastState()
export const useToast = () => {
const [toasts, setToasts] = React.useState<ToasterToast[]>(toastState.getSnapshot())
React.useEffect(() => toastState.subscribe(setToasts), [])
const toast = React.useCallback((props: Omit<ToasterToast, 'id'>) => {
const id = crypto.randomUUID()
toastState.add({ id, ...props })
return id
}, [])
const dismiss = React.useCallback((toastId: string) => {
toastState.dismiss(toastId)
}, [])
return {
toast,
dismiss,
toasts,
}
}
export type { ToastProps, ToasterToast }

View File

@ -0,0 +1,24 @@
const API_URL = import.meta.env.VITE_API_URL?.replace(/\/$/, '') || 'https://kitchen-crm.k1nq.tech'
const API_PREFIX = '/api/v1'
const APP_NAME = 'Kitchen CRM'
const ORG_HEADER = 'X-Organization-Id'
export const env = {
APP_NAME,
API_URL,
API_PREFIX,
ORG_HEADER,
DASHBOARD_URL: import.meta.env.VITE_APP_URL || (typeof window !== 'undefined' ? window.location.origin : ''),
}
export const storageKeys = {
theme: 'kcrm-theme',
tokens: 'kcrm-auth-tokens',
user: 'kcrm-user',
activeOrg: 'kcrm-active-org',
}
export const pagination = {
defaultPageSize: 20,
maxPageSize: 100,
}

View File

@ -0,0 +1,10 @@
import { LayoutDashboard, Users, Briefcase, ListTodo, Activity, Building2 } from 'lucide-react'
export const appNavItems = [
{ label: 'Дашборд', path: '/dashboard', icon: LayoutDashboard },
{ label: 'Контакты', path: '/contacts', icon: Users },
{ label: 'Сделки', path: '/deals', icon: Briefcase },
{ label: 'Задачи', path: '/tasks', icon: ListTodo },
{ label: 'Аналитика', path: '/analytics', icon: Activity },
{ label: 'Организации', path: '/organizations', icon: Building2 },
]

View File

@ -0,0 +1,7 @@
import { apiClient } from '@/lib/api-client'
import type { DealFunnelResponse, DealSummaryResponse } from '@/types/crm'
export const getDealSummary = (days = 30) =>
apiClient.get<DealSummaryResponse>('/analytics/deals/summary', { params: { days } })
export const getDealFunnel = () => apiClient.get<DealFunnelResponse>('/analytics/deals/funnel')

View File

@ -0,0 +1,17 @@
import { useQuery } from '@tanstack/react-query'
import { getDealFunnel, getDealSummary } from '@/features/analytics/api'
export const analyticsQueryKey = ['analytics'] as const
export const useDealSummaryQuery = (days = 30) =>
useQuery({
queryKey: [...analyticsQueryKey, 'summary', days],
queryFn: () => getDealSummary(days),
})
export const useDealFunnelQuery = () =>
useQuery({
queryKey: [...analyticsQueryKey, 'funnel'],
queryFn: () => getDealFunnel(),
})

View File

@ -0,0 +1,8 @@
import { apiClient } from '@/lib/api-client'
import type { LoginPayload, RegisterPayload, TokenResponse } from '@/types/crm'
export const login = (payload: LoginPayload) =>
apiClient.post<TokenResponse, LoginPayload>('/auth/login', payload, { auth: false })
export const register = (payload: RegisterPayload) =>
apiClient.post<TokenResponse, RegisterPayload>('/auth/register', payload, { auth: false })

View File

@ -0,0 +1,35 @@
import { apiClient } from '@/lib/api-client'
import type { Contact } from '@/types/crm'
export interface ContactFilters {
page?: number
pageSize?: number
search?: string
ownerId?: number
}
export interface ContactPayload {
name: string
email?: string | null
phone?: string | null
owner_id?: number | null
}
export type ContactUpdatePayload = Partial<Omit<ContactPayload, 'owner_id'>>
const mapFilters = (filters?: ContactFilters) => ({
page: filters?.page,
page_size: filters?.pageSize,
search: filters?.search,
owner_id: filters?.ownerId,
})
export const listContacts = (filters?: ContactFilters) =>
apiClient.get<Contact[]>('/contacts/', { params: mapFilters(filters) })
export const createContact = (payload: ContactPayload) => apiClient.post<Contact, ContactPayload>('/contacts/', payload)
export const updateContact = (contactId: number, payload: ContactUpdatePayload) =>
apiClient.patch<Contact, ContactUpdatePayload>(`/contacts/${contactId}`, payload)
export const deleteContact = (contactId: number) => apiClient.delete<void>(`/contacts/${contactId}`)

View File

@ -0,0 +1,51 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { createContact, deleteContact, listContacts, updateContact } from '@/features/contacts/api'
import type { ContactFilters, ContactUpdatePayload } from '@/features/contacts/api'
export const contactsQueryKey = ['contacts'] as const
const serializeFilters = (filters?: ContactFilters) => ({
page: filters?.page ?? 1,
pageSize: filters?.pageSize,
search: filters?.search ?? '',
ownerId: filters?.ownerId ?? null,
})
export const useContactsQuery = (filters?: ContactFilters) =>
useQuery({
queryKey: [...contactsQueryKey, serializeFilters(filters)],
queryFn: () => listContacts(filters),
})
export const useInvalidateContacts = () => {
const queryClient = useQueryClient()
return () => queryClient.invalidateQueries({ queryKey: contactsQueryKey })
}
export const useCreateContactMutation = () => {
const invalidate = useInvalidateContacts()
return useMutation({
mutationFn: createContact,
onSuccess: () => invalidate(),
})
}
export const useUpdateContactMutation = () => {
const invalidate = useInvalidateContacts()
return useMutation({
mutationFn: ({ contactId, payload }: { contactId: number; payload: ContactUpdatePayload }) =>
updateContact(contactId, payload),
onSuccess: () => {
invalidate()
},
})
}
export const useDeleteContactMutation = () => {
const invalidate = useInvalidateContacts()
return useMutation({
mutationFn: (contactId: number) => deleteContact(contactId),
onSuccess: () => invalidate(),
})
}

View File

@ -0,0 +1,48 @@
import { apiClient } from '@/lib/api-client'
import type { Deal, DealStage, DealStatus } from '@/types/crm'
export interface DealFilters {
page?: number
pageSize?: number
status?: DealStatus[]
stage?: DealStage | null
ownerId?: number
minAmount?: number | null
maxAmount?: number | null
orderBy?: 'created_at' | 'amount' | 'updated_at'
order?: 'asc' | 'desc'
}
export interface DealPayload {
contact_id: number
title: string
amount?: number | string | null
currency?: string | null
owner_id?: number | null
}
export interface DealUpdatePayload {
status?: DealStatus
stage?: DealStage
amount?: number | string | null
currency?: string | null
}
const mapFilters = (filters?: DealFilters) => ({
page: filters?.page,
page_size: filters?.pageSize,
status: filters?.status,
stage: filters?.stage ?? undefined,
owner_id: filters?.ownerId,
min_amount: filters?.minAmount ?? undefined,
max_amount: filters?.maxAmount ?? undefined,
order_by: filters?.orderBy,
order: filters?.order,
})
export const listDeals = (filters?: DealFilters) => apiClient.get<Deal[]>('/deals/', { params: mapFilters(filters) })
export const createDeal = (payload: DealPayload) => apiClient.post<Deal, DealPayload>('/deals/', payload)
export const updateDeal = (dealId: number, payload: DealUpdatePayload) =>
apiClient.patch<Deal, DealUpdatePayload>(`/deals/${dealId}`, payload)

View File

@ -0,0 +1,45 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { createDeal, listDeals, updateDeal } from '@/features/deals/api'
import type { DealFilters, DealPayload, DealUpdatePayload } from '@/features/deals/api'
export const dealsQueryKey = ['deals'] as const
const serializeFilters = (filters?: DealFilters) => ({
page: filters?.page ?? 1,
pageSize: filters?.pageSize,
status: filters?.status ?? [],
stage: filters?.stage ?? null,
ownerId: filters?.ownerId ?? null,
minAmount: filters?.minAmount ?? null,
maxAmount: filters?.maxAmount ?? null,
orderBy: filters?.orderBy ?? 'created_at',
order: filters?.order ?? 'desc',
})
export const useDealsQuery = (filters?: DealFilters) =>
useQuery({
queryKey: [...dealsQueryKey, serializeFilters(filters)],
queryFn: () => listDeals(filters),
})
export const useInvalidateDeals = () => {
const queryClient = useQueryClient()
return () => queryClient.invalidateQueries({ queryKey: dealsQueryKey })
}
export const useCreateDealMutation = () => {
const invalidate = useInvalidateDeals()
return useMutation({
mutationFn: (payload: DealPayload) => createDeal(payload),
onSuccess: () => invalidate(),
})
}
export const useUpdateDealMutation = () => {
const invalidate = useInvalidateDeals()
return useMutation({
mutationFn: ({ dealId, payload }: { dealId: number; payload: DealUpdatePayload }) => updateDeal(dealId, payload),
onSuccess: () => invalidate(),
})
}

View File

@ -0,0 +1,4 @@
import { apiClient } from '@/lib/api-client'
import type { Organization } from '@/types/crm'
export const listOrganizations = () => apiClient.get<Organization[]>('/organizations/me')

View File

@ -0,0 +1,26 @@
import { useQuery, useQueryClient } from '@tanstack/react-query'
import { listOrganizations } from '@/features/organizations/api'
import { useAuthStore } from '@/stores/auth-store'
export const organizationsQueryKey = ['organizations', 'me'] as const
export const useOrganizationsQuery = () => {
const setOrganizations = useAuthStore((state) => state.setOrganizations)
const enabled = useAuthStore((state) => state.tokens !== null)
return useQuery({
queryKey: organizationsQueryKey,
queryFn: async () => {
const data = await listOrganizations()
setOrganizations(data)
return data
},
enabled,
})
}
export const useInvalidateOrganizations = () => {
const queryClient = useQueryClient()
return () => queryClient.invalidateQueries({ queryKey: organizationsQueryKey })
}

View File

@ -0,0 +1,35 @@
import { apiClient } from '@/lib/api-client'
import type { Task } from '@/types/crm'
export interface TaskFilters {
dealId?: number
onlyOpen?: boolean
dueBefore?: string | Date | null
dueAfter?: string | Date | null
}
export interface TaskPayload {
deal_id: number
title: string
description?: string | null
due_date?: string | null
}
const normalizeDate = (value?: string | Date | null) => {
if (!value) return undefined
if (value instanceof Date) {
return value.toISOString().slice(0, 10)
}
return value
}
const mapFilters = (filters?: TaskFilters) => ({
deal_id: filters?.dealId,
only_open: filters?.onlyOpen,
due_before: normalizeDate(filters?.dueBefore ?? undefined),
due_after: normalizeDate(filters?.dueAfter ?? undefined),
})
export const listTasks = (filters?: TaskFilters) => apiClient.get<Task[]>('/tasks/', { params: mapFilters(filters) })
export const createTask = (payload: TaskPayload) => apiClient.post<Task, TaskPayload>('/tasks/', payload)

View File

@ -0,0 +1,32 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { createTask, listTasks } from '@/features/tasks/api'
import type { TaskFilters, TaskPayload } from '@/features/tasks/api'
export const tasksQueryKey = ['tasks'] as const
const serializeFilters = (filters?: TaskFilters) => ({
dealId: filters?.dealId ?? null,
onlyOpen: filters?.onlyOpen ?? false,
dueBefore: filters?.dueBefore ? String(filters.dueBefore) : null,
dueAfter: filters?.dueAfter ? String(filters.dueAfter) : null,
})
export const useTasksQuery = (filters?: TaskFilters) =>
useQuery({
queryKey: [...tasksQueryKey, serializeFilters(filters)],
queryFn: () => listTasks(filters),
})
export const useInvalidateTasks = () => {
const queryClient = useQueryClient()
return () => queryClient.invalidateQueries({ queryKey: tasksQueryKey })
}
export const useCreateTaskMutation = () => {
const invalidate = useInvalidateTasks()
return useMutation({
mutationFn: (payload: TaskPayload) => createTask(payload),
onSuccess: () => invalidate(),
})
}

View File

@ -0,0 +1,12 @@
import { useEffect, useState } from 'react'
export const useDebounce = <T>(value: T, delay = 300) => {
const [debounced, setDebounced] = useState(value)
useEffect(() => {
const timer = window.setTimeout(() => setDebounced(value), delay)
return () => window.clearTimeout(timer)
}, [value, delay])
return debounced
}

View File

@ -0,0 +1,20 @@
import { useCallback } from 'react'
import { useThemeStore } from '@/stores/theme-store'
export const useTheme = () => {
const theme = useThemeStore((state) => state.theme)
const resolvedTheme = useThemeStore((state) => state.resolvedTheme)
const setTheme = useThemeStore((state) => state.setTheme)
const toggleTheme = useCallback(() => {
setTheme(resolvedTheme === 'dark' ? 'light' : 'dark')
}, [resolvedTheme, setTheme])
return {
theme,
resolvedTheme,
setTheme,
toggleTheme,
}
}

68
frontend/src/index.css Normal file
View File

@ -0,0 +1,68 @@
:root {
font-family: system-ui, Avenir, Helvetica, Arial, sans-serif;
line-height: 1.5;
font-weight: 400;
color-scheme: light dark;
color: rgba(255, 255, 255, 0.87);
background-color: #242424;
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
a {
font-weight: 500;
color: #646cff;
text-decoration: inherit;
}
a:hover {
color: #535bf2;
}
body {
margin: 0;
display: flex;
place-items: center;
min-width: 320px;
min-height: 100vh;
}
h1 {
font-size: 3.2em;
line-height: 1.1;
}
button {
border-radius: 8px;
border: 1px solid transparent;
padding: 0.6em 1.2em;
font-size: 1em;
font-weight: 500;
font-family: inherit;
background-color: #1a1a1a;
cursor: pointer;
transition: border-color 0.25s;
}
button:hover {
border-color: #646cff;
}
button:focus,
button:focus-visible {
outline: 4px auto -webkit-focus-ring-color;
}
@media (prefers-color-scheme: light) {
:root {
color: #213547;
background-color: #ffffff;
}
a:hover {
color: #747bff;
}
button {
background-color: #f9f9f9;
}
}

View File

@ -0,0 +1,57 @@
import { Menu } from 'lucide-react'
import { Outlet } from 'react-router-dom'
import { OrganizationSwitcher } from '@/components/organization/organization-switcher'
import { AppLogo } from '@/components/navigation/app-logo'
import { SidebarNav } from '@/components/navigation/sidebar'
import { UserMenu } from '@/components/navigation/user-menu'
import { ThemeToggle } from '@/components/theme/theme-toggle'
import { Button } from '@/components/ui/button'
import { Sheet, SheetContent, SheetTrigger } from '@/components/ui/sheet'
export const AppShell = () => {
return (
<div className="flex min-h-screen bg-background text-foreground">
<aside className="hidden w-64 flex-col border-r bg-card/30 p-4 shadow-sm md:flex">
<AppLogo className="mb-6" />
<OrganizationSwitcher />
<div className="mt-6 flex-1">
<SidebarNav />
</div>
</aside>
<div className="flex flex-1 flex-col">
<header className="flex items-center justify-between border-b bg-card/30 px-4 py-3">
<div className="flex items-center gap-3">
<Sheet>
<SheetTrigger asChild>
<Button variant="ghost" size="icon" className="md:hidden">
<Menu className="h-5 w-5" />
</Button>
</SheetTrigger>
<SheetContent side="left" className="w-64">
<AppLogo className="mb-6" />
<OrganizationSwitcher />
<div className="mt-6">
<SidebarNav />
</div>
</SheetContent>
</Sheet>
<div className="md:hidden">
<AppLogo />
</div>
</div>
<div className="flex items-center gap-2">
<div className="hidden sm:block">
<OrganizationSwitcher />
</div>
<ThemeToggle />
<UserMenu />
</div>
</header>
<main className="flex-1 bg-muted/20 p-4 md:p-6">
<Outlet />
</main>
</div>
</div>
)
}

View File

@ -0,0 +1,22 @@
import { Outlet } from 'react-router-dom'
import { AppLogo } from '@/components/navigation/app-logo'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
export const AuthLayout = () => (
<div className="flex min-h-screen flex-col items-center justify-center bg-gradient-to-br from-slate-950 via-slate-900 to-slate-950 p-6 text-white">
<div className="mb-8 text-center">
<AppLogo className="text-white" />
<p className="text-sm text-slate-300">Войдите, чтобы управлять воронкой продаж</p>
</div>
<Card className="w-full max-w-md border-white/10 bg-white/5 text-white">
<CardHeader>
<CardTitle>Kitchen CRM</CardTitle>
<CardDescription className="text-slate-200">Мини-CRM с мультитенантной архитектурой</CardDescription>
</CardHeader>
<CardContent>
<Outlet />
</CardContent>
</Card>
</div>
)

View File

@ -0,0 +1,122 @@
import { env } from '@/config/env'
import { useAuthStore } from '@/stores/auth-store'
import type { ApiError } from '@/types/crm'
export type HttpMethod = 'GET' | 'POST' | 'PATCH' | 'PUT' | 'DELETE'
export interface RequestOptions<TBody = unknown> {
path: string
method?: HttpMethod
params?: Record<string, string | number | boolean | Array<string | number | boolean> | undefined | null>
body?: TBody
headers?: HeadersInit
signal?: AbortSignal
auth?: boolean
}
export class HttpError extends Error {
status: number
payload: ApiError | null
constructor(status: number, message: string, payload: ApiError | null = null) {
super(message)
this.status = status
this.payload = payload
}
}
const API_BASE_URL = `${env.API_URL}${env.API_PREFIX}`
const buildUrl = (path: string, params?: RequestOptions['params']) => {
const url = new URL(path.startsWith('http') ? path : `${API_BASE_URL}${path}`)
if (params) {
Object.entries(params).forEach(([key, value]) => {
if (value === undefined || value === null) return
if (Array.isArray(value)) {
value.forEach((entry) => {
if (entry === undefined || entry === null) return
url.searchParams.append(key, String(entry))
})
} else {
url.searchParams.set(key, String(value))
}
})
}
return url.toString()
}
const parseResponse = async <T>(response: Response): Promise<T> => {
if (response.status === 204 || response.status === 205) {
return undefined as T
}
const contentType = response.headers.get('content-type') || ''
if (contentType.includes('application/json')) {
return (await response.json()) as T
}
return (await response.text()) as T
}
const requestWithRefresh = async <T>(options: RequestOptions, retry = false): Promise<T> => {
const { tokens, activeOrganizationId, refreshSession, logout } = useAuthStore.getState()
const authEnabled = options.auth !== false
const headers = new Headers(options.headers || {})
if (authEnabled && tokens?.accessToken) {
headers.set('Authorization', `Bearer ${tokens.accessToken}`)
if (activeOrganizationId) {
headers.set(env.ORG_HEADER, String(activeOrganizationId))
}
}
if (options.body && !(options.body instanceof FormData)) {
headers.set('Content-Type', 'application/json')
}
headers.set('Accept', 'application/json')
const response = await fetch(buildUrl(options.path, options.params), {
method: options.method ?? 'GET',
body:
options.body instanceof FormData
? options.body
: options.body
? JSON.stringify(options.body)
: undefined,
headers,
signal: options.signal,
})
if (response.status === 401 && authEnabled && !retry) {
try {
await refreshSession()
} catch (error) {
logout()
throw error
}
return requestWithRefresh<T>(options, true)
}
if (!response.ok) {
let payload: ApiError | null = null
try {
payload = await response.clone().json()
} catch {
payload = null
}
throw new HttpError(response.status, payload?.detail ? String(payload.detail) : response.statusText, payload)
}
return parseResponse<T>(response)
}
export const apiClient = {
request: requestWithRefresh,
get: <T>(path: string, options: Omit<RequestOptions, 'path' | 'method'> = {}) =>
requestWithRefresh<T>({ path, ...options, method: 'GET' }),
post: <T, TBody>(path: string, body: TBody, options: Omit<RequestOptions<TBody>, 'path' | 'method' | 'body'> = {}) =>
requestWithRefresh<T>({ path, body, ...options, method: 'POST' }),
patch: <T, TBody>(path: string, body: TBody, options: Omit<RequestOptions<TBody>, 'path' | 'method' | 'body'> = {}) =>
requestWithRefresh<T>({ path, body, ...options, method: 'PATCH' }),
delete: <T>(path: string, options: Omit<RequestOptions, 'path' | 'method'> = {}) =>
requestWithRefresh<T>({ path, ...options, method: 'DELETE' }),
}

View File

@ -0,0 +1,21 @@
import { QueryClient } from '@tanstack/react-query'
export const createQueryClient = () =>
new QueryClient({
defaultOptions: {
queries: {
staleTime: 1000 * 60,
refetchOnWindowFocus: false,
refetchOnReconnect: true,
retry: (failureCount, error: unknown) => {
if (error instanceof Response && error.status === 401) {
return false
}
return failureCount < 3
},
},
mutations: {
retry: 0,
},
},
})

View File

@ -0,0 +1,21 @@
export const storage = {
get<T>(key: string, fallback: T | null = null): T | null {
if (typeof window === 'undefined') return fallback
try {
const raw = window.localStorage.getItem(key)
if (!raw) return fallback
return JSON.parse(raw) as T
} catch (error) {
console.error(`Failed to parse storage item ${key}`, error)
return fallback
}
},
set<T>(key: string, value: T | null): void {
if (typeof window === 'undefined') return
if (value === null) {
window.localStorage.removeItem(key)
return
}
window.localStorage.setItem(key, JSON.stringify(value))
},
}

17
frontend/src/lib/token.ts Normal file
View File

@ -0,0 +1,17 @@
import { jwtDecode } from 'jwt-decode'
interface TokenPayload {
sub?: string
email?: string
name?: string
[key: string]: unknown
}
export const parseToken = (token: string) => {
try {
return jwtDecode<TokenPayload>(token)
} catch (error) {
console.error('Failed to parse token', error)
return null
}
}

39
frontend/src/lib/utils.ts Normal file
View File

@ -0,0 +1,39 @@
import { type ClassValue, clsx } from 'clsx'
import { twMerge } from 'tailwind-merge'
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
export const formatCurrency = (value?: string | number | null, currency = 'USD') => {
if (value === null || value === undefined || value === '') return '—'
const amount = typeof value === 'string' ? Number(value) : value
if (Number.isNaN(amount)) return '—'
return new Intl.NumberFormat(undefined, {
style: 'currency',
currency,
maximumFractionDigits: 2,
}).format(amount)
}
export const formatDate = (value?: string | Date | null, options: Intl.DateTimeFormatOptions = {}) => {
if (!value) return '—'
const date = typeof value === 'string' ? new Date(value) : value
if (Number.isNaN(date.getTime())) return '—'
return new Intl.DateTimeFormat(undefined, {
day: '2-digit',
month: 'short',
year: 'numeric',
...options,
}).format(date)
}
export const formatRelativeDate = (value?: string | Date | null) => {
if (!value) return '—'
const date = typeof value === 'string' ? new Date(value) : value
if (Number.isNaN(date.getTime())) return '—'
const formatter = new Intl.RelativeTimeFormat(undefined, { numeric: 'auto' })
const diffMs = date.getTime() - Date.now()
const diffDays = Math.round(diffMs / (1000 * 60 * 60 * 24))
return formatter.format(diffDays, 'day')
}

11
frontend/src/main.tsx Normal file
View File

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

View File

@ -0,0 +1,206 @@
import { useMemo, useState } from 'react'
import { ResponsiveContainer, BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, Legend } from 'recharts'
import { dealStageLabels } from '@/components/crm/deal-stage-badge'
import { dealStatusLabels } from '@/components/crm/deal-status-badge'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { Skeleton } from '@/components/ui/skeleton'
import { useDealFunnelQuery, useDealSummaryQuery } from '@/features/analytics/hooks'
import { formatCurrency } from '@/lib/utils'
import type { DealFunnelResponse, DealStatus } from '@/types/crm'
const dayOptions = [7, 14, 30, 60, 90, 120]
const statusColors: Record<DealStatus, string> = {
new: '#fbbf24',
in_progress: '#38bdf8',
won: '#22c55e',
lost: '#f87171',
}
const parseDecimal = (value?: string | number | null) => {
if (value === null || value === undefined) return 0
const num = typeof value === 'string' ? Number(value) : value
return Number.isFinite(num) ? num : 0
}
const AnalyticsPage = () => {
const [days, setDays] = useState(30)
const summaryQuery = useDealSummaryQuery(days)
const funnelQuery = useDealFunnelQuery()
const summary = summaryQuery.data
const funnel = funnelQuery.data
const statusChartData = useMemo(
() =>
summary?.by_status.map((item) => ({
status: dealStatusLabels[item.status],
count: item.count,
amount: parseDecimal(item.amount_sum),
rawStatus: item.status,
})) ?? [],
[summary],
)
const funnelChartData = useMemo(() => buildFunnelChartData(funnel), [funnel])
return (
<div className="space-y-6">
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<div>
<h1 className="text-2xl font-semibold text-foreground">Аналитика сделок</h1>
<p className="text-sm text-muted-foreground">Сводка по статусам и этапам с конверсией и динамикой.</p>
</div>
<Select value={String(days)} onValueChange={(value) => setDays(Number(value))}>
<SelectTrigger className="w-[200px] bg-background">
<SelectValue placeholder="Период" />
</SelectTrigger>
<SelectContent>
{dayOptions.map((option) => (
<SelectItem key={option} value={String(option)}>
Последние {option} дней
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<section className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
{summary ? (
<>
<SummaryCard title="Сделок в работе" primary={summary.total_deals} secondary={`${summary.new_deals.count} за ${summary.new_deals.days} дн.`} />
<SummaryCard title="Выиграно" primary={formatCurrency(summary.won.amount_sum)} secondary={`Средний чек ${formatCurrency(summary.won.average_amount)}`} />
<SummaryCard title="По статусам" primary={summary.by_status.reduce((acc, item) => acc + item.count, 0)} secondary="Всего записей" />
<SummaryCard title="Активность" primary={`${summary.new_deals.count}`} secondary={`Новых за ${summary.new_deals.days} дн.`} />
</>
) : (
<SummarySkeleton />
)}
</section>
<div className="grid gap-4 lg:grid-cols-2">
<Card>
<CardHeader>
<CardTitle>Статусы сделок</CardTitle>
<CardDescription>Количество сделок и суммы по каждому статусу.</CardDescription>
</CardHeader>
<CardContent className="h-[320px]">
{summaryQuery.isLoading ? (
<Skeleton className="h-full w-full rounded-xl" />
) : (
<ResponsiveContainer width="100%" height="100%">
<BarChart data={statusChartData}>
<CartesianGrid strokeDasharray="3 3" opacity={0.3} />
<XAxis dataKey="status" tick={{ fill: 'currentColor', fontSize: 12 }} />
<YAxis tick={{ fill: 'currentColor', fontSize: 12 }} allowDecimals={false} />
<Tooltip content={<StatusTooltip />} cursor={{ fill: 'transparent' }} />
<Bar dataKey="count" fill="hsl(var(--primary))" radius={[8, 8, 0, 0]} name="Сделки" />
</BarChart>
</ResponsiveContainer>
)}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Воронка продаж</CardTitle>
<CardDescription>Распределение сделок по этапам и статусам.</CardDescription>
</CardHeader>
<CardContent className="h-[320px]">
{funnelQuery.isLoading ? (
<Skeleton className="h-full w-full rounded-xl" />
) : (
<ResponsiveContainer width="100%" height="100%">
<BarChart data={funnelChartData} stackOffset="expand">
<CartesianGrid strokeDasharray="3 3" opacity={0.3} />
<XAxis dataKey="stage" tick={{ fill: 'currentColor', fontSize: 12 }} />
<YAxis tick={{ fill: 'currentColor', fontSize: 12 }} tickFormatter={(value) => `${Math.round(value * 100)}%`} />
<Tooltip content={<FunnelTooltip />} cursor={{ fill: 'transparent' }} />
<Legend />
{(['new', 'in_progress', 'won', 'lost'] as DealStatus[]).map((status) => (
<Bar key={status} dataKey={status} stackId="status" fill={statusColors[status]} name={dealStatusLabels[status]} />
))}
</BarChart>
</ResponsiveContainer>
)}
</CardContent>
</Card>
</div>
</div>
)
}
const SummaryCard = ({ title, primary, secondary }: { title: string; primary: string | number; secondary: string }) => (
<Card>
<CardHeader>
<CardTitle className="text-base">{title}</CardTitle>
<CardDescription>{secondary}</CardDescription>
</CardHeader>
<CardContent>
<p className="text-3xl font-semibold">{primary}</p>
</CardContent>
</Card>
)
const SummarySkeleton = () => (
<>
{[...Array(4)].map((_, index) => (
<Card key={index}>
<CardHeader>
<Skeleton className="h-3 w-24" />
</CardHeader>
<CardContent>
<Skeleton className="h-8 w-32" />
</CardContent>
</Card>
))}
</>
)
const StatusTooltip = ({ active, payload }: { active?: boolean; payload?: Array<{ payload: { status: string; count: number; amount: number } }> }) => {
if (!active || !payload?.length) return null
const [{ payload: data }] = payload
return (
<div className="rounded-md border bg-background/90 px-4 py-2 text-sm shadow">
<p className="font-semibold">{data.status}</p>
<p>Сделок: {data.count}</p>
<p>Сумма: {formatCurrency(data.amount)}</p>
</div>
)
}
const FunnelTooltip = ({ active, payload }: { active?: boolean; payload?: Array<{ payload: Record<string, number> }> }) => {
if (!active || !payload?.length) return null
const [{ payload: data }] = payload
return (
<div className="rounded-md border bg-background/90 px-4 py-2 text-sm shadow">
<p className="font-semibold">{data.stage}</p>
{(['new', 'in_progress', 'won', 'lost'] as DealStatus[]).map((status) => (
<p key={status}>
{dealStatusLabels[status]}: {Math.round((data[status] || 0) * 100)}%
</p>
))}
</div>
)
}
const buildFunnelChartData = (funnel?: DealFunnelResponse) => {
if (!funnel) return []
return funnel.stages.map((stage) => {
const total = stage.total || 1
return {
stage: dealStageLabels[stage.stage],
...(['new', 'in_progress', 'won', 'lost'] as DealStatus[]).reduce(
(acc, status) => ({
...acc,
[status]: (stage.by_status[status] ?? 0) / total,
}),
{},
),
}
})
}
export default AnalyticsPage

View File

@ -0,0 +1,110 @@
import { zodResolver } from '@hookform/resolvers/zod'
import { useMutation } from '@tanstack/react-query'
import { Loader2 } from 'lucide-react'
import { useForm } from 'react-hook-form'
import { Link, useNavigate } from 'react-router-dom'
import { z } from 'zod'
import { Button } from '@/components/ui/button'
import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form'
import { Input } from '@/components/ui/input'
import { useToast } from '@/components/ui/use-toast'
import { login } from '@/features/auth/api'
import { HttpError } from '@/lib/api-client'
import { authSelectors, useAuthStore } from '@/stores/auth-store'
const loginSchema = z.object({
email: z.string().email('Введите корректный email'),
password: z.string().min(8, 'Минимальная длина пароля — 8 символов'),
})
type LoginValues = z.infer<typeof loginSchema>
const LoginPage = () => {
const form = useForm<LoginValues>({
resolver: zodResolver(loginSchema),
defaultValues: { email: '', password: '' },
})
const navigate = useNavigate()
const { toast } = useToast()
const setSession = useAuthStore((state) => state.setSession)
const { mutateAsync, isPending } = useMutation({
mutationFn: login,
})
const handleSubmit = async (values: LoginValues) => {
form.clearErrors('root')
try {
const tokenResponse = await mutateAsync(values)
const tokens = authSelectors.mapTokens(tokenResponse)
setSession({ tokens, organizations: [], activeOrganizationId: null })
toast({ title: 'Добро пожаловать 👋', description: 'Контакты и сделки синхронизируются с сервером.' })
navigate('/dashboard')
} catch (error) {
const message = error instanceof HttpError ? error.message : 'Не удалось выполнить вход'
form.setError('root', { message })
}
}
const rootError = form.formState.errors.root?.message
return (
<div className="space-y-6">
<div>
<h2 className="text-2xl font-semibold">С возвращением</h2>
<p className="text-sm text-muted-foreground">Введите корпоративный email и пароль.</p>
</div>
<Form {...form}>
<form onSubmit={form.handleSubmit(handleSubmit)} className="space-y-5">
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>E-mail</FormLabel>
<FormControl>
<Input type="email" placeholder="you@company.com" autoComplete="email" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="password"
render={({ field }) => (
<FormItem>
<FormLabel>Пароль</FormLabel>
<FormControl>
<Input type="password" placeholder="Введите пароль" autoComplete="current-password" {...field} />
</FormControl>
<FormDescription>Минимум 8 символов, буквы и цифры.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
{rootError ? <p className="text-sm font-medium text-destructive">{rootError}</p> : null}
<Button type="submit" className="w-full" disabled={isPending}>
{isPending ? (
<span className="flex items-center justify-center gap-2">
<Loader2 className="h-4 w-4 animate-spin" />
Входим
</span>
) : (
'Войти'
)}
</Button>
</form>
</Form>
<p className="text-center text-sm text-muted-foreground">
Нет аккаунта?{' '}
<Link to="/auth/register" className="font-medium text-primary hover:underline">
Зарегистрироваться
</Link>
</p>
</div>
)
}
export default LoginPage

View File

@ -0,0 +1,155 @@
import { zodResolver } from '@hookform/resolvers/zod'
import { useMutation } from '@tanstack/react-query'
import { Loader2 } from 'lucide-react'
import { useForm } from 'react-hook-form'
import { Link, useNavigate } from 'react-router-dom'
import { z } from 'zod'
import { Button } from '@/components/ui/button'
import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form'
import { Input } from '@/components/ui/input'
import { useToast } from '@/components/ui/use-toast'
import { register } from '@/features/auth/api'
import { HttpError } from '@/lib/api-client'
import { authSelectors, useAuthStore } from '@/stores/auth-store'
const registerSchema = z.object({
name: z.string().min(2, 'Введите имя владельца'),
email: z.string().email('Введите корректный email'),
password: z
.string()
.min(8, 'Минимальная длина пароля — 8 символов')
.regex(/[A-Za-z]/, 'Добавьте буквы')
.regex(/\d/, 'Добавьте цифры'),
organization_name: z
.string()
.transform((value) => value.trim())
.refine((value) => value.length <= 120, 'Название слишком длинное'),
})
type RegisterValues = z.infer<typeof registerSchema>
const RegisterPage = () => {
const form = useForm<RegisterValues>({
resolver: zodResolver(registerSchema),
defaultValues: { name: '', email: '', password: '', organization_name: '' },
})
const setSession = useAuthStore((state) => state.setSession)
const navigate = useNavigate()
const { toast } = useToast()
const { mutateAsync, isPending } = useMutation({
mutationFn: register,
})
const handleSubmit = async (values: RegisterValues) => {
form.clearErrors('root')
try {
const payload = {
...values,
organization_name: values.organization_name?.trim() ? values.organization_name.trim() : null,
}
const tokenResponse = await mutateAsync(payload)
const tokens = authSelectors.mapTokens(tokenResponse)
setSession({ tokens, organizations: [], activeOrganizationId: null })
toast({
title: 'Организация создана',
description: 'Вы владелец первой организации. Добавьте коллег на вкладке «Организации».',
})
navigate('/dashboard')
} catch (error) {
const message = error instanceof HttpError ? error.message : 'Не удалось завершить регистрацию'
form.setError('root', { message })
}
}
const rootError = form.formState.errors.root?.message
return (
<div className="space-y-6">
<div className="space-y-1">
<h2 className="text-2xl font-semibold">Создайте организацию</h2>
<p className="text-sm text-muted-foreground">Владелец получит полный доступ и сможет пригласить команду.</p>
</div>
<Form {...form}>
<form onSubmit={form.handleSubmit(handleSubmit)} className="space-y-5">
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Имя и фамилия</FormLabel>
<FormControl>
<Input placeholder="Алиса Менеджер" autoComplete="name" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>Корпоративный email</FormLabel>
<FormControl>
<Input type="email" placeholder="owner@acme.io" autoComplete="email" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="password"
render={({ field }) => (
<FormItem>
<FormLabel>Пароль</FormLabel>
<FormControl>
<Input type="password" placeholder="Сложный пароль" autoComplete="new-password" {...field} />
</FormControl>
<FormDescription>Используйте минимум 8 символов, буквы и цифры.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="organization_name"
render={({ field }) => (
<FormItem>
<FormLabel optional>Организация</FormLabel>
<FormControl>
<Input placeholder="Acme Inc" {...field} />
</FormControl>
<FormDescription>
Укажите, чтобы сразу создать компанию и стать владельцем. Можно пропустить и присоединиться позже.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
{rootError ? <p className="text-sm font-medium text-destructive">{rootError}</p> : null}
<Button type="submit" className="w-full" disabled={isPending}>
{isPending ? (
<span className="flex items-center justify-center gap-2">
<Loader2 className="h-4 w-4 animate-spin" />
Создаём
</span>
) : (
'Зарегистрироваться'
)}
</Button>
</form>
</Form>
<p className="text-center text-sm text-muted-foreground">
Уже есть аккаунт?{' '}
<Link to="/auth/login" className="font-medium text-primary hover:underline">
Войти
</Link>
</p>
</div>
)
}
export default RegisterPage

View File

@ -0,0 +1,328 @@
import { zodResolver } from '@hookform/resolvers/zod'
import { type ColumnDef } from '@tanstack/react-table'
import { Pencil, Plus, Trash2 } from 'lucide-react'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { useForm } from 'react-hook-form'
import { z } from 'zod'
import { DataTable } from '@/components/data-table/data-table'
import { DataTableToolbar } from '@/components/data-table/data-table-toolbar'
import { Button } from '@/components/ui/button'
import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form'
import { Input } from '@/components/ui/input'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from '@/components/ui/sheet'
import { useToast } from '@/components/ui/use-toast'
import { useContactsQuery, useCreateContactMutation, useDeleteContactMutation, useUpdateContactMutation } from '@/features/contacts/hooks'
import { useDebounce } from '@/hooks/use-debounce'
import { formatDate } from '@/lib/utils'
import type { Contact } from '@/types/crm'
const contactFormSchema = z.object({
name: z.string().min(2, 'Имя не короче двух символов'),
email: z.string().email('Некорректный email').or(z.literal('')).optional(),
phone: z.string().min(6, 'Телефон слишком короткий').or(z.literal('')).optional(),
ownerId: z.string().optional(),
})
interface ContactFormValues extends z.infer<typeof contactFormSchema> {}
const defaultValues: ContactFormValues = {
name: '',
email: '',
phone: '',
ownerId: '',
}
const ContactsPage = () => {
const [search, setSearch] = useState('')
const [ownerFilter, setOwnerFilter] = useState('all')
const [drawerOpen, setDrawerOpen] = useState(false)
const [editingContact, setEditingContact] = useState<Contact | null>(null)
const debouncedSearch = useDebounce(search, 400)
const ownerIdFilter = ownerFilter === 'all' ? undefined : Number(ownerFilter)
const { data: contacts = [], isLoading } = useContactsQuery({
search: debouncedSearch.trim() || undefined,
ownerId: Number.isFinite(ownerIdFilter) ? ownerIdFilter : undefined,
})
const createContact = useCreateContactMutation()
const updateContact = useUpdateContactMutation()
const deleteContact = useDeleteContactMutation()
const { toast } = useToast()
const ownerIds = useMemo(() => {
const ids = new Set<number>()
contacts.forEach((contact) => {
if (contact.owner_id) ids.add(contact.owner_id)
})
return Array.from(ids).sort((a, b) => a - b)
}, [contacts])
const ownerSelectOptions = useMemo(() => ownerIds.map((ownerId) => ({ value: String(ownerId), label: `Сотрудник #${ownerId}` })), [ownerIds])
const openCreateDrawer = () => {
setEditingContact(null)
setDrawerOpen(true)
}
const openEditDrawer = (contact: Contact) => {
setEditingContact(contact)
setDrawerOpen(true)
}
const handleDelete = useCallback(
async (contact: Contact) => {
const confirmed = window.confirm(`Удалить контакт «${contact.name}»?`)
if (!confirmed) return
try {
await deleteContact.mutateAsync(contact.id)
toast({ title: 'Контакт удалён', description: 'Запись больше не отображается в списке.' })
} catch (error) {
toast({ title: 'Ошибка удаления', description: error instanceof Error ? error.message : 'Попробуйте позже', variant: 'destructive' })
}
},
[deleteContact, toast],
)
const columns = useMemo<ColumnDef<Contact>[]>(
() => [
{
accessorKey: 'name',
header: 'Контакт',
cell: ({ row }) => (
<div>
<p className="font-medium">{row.original.name}</p>
<p className="text-xs text-muted-foreground">{row.original.email ?? '—'}</p>
</div>
),
},
{
accessorKey: 'phone',
header: 'Телефон',
cell: ({ row }) => row.original.phone ?? '—',
},
{
accessorKey: 'owner_id',
header: 'Владелец',
cell: ({ row }) => <span className="text-sm text-muted-foreground">Сотрудник #{row.original.owner_id}</span>,
},
{
accessorKey: 'created_at',
header: 'Создан',
cell: ({ row }) => <span className="text-sm text-muted-foreground">{formatDate(row.original.created_at)}</span>,
},
{
id: 'actions',
header: '',
cell: ({ row }) => (
<div className="flex items-center justify-end gap-2">
<Button variant="ghost" size="icon" onClick={() => openEditDrawer(row.original)}>
<Pencil className="h-4 w-4" />
<span className="sr-only">Редактировать</span>
</Button>
<Button variant="ghost" size="icon" className="text-destructive" onClick={() => handleDelete(row.original)}>
<Trash2 className="h-4 w-4" />
<span className="sr-only">Удалить</span>
</Button>
</div>
),
},
],
[handleDelete],
)
return (
<div className="space-y-6">
<header>
<h1 className="text-2xl font-semibold text-foreground">Контакты</h1>
<p className="text-sm text-muted-foreground">Управляйте базой контактов и быстро создавайте новые записи.</p>
</header>
<DataTable
columns={columns}
data={contacts}
isLoading={isLoading}
renderToolbar={
<DataTableToolbar
searchPlaceholder="Поиск по имени или email"
searchValue={search}
onSearchChange={setSearch}
actions={
<Button onClick={openCreateDrawer} className="gap-2">
<Plus className="h-4 w-4" />
Новый контакт
</Button>
}
>
{ownerIds.length ? (
<Select value={ownerFilter} onValueChange={setOwnerFilter}>
<SelectTrigger className="w-[200px] bg-background">
<SelectValue placeholder="Все владельцы" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Все владельцы</SelectItem>
{ownerIds.map((ownerId) => (
<SelectItem key={ownerId} value={String(ownerId)}>
Сотрудник #{ownerId}
</SelectItem>
))}
</SelectContent>
</Select>
) : null}
</DataTableToolbar>
}
/>
<ContactFormDrawer
open={drawerOpen}
onOpenChange={setDrawerOpen}
contact={editingContact}
isSubmitting={createContact.isPending || updateContact.isPending}
ownerOptions={ownerSelectOptions}
onSubmit={async (values) => {
const payload = {
name: values.name,
email: values.email ? values.email : null,
phone: values.phone ? values.phone : null,
owner_id: values.ownerId ? Number(values.ownerId) : undefined,
}
try {
if (editingContact) {
await updateContact.mutateAsync({ contactId: editingContact.id, payload })
toast({ title: 'Контакт обновлён', description: 'Изменения сохранены.' })
} else {
await createContact.mutateAsync(payload)
toast({ title: 'Контакт создан', description: 'Добавлен новый контакт.' })
}
setDrawerOpen(false)
} catch (error) {
toast({ title: 'Ошибка сохранения', description: error instanceof Error ? error.message : 'Попробуйте ещё раз', variant: 'destructive' })
}
}}
key={editingContact?.id ?? 'create'}
/>
</div>
)
}
interface ContactFormDrawerProps {
open: boolean
onOpenChange: (open: boolean) => void
contact: Contact | null
onSubmit: (values: ContactFormValues) => Promise<void>
isSubmitting: boolean
ownerOptions: Array<{ value: string; label: string }>
}
const ContactFormDrawer = ({ open, onOpenChange, contact, onSubmit, isSubmitting, ownerOptions }: ContactFormDrawerProps) => {
const form = useForm<ContactFormValues>({
resolver: zodResolver(contactFormSchema),
defaultValues,
})
useEffect(() => {
if (contact) {
form.reset({
name: contact.name,
email: contact.email ?? '',
phone: contact.phone ?? '',
ownerId: contact.owner_id ? String(contact.owner_id) : '',
})
} else {
form.reset(defaultValues)
}
}, [contact, form])
const handleSubmit = async (values: ContactFormValues) => {
await onSubmit(values)
form.reset(defaultValues)
}
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent className="w-full max-w-md sm:max-w-lg">
<SheetHeader>
<SheetTitle>{contact ? 'Редактирование контакта' : 'Новый контакт'}</SheetTitle>
<SheetDescription>Укажите основные данные и при необходимости закрепите владельца.</SheetDescription>
</SheetHeader>
<Form {...form}>
<form onSubmit={form.handleSubmit(handleSubmit)} className="mt-6 space-y-4">
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Имя</FormLabel>
<FormControl>
<Input placeholder="Мария Иванова" autoFocus {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>E-mail</FormLabel>
<FormControl>
<Input type="email" placeholder="maria@example.com" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="phone"
render={({ field }) => (
<FormItem>
<FormLabel>Телефон</FormLabel>
<FormControl>
<Input placeholder="+7 999 000-00-00" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="ownerId"
render={({ field }) => (
<FormItem>
<FormLabel>Владелец</FormLabel>
<Select value={field.value} onValueChange={field.onChange} disabled={!ownerOptions.length}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder={ownerOptions.length ? 'Выберите владельца' : 'Назначение недоступно'} />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="">Назначить меня</SelectItem>
{ownerOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
<FormDescription>Выберите руководителя из списка или оставьте поле пустым, чтобы назначить себя.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<div className="flex justify-end gap-2 pt-4">
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
Отмена
</Button>
<Button type="submit" disabled={isSubmitting}>
{isSubmitting ? 'Сохраняем…' : contact ? 'Сохранить' : 'Создать'}
</Button>
</div>
</form>
</Form>
</SheetContent>
</Sheet>
)
}
export default ContactsPage

View File

@ -0,0 +1,13 @@
const DashboardPage = () => (
<div className="space-y-4">
<div>
<h1 className="text-2xl font-semibold text-foreground">Дашборд</h1>
<p className="text-sm text-muted-foreground">Скоро здесь появится аналитика сделок и активностей.</p>
</div>
<div className="rounded-lg border border-dashed border-border p-6 text-muted-foreground">
Контент дашборда в разработке.
</div>
</div>
)
export default DashboardPage

View File

@ -0,0 +1,630 @@
import { zodResolver } from '@hookform/resolvers/zod'
import { type ColumnDef } from '@tanstack/react-table'
import { ResponsiveContainer, BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip } from 'recharts'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { useForm } from 'react-hook-form'
import { z } from 'zod'
import { DataTable } from '@/components/data-table/data-table'
import { DataTableToolbar } from '@/components/data-table/data-table-toolbar'
import { DealStageBadge, dealStageLabels } from '@/components/crm/deal-stage-badge'
import { DealStatusBadge, dealStatusLabels } from '@/components/crm/deal-status-badge'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form'
import { Input } from '@/components/ui/input'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from '@/components/ui/sheet'
import { useToast } from '@/components/ui/use-toast'
import { useContactsQuery } from '@/features/contacts/hooks'
import { useCreateDealMutation, useDealsQuery, useUpdateDealMutation } from '@/features/deals/hooks'
import { formatCurrency, formatDate } from '@/lib/utils'
import type { Contact, Deal, DealStage, DealStatus } from '@/types/crm'
const dealStatusList: DealStatus[] = ['new', 'in_progress', 'won', 'lost']
const dealStageList: DealStage[] = ['qualification', 'proposal', 'negotiation', 'closed']
const dealCreateSchema = z.object({
title: z.string().min(3, 'Минимум 3 символа'),
contactId: z.string().min(1, 'Введите ID контакта'),
amount: z.string().optional(),
currency: z.string().min(3).max(3).optional(),
ownerId: z.string().optional(),
})
type DealCreateFormValues = z.infer<typeof dealCreateSchema>
const dealUpdateSchema = z.object({
status: z.enum(dealStatusList),
stage: z.enum(dealStageList),
amount: z.string().optional(),
currency: z.string().min(3).max(3).optional(),
})
type DealUpdateFormValues = z.infer<typeof dealUpdateSchema>
const mapAmount = (value?: string | null) => {
if (!value) return 0
const number = Number(value)
return Number.isFinite(number) ? number : 0
}
const DealsPage = () => {
const [search, setSearch] = useState('')
const [statusFilter, setStatusFilter] = useState<'all' | DealStatus>('all')
const [stageFilter, setStageFilter] = useState<'all' | DealStage>('all')
const [ownerFilter, setOwnerFilter] = useState('')
const [minAmount, setMinAmount] = useState('')
const [maxAmount, setMaxAmount] = useState('')
const [createOpen, setCreateOpen] = useState(false)
const [dealToEdit, setDealToEdit] = useState<Deal | null>(null)
const { toast } = useToast()
const filters = useMemo(() => {
const ownerNumber = ownerFilter ? Number(ownerFilter) : undefined
const min = minAmount ? Number(minAmount) : undefined
const max = maxAmount ? Number(maxAmount) : undefined
return {
status: statusFilter === 'all' ? undefined : [statusFilter],
stage: stageFilter === 'all' ? undefined : stageFilter,
ownerId: Number.isFinite(ownerNumber) ? ownerNumber : undefined,
minAmount: Number.isFinite(min) ? min : undefined,
maxAmount: Number.isFinite(max) ? max : undefined,
}
}, [statusFilter, stageFilter, ownerFilter, minAmount, maxAmount])
const { data: deals = [], isLoading } = useDealsQuery(filters)
const { data: contacts = [], isLoading: contactsLoading } = useContactsQuery({ pageSize: 100 })
const filteredDeals = useMemo(() => {
const query = search.trim().toLowerCase()
if (!query) return deals
return deals.filter((deal) => deal.title.toLowerCase().includes(query))
}, [deals, search])
const createDeal = useCreateDealMutation()
const updateDeal = useUpdateDealMutation()
const ownerOptions = useMemo(
() =>
Array.from(
new Set(
[
...deals.map((deal) => deal.owner_id),
...contacts.map((contact) => contact.owner_id).filter((id): id is number => typeof id === 'number'),
],
),
)
.filter((id) => id !== undefined && id !== null)
.map((id) => ({ value: String(id), label: `Сотрудник #${id}` })),
[contacts, deals],
)
const stats = useMemo(() => {
const total = deals.length
const pipeline = deals.reduce((acc, deal) => acc + mapAmount(deal.amount), 0)
const won = deals.filter((deal) => deal.status === 'won')
const wonAmount = won.reduce((acc, deal) => acc + mapAmount(deal.amount), 0)
const conversion = total ? Math.round((won.length / total) * 100) : 0
return { total, pipeline, wonAmount, conversion }
}, [deals])
const stageChartData = useMemo(
() =>
dealStageList.map((stage) => {
const stageDeals = deals.filter((deal) => deal.stage === stage)
return {
stage: dealStageLabels[stage],
count: stageDeals.length,
value: stageDeals.reduce((acc, deal) => acc + mapAmount(deal.amount), 0),
}
}),
[deals],
)
const handleEditDeal = useCallback((deal: Deal) => setDealToEdit(deal), [])
const columns = useMemo<ColumnDef<Deal>[]>(
() => [
{
accessorKey: 'title',
header: 'Сделка',
cell: ({ row }) => (
<div>
<p className="font-medium">{row.original.title}</p>
<p className="text-xs text-muted-foreground">Контакт #{row.original.contact_id}</p>
</div>
),
},
{
accessorKey: 'amount',
header: 'Сумма',
cell: ({ row }) => (
<div>
<p className="font-medium">{formatCurrency(row.original.amount, row.original.currency ?? 'USD')}</p>
<p className="text-xs text-muted-foreground">{row.original.currency ?? '—'}</p>
</div>
),
},
{
accessorKey: 'status',
header: 'Статус',
cell: ({ row }) => <DealStatusBadge status={row.original.status} />,
},
{
accessorKey: 'stage',
header: 'Этап',
cell: ({ row }) => <DealStageBadge stage={row.original.stage} />,
},
{
accessorKey: 'owner_id',
header: 'Владелец',
cell: ({ row }) => <span className="text-sm text-muted-foreground">Сотрудник #{row.original.owner_id}</span>,
},
{
accessorKey: 'updated_at',
header: 'Обновлена',
cell: ({ row }) => <span className="text-sm text-muted-foreground">{formatDate(row.original.updated_at)}</span>,
},
{
id: 'actions',
header: '',
cell: ({ row }) => (
<Button variant="ghost" size="sm" onClick={() => handleEditDeal(row.original)}>
Обновить
</Button>
),
},
],
[handleEditDeal],
)
return (
<div className="space-y-6">
<header>
<h1 className="text-2xl font-semibold text-foreground">Сделки</h1>
<p className="text-sm text-muted-foreground">Следите за воронкой продаж и обновляйте статусы в один клик.</p>
</header>
<section className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
<StatCard title="Всего сделок" value={stats.total} description="Количество в текущем списке" />
<StatCard title="Потенциал" value={formatCurrency(stats.pipeline)} description="Суммарный pipeline" />
<StatCard title="Выиграно" value={formatCurrency(stats.wonAmount)} description="Сумма выигранных сделок" />
<StatCard title="Конверсия" value={`${stats.conversion}%`} description="Выиграно от всех" />
</section>
<Card>
<CardHeader>
<CardTitle>Воронка по этапам</CardTitle>
<CardDescription>Количество и сумма сделок на каждом этапе.</CardDescription>
</CardHeader>
<CardContent className="h-[280px]">
<ResponsiveContainer width="100%" height="100%">
<BarChart data={stageChartData}>
<CartesianGrid strokeDasharray="3 3" opacity={0.3} />
<XAxis dataKey="stage" tick={{ fill: 'currentColor', fontSize: 12 }} />
<YAxis tick={{ fill: 'currentColor', fontSize: 12 }} />
<Tooltip content={<StageTooltip />} cursor={{ fill: 'transparent' }} />
<Bar dataKey="count" name="Сделки" fill="hsl(var(--primary))" radius={[8, 8, 0, 0]} />
</BarChart>
</ResponsiveContainer>
</CardContent>
</Card>
<DataTable
columns={columns}
data={filteredDeals}
isLoading={isLoading}
renderToolbar={
<DataTableToolbar
searchPlaceholder="Поиск по названию"
searchValue={search}
onSearchChange={setSearch}
actions={
<Button className="gap-2" onClick={() => setCreateOpen(true)}>
+ Новая сделка
</Button>
}
>
<Select value={statusFilter} onValueChange={(value) => setStatusFilter(value as typeof statusFilter)}>
<SelectTrigger className="w-[180px] bg-background">
<SelectValue placeholder="Статус" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Все статусы</SelectItem>
{dealStatusList.map((status) => (
<SelectItem key={status} value={status}>
{dealStatusLabels[status]}
</SelectItem>
))}
</SelectContent>
</Select>
<Select value={stageFilter} onValueChange={(value) => setStageFilter(value as typeof stageFilter)}>
<SelectTrigger className="w-[200px] bg-background">
<SelectValue placeholder="Этап" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Все этапы</SelectItem>
{dealStageList.map((stage) => (
<SelectItem key={stage} value={stage}>
{dealStageLabels[stage]}
</SelectItem>
))}
</SelectContent>
</Select>
<Input
type="number"
placeholder="ID владельца"
value={ownerFilter}
onChange={(event) => setOwnerFilter(event.target.value)}
className="w-[140px]"
/>
<div className="flex items-center gap-2">
<Input
type="number"
placeholder="Мин сумма"
value={minAmount}
onChange={(event) => setMinAmount(event.target.value)}
className="w-[130px]"
/>
<Input
type="number"
placeholder="Макс сумма"
value={maxAmount}
onChange={(event) => setMaxAmount(event.target.value)}
className="w-[130px]"
/>
</div>
</DataTableToolbar>
}
/>
<DealCreateDrawer
open={createOpen}
onOpenChange={setCreateOpen}
isSubmitting={createDeal.isPending}
contacts={contacts}
contactsLoading={contactsLoading}
ownerOptions={ownerOptions}
onSubmit={async (values) => {
const payload = {
title: values.title,
contact_id: Number(values.contactId),
amount: values.amount ? Number(values.amount) : undefined,
currency: values.currency ? values.currency.toUpperCase() : undefined,
owner_id: values.ownerId && values.ownerId !== 'auto' ? Number(values.ownerId) : undefined,
}
try {
await createDeal.mutateAsync(payload)
toast({ title: 'Сделка создана', description: 'Запись появилась в воронке.' })
setCreateOpen(false)
} catch (error) {
toast({ title: 'Не удалось создать сделку', description: error instanceof Error ? error.message : 'Попробуйте снова', variant: 'destructive' })
}
}}
/>
<DealUpdateDrawer
deal={dealToEdit}
open={Boolean(dealToEdit)}
onOpenChange={(open) => {
if (!open) setDealToEdit(null)
}}
isSubmitting={updateDeal.isPending}
onSubmit={async (values) => {
if (!dealToEdit) return
const payload = {
status: values.status,
stage: values.stage,
amount: values.amount ? Number(values.amount) : undefined,
currency: values.currency ? values.currency.toUpperCase() : undefined,
}
try {
await updateDeal.mutateAsync({ dealId: dealToEdit.id, payload })
toast({ title: 'Сделка обновлена', description: 'Статус и этап сохранены.' })
setDealToEdit(null)
} catch (error) {
toast({ title: 'Ошибка обновления', description: error instanceof Error ? error.message : 'Попробуйте позже', variant: 'destructive' })
}
}}
/>
</div>
)
}
interface StatCardProps {
title: string
value: string | number
description: string
}
const StatCard = ({ title, value, description }: StatCardProps) => (
<Card>
<CardHeader>
<CardTitle className="text-base">{title}</CardTitle>
<CardDescription>{description}</CardDescription>
</CardHeader>
<CardContent>
<p className="text-3xl font-semibold">{value}</p>
</CardContent>
</Card>
)
const StageTooltip = ({ active, payload }: { active?: boolean; payload?: Array<{ payload: { stage: string; count: number; value: number } }> }) => {
if (!active || !payload?.length) return null
const [{ payload: data }] = payload
return (
<div className="rounded-md border bg-background/90 px-4 py-2 text-sm shadow">
<p className="font-semibold">{data.stage}</p>
<p>Сделок: {data.count}</p>
<p>Сумма: {formatCurrency(data.value)}</p>
</div>
)
}
interface DealCreateDrawerProps {
open: boolean
onOpenChange: (open: boolean) => void
onSubmit: (values: DealCreateFormValues) => Promise<void>
isSubmitting: boolean
contacts: Contact[]
contactsLoading: boolean
ownerOptions: Array<{ value: string; label: string }>
}
const DealCreateDrawer = ({ open, onOpenChange, onSubmit, isSubmitting, contacts, contactsLoading, ownerOptions }: DealCreateDrawerProps) => {
const form = useForm<DealCreateFormValues>({
resolver: zodResolver(dealCreateSchema),
defaultValues: { title: '', contactId: '', amount: '', currency: 'USD', ownerId: 'auto' },
})
const handleSubmit = async (values: DealCreateFormValues) => {
await onSubmit(values)
form.reset({ title: '', contactId: '', amount: '', currency: 'USD', ownerId: 'auto' })
}
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent className="w-full max-w-lg">
<SheetHeader>
<SheetTitle>Новая сделка</SheetTitle>
<SheetDescription>Заполните данные для создания сделки.</SheetDescription>
</SheetHeader>
<Form {...form}>
<form onSubmit={form.handleSubmit(handleSubmit)} className="mt-6 space-y-4">
<FormField
control={form.control}
name="title"
render={({ field }) => (
<FormItem>
<FormLabel>Название</FormLabel>
<FormControl>
<Input placeholder="Реставрация сайта" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="contactId"
render={({ field }) => (
<FormItem>
<FormLabel>Контакт</FormLabel>
<Select value={field.value} onValueChange={field.onChange} disabled={contactsLoading || !contacts.length}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder={contactsLoading ? 'Загружаем контакты…' : 'Выберите контакт'} />
</SelectTrigger>
</FormControl>
<SelectContent>
{contacts.map((contact) => (
<SelectItem key={contact.id} value={String(contact.id)}>
{contact.name} · #{contact.id}
</SelectItem>
))}
</SelectContent>
</Select>
<FormDescription>
{contacts.length ? 'Контакт будет связан со сделкой.' : 'Сначала создайте контакт в разделе «Контакты».'}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="amount"
render={({ field }) => (
<FormItem>
<FormLabel>Сумма</FormLabel>
<FormControl>
<Input type="number" placeholder="10000" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="currency"
render={({ field }) => (
<FormItem>
<FormLabel>Валюта</FormLabel>
<FormControl>
<Input placeholder="USD" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="ownerId"
render={({ field }) => (
<FormItem>
<FormLabel>Владелец (необязательно)</FormLabel>
<Select value={field.value} onValueChange={field.onChange}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder={ownerOptions.length ? 'Выберите владельца' : 'Только автоназначение'} />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="auto">Назначить автоматически</SelectItem>
{ownerOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
<FormDescription>При отсутствии выбора сделка будет закреплена за вами.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<div className="flex justify-end gap-2 pt-4">
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
Отмена
</Button>
<Button type="submit" disabled={isSubmitting}>
{isSubmitting ? 'Создаём…' : 'Создать'}
</Button>
</div>
</form>
</Form>
</SheetContent>
</Sheet>
)
}
interface DealUpdateDrawerProps {
deal: Deal | null
open: boolean
onOpenChange: (open: boolean) => void
onSubmit: (values: DealUpdateFormValues) => Promise<void>
isSubmitting: boolean
}
const DealUpdateDrawer = ({ deal, open, onOpenChange, onSubmit, isSubmitting }: DealUpdateDrawerProps) => {
const form = useForm<DealUpdateFormValues>({
resolver: zodResolver(dealUpdateSchema),
defaultValues: { status: 'new', stage: 'qualification', amount: '', currency: 'USD' },
})
useEffect(() => {
if (deal) {
form.reset({
status: deal.status,
stage: deal.stage,
amount: deal.amount ?? '',
currency: deal.currency ?? 'USD',
})
}
}, [deal, form])
if (!deal) return null
const handleSubmit = async (values: DealUpdateFormValues) => {
await onSubmit(values)
}
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent className="w-full max-w-lg">
<SheetHeader>
<SheetTitle>Обновление сделки</SheetTitle>
<SheetDescription>Измените статус, этап или сумму.</SheetDescription>
</SheetHeader>
<Form {...form}>
<form onSubmit={form.handleSubmit(handleSubmit)} className="mt-6 space-y-4">
<FormField
control={form.control}
name="status"
render={({ field }) => (
<FormItem>
<FormLabel>Статус</FormLabel>
<Select value={field.value} onValueChange={field.onChange}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Выберите статус" />
</SelectTrigger>
</FormControl>
<SelectContent>
{dealStatusList.map((status) => (
<SelectItem key={status} value={status}>
{dealStatusLabels[status]}
</SelectItem>
))}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="stage"
render={({ field }) => (
<FormItem>
<FormLabel>Этап</FormLabel>
<Select value={field.value} onValueChange={field.onChange}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Выберите этап" />
</SelectTrigger>
</FormControl>
<SelectContent>
{dealStageList.map((stage) => (
<SelectItem key={stage} value={stage}>
{dealStageLabels[stage]}
</SelectItem>
))}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="amount"
render={({ field }) => (
<FormItem>
<FormLabel>Сумма</FormLabel>
<FormControl>
<Input type="number" placeholder="0" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="currency"
render={({ field }) => (
<FormItem>
<FormLabel>Валюта</FormLabel>
<FormControl>
<Input placeholder="USD" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className="flex justify-end gap-2 pt-4">
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
Отмена
</Button>
<Button type="submit" disabled={isSubmitting}>
{isSubmitting ? 'Сохраняем…' : 'Сохранить'}
</Button>
</div>
</form>
</Form>
</SheetContent>
</Sheet>
)
}
export default DealsPage

View File

@ -0,0 +1,14 @@
import { Link } from 'react-router-dom'
export const NotFoundPage = () => (
<div className="flex min-h-[60vh] flex-col items-center justify-center space-y-4 text-center">
<div className="space-y-2">
<p className="text-sm font-semibold text-primary">404</p>
<h1 className="text-3xl font-bold tracking-tight text-foreground">Страница не найдена</h1>
<p className="text-sm text-muted-foreground">Мы не смогли найти запрошенную страницу. Проверьте URL или вернитесь на дашборд.</p>
</div>
<Link className="text-sm font-semibold text-primary hover:underline" to="/dashboard">
Вернуться в приложение
</Link>
</div>
)

View File

@ -0,0 +1,91 @@
import { Building2, RefreshCw } from 'lucide-react'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Skeleton } from '@/components/ui/skeleton'
import { useToast } from '@/components/ui/use-toast'
import { useOrganizationsQuery, useInvalidateOrganizations } from '@/features/organizations/hooks'
import { useAuthStore } from '@/stores/auth-store'
import { formatDate } from '@/lib/utils'
const OrganizationsPage = () => {
const { data: organizations, isLoading, isFetching } = useOrganizationsQuery()
const activeOrganizationId = useAuthStore((state) => state.activeOrganizationId)
const setActiveOrganization = useAuthStore((state) => state.setActiveOrganization)
const invalidate = useInvalidateOrganizations()
const { toast } = useToast()
const handleSwitch = (id: number) => {
setActiveOrganization(id)
toast({ title: 'Контекст переключён', description: 'Все запросы теперь выполняются в выбранной организации.' })
}
return (
<div className="space-y-6">
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<div>
<h1 className="text-2xl font-semibold text-foreground">Организации</h1>
<p className="text-sm text-muted-foreground">Список компаний, к которым у вас есть доступ.</p>
</div>
<Button variant="outline" size="sm" className="gap-2" onClick={invalidate} disabled={isFetching}>
<RefreshCw className={`h-4 w-4 ${isFetching ? 'animate-spin' : ''}`} />
Обновить
</Button>
</div>
{isLoading ? (
<div className="grid gap-4 lg:grid-cols-2">
{[...Array(2)].map((_, index) => (
<Card key={index} className="border-dashed">
<CardHeader>
<Skeleton className="h-4 w-32" />
</CardHeader>
<CardContent>
<Skeleton className="h-6 w-24" />
<Skeleton className="mt-4 h-4 w-40" />
</CardContent>
</Card>
))}
</div>
) : organizations && organizations.length ? (
<div className="grid gap-4 lg:grid-cols-2">
{organizations.map((org) => (
<Card key={org.id} className={org.id === activeOrganizationId ? 'border-primary shadow-md' : undefined}>
<CardHeader className="flex flex-row items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-primary/10 text-primary">
<Building2 className="h-5 w-5" />
</div>
<div>
<CardTitle className="text-lg">{org.name}</CardTitle>
<CardDescription>ID {org.id}</CardDescription>
</div>
</CardHeader>
<CardContent className="flex items-center justify-between">
<div>
<p className="text-sm text-muted-foreground">Создана {formatDate(org.created_at)}</p>
{org.id === activeOrganizationId ? (
<p className="text-sm font-medium text-primary">Активная организация</p>
) : null}
</div>
{org.id === activeOrganizationId ? null : (
<Button variant="outline" size="sm" onClick={() => handleSwitch(org.id)}>
Сделать активной
</Button>
)}
</CardContent>
</Card>
))}
</div>
) : (
<Card className="border-dashed text-center">
<CardHeader>
<CardTitle>Нет организаций</CardTitle>
<CardDescription>Обратитесь к администратору, чтобы вас добавили в рабочую область.</CardDescription>
</CardHeader>
</Card>
)}
</div>
)
}
export default OrganizationsPage

View File

@ -0,0 +1,281 @@
import { zodResolver } from '@hookform/resolvers/zod'
import { type ColumnDef } from '@tanstack/react-table'
import { useMemo, useState } from 'react'
import { useForm } from 'react-hook-form'
import { z } from 'zod'
import { DataTable } from '@/components/data-table/data-table'
import { DataTableToolbar } from '@/components/data-table/data-table-toolbar'
import { TaskStatusPill } from '@/components/crm/task-status-pill'
import { Button } from '@/components/ui/button'
import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form'
import { Input } from '@/components/ui/input'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from '@/components/ui/sheet'
import { Switch } from '@/components/ui/switch'
import { Textarea } from '@/components/ui/textarea'
import { useToast } from '@/components/ui/use-toast'
import { useCreateTaskMutation, useTasksQuery } from '@/features/tasks/hooks'
import { useDealsQuery } from '@/features/deals/hooks'
import { useDebounce } from '@/hooks/use-debounce'
import { formatDate, formatRelativeDate } from '@/lib/utils'
import type { Deal, Task } from '@/types/crm'
const taskFormSchema = z
.object({
title: z.string().min(3, 'Минимум 3 символа'),
dealId: z.string().min(1, 'Укажите ID сделки'),
description: z.string().max(500, 'Описание до 500 символов').optional(),
dueDate: z.string().optional(),
})
.refine((values) => {
if (!values.dueDate) return true
const selected = new Date(values.dueDate)
const today = new Date()
selected.setHours(0, 0, 0, 0)
today.setHours(0, 0, 0, 0)
return selected >= today
}, { message: 'Дата не может быть в прошлом', path: ['dueDate'] })
type TaskFormValues = z.infer<typeof taskFormSchema>
const defaultTaskValues: TaskFormValues = { title: '', dealId: '', description: '', dueDate: '' }
const TasksPage = () => {
const [search, setSearch] = useState('')
const [dealFilter, setDealFilter] = useState('')
const [onlyOpen, setOnlyOpen] = useState(true)
const [dueAfter, setDueAfter] = useState('')
const [dueBefore, setDueBefore] = useState('')
const [drawerOpen, setDrawerOpen] = useState(false)
const debouncedDealId = useDebounce(dealFilter, 300)
const { toast } = useToast()
const { data: tasks = [], isLoading } = useTasksQuery({
dealId: debouncedDealId ? Number(debouncedDealId) : undefined,
onlyOpen,
dueAfter: dueAfter || undefined,
dueBefore: dueBefore || undefined,
})
const filteredTasks = useMemo(() => {
const query = search.trim().toLowerCase()
if (!query) return tasks
return tasks.filter((task) => task.title.toLowerCase().includes(query))
}, [tasks, search])
const createTask = useCreateTaskMutation()
const { data: deals = [], isLoading: dealsLoading } = useDealsQuery({ pageSize: 100, orderBy: 'updated_at', order: 'desc' })
const columns = useMemo<ColumnDef<Task>[]>(
() => [
{
accessorKey: 'title',
header: 'Задача',
cell: ({ row }) => (
<div>
<p className="font-medium">{row.original.title}</p>
<p className="text-xs text-muted-foreground">Сделка #{row.original.deal_id}</p>
</div>
),
},
{
accessorKey: 'due_date',
header: 'Срок',
cell: ({ row }) => (
<div>
<p>{row.original.due_date ? formatDate(row.original.due_date) : '—'}</p>
<p className="text-xs text-muted-foreground">{row.original.due_date ? formatRelativeDate(row.original.due_date) : ''}</p>
</div>
),
},
{
accessorKey: 'is_done',
header: 'Статус',
cell: ({ row }) => <TaskStatusPill done={row.original.is_done} />,
},
{
accessorKey: 'created_at',
header: 'Создана',
cell: ({ row }) => <span className="text-sm text-muted-foreground">{formatDate(row.original.created_at)}</span>,
},
],
[],
)
return (
<div className="space-y-6">
<header>
<h1 className="text-2xl font-semibold text-foreground">Задачи</h1>
<p className="text-sm text-muted-foreground">Контролируйте follow-up по сделкам и создавайте напоминания.</p>
</header>
<DataTable
columns={columns}
data={filteredTasks}
isLoading={isLoading}
renderToolbar={
<DataTableToolbar
searchPlaceholder="Поиск по названию"
searchValue={search}
onSearchChange={setSearch}
actions={
<Button className="gap-2" onClick={() => setDrawerOpen(true)}>
+ Новая задача
</Button>
}
>
<Input
type="number"
placeholder="ID сделки"
value={dealFilter}
onChange={(event) => setDealFilter(event.target.value)}
className="w-[140px]"
/>
<div className="flex items-center gap-2 rounded-lg border bg-background px-3 py-1.5 text-sm">
<Switch checked={onlyOpen} onCheckedChange={(value) => setOnlyOpen(value === true)} id="only-open" />
<label htmlFor="only-open" className="cursor-pointer">
Только открытые
</label>
</div>
<Input type="date" value={dueAfter} onChange={(event) => setDueAfter(event.target.value)} className="w-[170px]" />
<Input type="date" value={dueBefore} onChange={(event) => setDueBefore(event.target.value)} className="w-[170px]" />
</DataTableToolbar>
}
/>
<TaskDrawer
open={drawerOpen}
onOpenChange={setDrawerOpen}
isSubmitting={createTask.isPending}
deals={deals}
dealsLoading={dealsLoading}
onSubmit={async (values) => {
const payload = {
deal_id: Number(values.dealId),
title: values.title,
description: values.description ? values.description : undefined,
due_date: values.dueDate || undefined,
}
try {
await createTask.mutateAsync(payload)
toast({ title: 'Задача создана', description: 'Добавлено напоминание для сделки.' })
setDrawerOpen(false)
} catch (error) {
toast({ title: 'Ошибка создания', description: error instanceof Error ? error.message : 'Попробуйте позже', variant: 'destructive' })
}
}}
/>
</div>
)
}
interface TaskDrawerProps {
open: boolean
onOpenChange: (open: boolean) => void
onSubmit: (values: TaskFormValues) => Promise<void>
isSubmitting: boolean
deals: Deal[]
dealsLoading: boolean
}
const TaskDrawer = ({ open, onOpenChange, onSubmit, isSubmitting, deals, dealsLoading }: TaskDrawerProps) => {
const form = useForm<TaskFormValues>({
resolver: zodResolver(taskFormSchema),
defaultValues: defaultTaskValues,
})
const handleSubmit = async (values: TaskFormValues) => {
await onSubmit(values)
form.reset(defaultTaskValues)
}
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent className="w-full max-w-lg">
<SheetHeader>
<SheetTitle>Новая задача</SheetTitle>
<SheetDescription>Запланируйте следующий шаг для сделки.</SheetDescription>
</SheetHeader>
<Form {...form}>
<form onSubmit={form.handleSubmit(handleSubmit)} className="mt-6 space-y-4">
<FormField
control={form.control}
name="title"
render={({ field }) => (
<FormItem>
<FormLabel>Название</FormLabel>
<FormControl>
<Input placeholder="Позвонить клиенту" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="dealId"
render={({ field }) => (
<FormItem>
<FormLabel>Сделка</FormLabel>
<Select value={field.value} onValueChange={field.onChange} disabled={dealsLoading || !deals.length}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder={dealsLoading ? 'Загружаем сделки…' : 'Выберите сделку'} />
</SelectTrigger>
</FormControl>
<SelectContent>
{deals.map((deal) => (
<SelectItem key={deal.id} value={String(deal.id)}>
{deal.title} · #{deal.id}
</SelectItem>
))}
</SelectContent>
</Select>
<FormDescription>
{deals.length ? 'Задача появится в таймлайне выбранной сделки.' : 'Сначала создайте сделку в разделе «Сделки».'}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="dueDate"
render={({ field }) => (
<FormItem>
<FormLabel>Срок выполнения</FormLabel>
<FormControl>
<Input type="date" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="description"
render={({ field }) => (
<FormItem>
<FormLabel>Описание</FormLabel>
<FormControl>
<Textarea rows={4} placeholder="Кратко опишите следующий шаг" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className="flex justify-end gap-2 pt-4">
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
Отмена
</Button>
<Button type="submit" disabled={isSubmitting}>
{isSubmitting ? 'Сохраняем…' : 'Создать'}
</Button>
</div>
</form>
</Form>
</SheetContent>
</Sheet>
)
}
export default TasksPage

View File

@ -0,0 +1,13 @@
import { Navigate, Outlet } from 'react-router-dom'
import { useAuthStore } from '@/stores/auth-store'
export const GuestOnly = () => {
const hasSession = useAuthStore((state) => Boolean(state.tokens))
if (hasSession) {
return <Navigate to="/dashboard" replace />
}
return <Outlet />
}

View File

@ -0,0 +1,26 @@
import { Navigate, Outlet, useLocation } from 'react-router-dom'
import { AppLoading } from '@/components/system/app-loading'
import { useOrganizationsQuery } from '@/features/organizations/hooks'
import { useAuthStore } from '@/stores/auth-store'
export const RequireAuth = () => {
const status = useAuthStore((state) => state.status)
const tokens = useAuthStore((state) => state.tokens)
const location = useLocation()
const { isLoading: orgsLoading, data: orgs } = useOrganizationsQuery()
if (status === 'loading') {
return <AppLoading />
}
if (!tokens) {
return <Navigate to="/auth/login" state={{ from: location }} replace />
}
if (orgsLoading && !orgs) {
return <AppLoading />
}
return <Outlet />
}

View File

@ -0,0 +1,53 @@
import { lazy } from 'react'
import { Navigate, createBrowserRouter } from 'react-router-dom'
import { AuthLayout } from '@/layouts/auth-layout'
import { AppShell } from '@/layouts/app-shell'
import { NotFoundPage } from '@/pages/not-found-page'
import { GuestOnly } from '@/routes/guards/guest-only'
import { RequireAuth } from '@/routes/guards/require-auth'
const LoginPage = lazy(() => import('@/pages/auth/login-page'))
const RegisterPage = lazy(() => import('@/pages/auth/register-page'))
const DashboardPage = lazy(() => import('@/pages/dashboard/dashboard-page'))
const ContactsPage = lazy(() => import('@/pages/contacts/contacts-page'))
const DealsPage = lazy(() => import('@/pages/deals/deals-page'))
const TasksPage = lazy(() => import('@/pages/tasks/tasks-page'))
const AnalyticsPage = lazy(() => import('@/pages/analytics/analytics-page'))
const OrganizationsPage = lazy(() => import('@/pages/organizations/organizations-page'))
export const router = createBrowserRouter([
{
path: '/auth',
element: <GuestOnly />,
children: [
{
element: <AuthLayout />,
children: [
{ index: true, element: <Navigate to="/auth/login" replace /> },
{ path: 'login', element: <LoginPage /> },
{ path: 'register', element: <RegisterPage /> },
],
},
],
},
{
element: <RequireAuth />,
children: [
{
path: '/',
element: <AppShell />,
children: [
{ index: true, element: <Navigate to="/dashboard" replace /> },
{ path: 'dashboard', element: <DashboardPage /> },
{ path: 'contacts', element: <ContactsPage /> },
{ path: 'deals', element: <DealsPage /> },
{ path: 'tasks', element: <TasksPage /> },
{ path: 'analytics', element: <AnalyticsPage /> },
{ path: 'organizations', element: <OrganizationsPage /> },
],
},
],
},
{ path: '*', element: <NotFoundPage /> },
])

View File

@ -0,0 +1,133 @@
import { create } from 'zustand'
import { persist } from 'zustand/middleware'
import { env, storageKeys } from '@/config/env'
import type { Organization, User } from '@/types/crm'
import { parseToken } from '@/lib/token'
type TokenBundle = {
accessToken: string
refreshToken: string
expiresAt: number
refreshExpiresAt: number
}
type AuthStatus = 'idle' | 'authenticated' | 'loading'
interface AuthState {
status: AuthStatus
user: User | null
organizations: Organization[]
activeOrganizationId: number | null
tokens: TokenBundle | null
setSession: (payload: {
user?: User | null
organizations: Organization[]
activeOrganizationId?: number | null
tokens: TokenBundle
}) => void
setOrganizations: (organizations: Organization[]) => void
setActiveOrganization: (organizationId: number | null) => void
setTokens: (tokens: TokenBundle | null) => void
logout: () => void
refreshSession: () => Promise<void>
}
const mapTokens = (input: {
access_token: string
refresh_token: string
expires_in: number
refresh_expires_in: number
}): TokenBundle => ({
accessToken: input.access_token,
refreshToken: input.refresh_token,
expiresAt: Date.now() + input.expires_in * 1000,
refreshExpiresAt: Date.now() + input.refresh_expires_in * 1000,
})
const deriveUser = (tokens: TokenBundle, fallback?: User | null): User | null => {
if (fallback) return fallback
const payload = parseToken(tokens.accessToken)
if (!payload?.sub) return null
const derived: User = {
id: Number(payload.sub),
email: payload.email ?? 'anonymous@unknown',
name: payload.name ?? payload.email?.split('@')[0] ?? 'Сотрудник',
created_at: new Date().toISOString(),
}
return derived
}
export const useAuthStore = create<AuthState>()(
persist(
(set, get) => ({
status: 'idle',
user: null,
organizations: [],
activeOrganizationId: null,
tokens: null,
setSession: ({ user, organizations, activeOrganizationId, tokens }) => {
const nextUser = deriveUser(tokens, user ?? get().user)
set({
status: 'authenticated',
user: nextUser,
organizations,
activeOrganizationId: activeOrganizationId ?? organizations[0]?.id ?? null,
tokens,
})
},
setOrganizations: (organizations) => {
const current = get().activeOrganizationId
const fallback = organizations[0]?.id ?? null
set({
organizations,
activeOrganizationId: current && organizations.some((org) => org.id === current) ? current : fallback,
})
},
setActiveOrganization: (organizationId) => {
set({ activeOrganizationId: organizationId })
},
setTokens: (tokens) => set({ tokens }),
logout: () => {
set({ status: 'idle', user: null, tokens: null, organizations: [], activeOrganizationId: null })
},
refreshSession: async () => {
const { tokens, logout } = get()
if (!tokens?.refreshToken) {
logout()
return
}
const response = await fetch(`${env.API_URL}${env.API_PREFIX}/auth/refresh`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ refresh_token: tokens.refreshToken }),
})
if (!response.ok) {
logout()
throw new Error('Unable to refresh session')
}
const nextTokens = mapTokens(await response.json())
set({ tokens: nextTokens, status: 'authenticated' })
},
}),
{
name: storageKeys.tokens,
partialize: ({ tokens, user, organizations, activeOrganizationId, status }) => ({
tokens,
user,
organizations,
activeOrganizationId,
status,
}),
},
),
)
export const authSelectors = {
accessToken: () => useAuthStore.getState().tokens?.accessToken ?? null,
refreshToken: () => useAuthStore.getState().tokens?.refreshToken ?? null,
expiresAt: () => useAuthStore.getState().tokens?.expiresAt ?? null,
organizationId: () => useAuthStore.getState().activeOrganizationId,
isAuthenticated: () => useAuthStore.getState().status === 'authenticated',
mapTokens,
}

View File

@ -0,0 +1,59 @@
import { create } from 'zustand'
import { persist } from 'zustand/middleware'
import { storageKeys } from '@/config/env'
type Theme = 'light' | 'dark' | 'system'
interface ThemeState {
theme: Theme
resolvedTheme: 'light' | 'dark'
setTheme: (theme: Theme) => void
hydrate: () => void
}
const getPreferred = (): 'light' | 'dark' => {
if (typeof window === 'undefined') return 'light'
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
}
const applyThemeClass = (theme: 'light' | 'dark') => {
if (typeof document === 'undefined') return
const root = document.documentElement
root.classList.remove('light', 'dark')
root.classList.add(theme)
}
export const useThemeStore = create<ThemeState>()(
persist(
(set, get) => ({
theme: 'system',
resolvedTheme: 'light',
setTheme: (next) => {
const resolved = next === 'system' ? getPreferred() : next
set({ theme: next, resolvedTheme: resolved })
applyThemeClass(resolved)
if (typeof window !== 'undefined') {
window.localStorage.setItem(storageKeys.theme, next)
}
},
hydrate: () => {
const { theme } = get()
const resolved = theme === 'system' ? getPreferred() : theme
set({ resolvedTheme: resolved })
applyThemeClass(resolved)
},
}),
{
name: storageKeys.theme,
partialize: ({ theme }) => ({ theme }),
onRehydrateStorage: () => (state) => {
if (state) {
const resolved = state.theme === 'system' ? getPreferred() : state.theme
state.resolvedTheme = resolved
requestAnimationFrame(() => applyThemeClass(resolved))
}
},
},
),
)

View File

@ -0,0 +1,73 @@
@import 'tailwindcss';
:root {
color-scheme: light;
--background: 210 40% 98%;
--foreground: 222 47% 11%;
--muted: 214 32% 91%;
--muted-foreground: 215 16% 46%;
--popover: 0 0% 100%;
--popover-foreground: 222 47% 11%;
--card: 0 0% 100%;
--card-foreground: 222 47% 11%;
--border: 214 32% 91%;
--input: 214 32% 91%;
--primary: 217 91% 60%;
--primary-foreground: 210 40% 98%;
--secondary: 214 32% 91%;
--secondary-foreground: 222 47% 11%;
--accent: 221 83% 53%;
--accent-foreground: 210 40% 98%;
--destructive: 0 72% 51%;
--destructive-foreground: 210 40% 98%;
--ring: 221 83% 53%;
--radius: 0.75rem;
}
.dark {
color-scheme: dark;
--background: 222 47% 11%;
--foreground: 213 31% 91%;
--muted: 217 32% 25%;
--muted-foreground: 215 20% 65%;
--popover: 224 47% 7%;
--popover-foreground: 213 31% 91%;
--card: 224 47% 7%;
--card-foreground: 213 31% 91%;
--border: 217 32% 25%;
--input: 217 32% 25%;
--primary: 217 91% 60%;
--primary-foreground: 222 47% 11%;
--secondary: 217 32% 25%;
--secondary-foreground: 213 31% 91%;
--accent: 199 89% 48%;
--accent-foreground: 210 40% 98%;
--destructive: 0 84% 60%;
--destructive-foreground: 210 40% 98%;
--ring: 199 89% 48%;
}
@layer base {
* {
border-color: hsl(var(--border));
}
body {
min-height: 100vh;
background-color: hsl(var(--background));
color: hsl(var(--foreground));
font-family: 'Inter', 'Inter var', system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
-webkit-font-smoothing: antialiased;
}
::selection {
background-color: hsla(var(--primary), 0.2);
color: hsl(var(--primary));
}
}
@layer utilities {
.text-balance {
text-wrap: balance;
}
}

135
frontend/src/types/crm.ts Normal file
View File

@ -0,0 +1,135 @@
export type OrganizationRole = 'owner' | 'admin' | 'manager' | 'member'
export interface Organization {
id: number
name: string
created_at: string
}
export interface OrganizationMembership {
id: number
organization_id: number
user_id: number
role: OrganizationRole
}
export interface User {
id: number
name: string
email: string
created_at: string
}
export interface Contact {
id: number
organization_id: number
owner_id: number
name: string
email?: string | null
phone?: string | null
created_at: string
}
export type DealStatus = 'new' | 'in_progress' | 'won' | 'lost'
export type DealStage = 'qualification' | 'proposal' | 'negotiation' | 'closed'
export interface Deal {
id: number
organization_id: number
contact_id: number
owner_id: number
title: string
amount?: string | null
currency?: string | null
status: DealStatus
stage: DealStage
created_at: string
updated_at: string
}
export interface Task {
id: number
deal_id: number
title: string
description?: string | null
due_date?: string | null
is_done: boolean
created_at: string
}
export type ActivityType = 'comment' | 'status_changed' | 'stage_changed' | 'task_created' | 'system'
export interface ActivityPayload {
text?: string
previous_status?: DealStatus
new_status?: DealStatus
previous_stage?: DealStage
new_stage?: DealStage
[key: string]: unknown
}
export interface Activity {
id: number
deal_id: number
author_id?: number | null
type: ActivityType
payload: ActivityPayload
created_at: string
}
export interface TokenResponse {
access_token: string
refresh_token: string
token_type: string
expires_in: number
refresh_expires_in: number
}
export interface LoginPayload {
email: string
password: string
}
export interface RegisterPayload extends LoginPayload {
name: string
organization_name?: string | null
}
export interface DealSummaryByStatus {
status: DealStatus
count: number
amount_sum: string
}
export interface WonStatisticsSummary {
count: number
amount_sum: string
average_amount: string
}
export interface NewDealsWindowSummary {
days: number
count: number
}
export interface DealSummaryResponse {
by_status: DealSummaryByStatus[]
won: WonStatisticsSummary
new_deals: NewDealsWindowSummary
total_deals: number
}
export interface StageBreakdownItem {
stage: DealStage
total: number
by_status: Record<DealStatus, number>
conversion_to_next: number | null
}
export interface DealFunnelResponse {
stages: StageBreakdownItem[]
}
export type ApiError = {
detail: string | { msg: string } | { [key: string]: unknown }
}

1
frontend/src/vite-env.d.ts vendored Normal file
View File

@ -0,0 +1 @@
/// <reference types="vite/client" />

View File

@ -0,0 +1,84 @@
import type { Config } from 'tailwindcss'
import animatePlugin from 'tailwindcss-animate'
import defaultTheme from 'tailwindcss/defaultTheme'
const config: Config = {
darkMode: ['class', '.dark'],
content: ['./index.html', './src/**/*.{ts,tsx}'],
theme: {
container: {
center: true,
padding: '1.5rem',
screens: {
'2xl': '1400px',
},
},
extend: {
fontFamily: {
sans: ['Inter', ...defaultTheme.fontFamily.sans],
},
colors: {
border: 'hsl(var(--border))',
input: 'hsl(var(--input))',
ring: 'hsl(var(--ring))',
background: 'hsl(var(--background))',
foreground: 'hsl(var(--foreground))',
primary: {
DEFAULT: 'hsl(var(--primary))',
foreground: 'hsl(var(--primary-foreground))',
},
secondary: {
DEFAULT: 'hsl(var(--secondary))',
foreground: 'hsl(var(--secondary-foreground))',
},
destructive: {
DEFAULT: 'hsl(var(--destructive))',
foreground: 'hsl(var(--destructive-foreground))',
},
muted: {
DEFAULT: 'hsl(var(--muted))',
foreground: 'hsl(var(--muted-foreground))',
},
accent: {
DEFAULT: 'hsl(var(--accent))',
foreground: 'hsl(var(--accent-foreground))',
},
popover: {
DEFAULT: 'hsl(var(--popover))',
foreground: 'hsl(var(--popover-foreground))',
},
card: {
DEFAULT: 'hsl(var(--card))',
foreground: 'hsl(var(--card-foreground))',
},
},
borderRadius: {
lg: 'var(--radius)',
md: 'calc(var(--radius) - 2px)',
sm: 'calc(var(--radius) - 4px)',
},
keyframes: {
'accordion-down': {
from: { height: '0' },
to: { height: 'var(--radix-accordion-content-height)' },
},
'accordion-up': {
from: { height: 'var(--radix-accordion-content-height)' },
to: { height: '0' },
},
shimmer: {
'0%': { backgroundPosition: '-468px 0' },
'100%': { backgroundPosition: '468px 0' },
},
},
animation: {
'accordion-down': 'accordion-down 0.2s ease-out',
'accordion-up': 'accordion-up 0.2s ease-out',
shimmer: 'shimmer 1.2s ease-in-out infinite',
},
},
},
plugins: [animatePlugin],
}
export default config

View File

@ -0,0 +1,32 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"types": ["vite/client"],
"skipLibCheck": true,
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
},
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["src"]
}

Some files were not shown because too many files have changed in this diff Show More