Compare commits

..

31 Commits

Author SHA1 Message Date
k1nq 9b31d924f4 Merge pull request 'dev' (#12) from dev into master
Build and deploy / build (push) Successful in 48s Details
Test / test (push) Successful in 15s Details
Build and deploy / deploy (push) Successful in 23s Details
Reviewed-on: #12
2025-12-01 12:03:22 +00:00
Artem Kashaev 236cc4c50c fix: update uvicorn command to use 10 workers for improved performance
Test / test (pull_request) Successful in 15s Details
2025-12-01 17:00:07 +05:00
Artem Kashaev e92d22baa5 fix docker-compose-dev.yml
Test / test (push) Successful in 15s Details
2025-12-01 16:57:12 +05:00
Artem Kashaev eb67223fc0 fix: update test command in README for correct usage of uvicorn 2025-12-01 16:45:21 +05:00
Artem Kashaev 093510c0aa refactor: remove redundant import of TypeEngine for cleaner code 2025-12-01 16:44:23 +05:00
Artem Kashaev 688ade0452 refactor: enhance type hinting and casting for improved type safety across multiple files 2025-12-01 16:44:14 +05:00
Artem Kashaev f234e60e65 refactor: reorganize import statements for consistency across multiple files 2025-12-01 16:35:09 +05:00
Artem Kashaev 754cdb7bd6 fix: update command prefixes for linting and static analysis in README 2025-12-01 16:33:18 +05:00
Artem Kashaev d2c424c419 refactor: reorganize import statements for consistency across multiple files 2025-12-01 16:26:10 +05:00
Artem Kashaev 1dd7c2f2b8 refactor: improve code readability by formatting arguments across multiple files
Test / test (push) Successful in 15s Details
2025-12-01 16:25:30 +05:00
Artem Kashaev dc0046c730 refactor: improve variable naming and add comments for clarity in models and services
Test / test (push) Successful in 15s Details
2025-12-01 16:24:23 +05:00
Artem Kashaev 1039fba571 fix: add missing commas for syntax correctness across multiple files
Test / test (push) Successful in 15s Details
2025-12-01 16:19:19 +05:00
Artem Kashaev ed6c656963 fix: update CORS allowed origins for better security 2025-12-01 16:18:36 +05:00
Artem Kashaev 5fcb574aca Refactor code for improved readability and consistency
Test / test (push) Successful in 15s Details
- Reformatted function signatures in `organization_service.py` and `task_service.py` for better alignment.
- Updated import statements across multiple files for consistency and organization.
- Enhanced test files by improving formatting and ensuring consistent use of async session factories.
- Added type hints and improved type safety in various service and test files.
- Adjusted `pyproject.toml` to include configuration for isort, mypy, and ruff for better code quality checks.
- Cleaned up unused imports and organized existing ones in several test files.
2025-12-01 16:18:03 +05:00
Artem Kashaev eecb74c523 feat: add .env.example file with environment variable configurations 2025-12-01 16:09:58 +05:00
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
170 changed files with 23302 additions and 545 deletions

31
.env.example Normal file
View File

@ -0,0 +1,31 @@
# --- Core metadata --------------------------------------------------------
PROJECT_NAME="Test Task CRM"
VERSION="0.1.0"
API_V1_PREFIX="/api/v1"
# --- Database (used when DATABASE_URL не задан) ---------------------------
DB_HOST="localhost"
DB_PORT="5432"
DB_NAME="test_task_crm"
DB_USER="postgres"
DB_PASSWORD="postgres"
# DATABASE_URL="postgresql+asyncpg://postgres:postgres@localhost:5432/test_task_crm"
# --- SQLAlchemy -----------------------------------------------------------
SQLALCHEMY_ECHO="false"
# --- JWT -----------------------------------------------------------------
JWT_SECRET_KEY="change-me"
JWT_ALGORITHM="HS256"
ACCESS_TOKEN_EXPIRE_MINUTES="30"
REFRESH_TOKEN_EXPIRE_DAYS="7"
# --- Redis cache for analytics -------------------------------------------
REDIS_ENABLED="false"
REDIS_URL="redis://localhost:6379/0"
ANALYTICS_CACHE_TTL_SECONDS="120"
ANALYTICS_CACHE_BACKOFF_MS="200"
# --- Frontend (Vite exposes только VITE_*) --------------------------------
VITE_API_URL="http://localhost:8000"
VITE_APP_URL="http://localhost:5173"

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?

178
README.md
View File

@ -1,40 +1,166 @@
# Test Task CRM
FastAPI backend template that follows the architecture described in the system prompt: async SQLAlchemy ORM, Alembic-ready models, service/repository layers, JWT auth helpers, and Pydantic v2 schemas.
Многопользовательская mini-CRM на FastAPI + PostgreSQL c сервисно-репозиторной архитектурой, JWT-аутентификацией, Alembic-миграциями и готовым React/Vite фронтендом.
## Quick start
## Стек и особенности
1. Create a `.env` file based on variables in `app/core/config.py`.
2. Install dependencies:
```bash
uv sync
```
3. Run the development server:
```bash
uv run python main.py
```
- Python 3.14, FastAPI, SQLAlchemy Async ORM, Alembic.
- Pydantic Settings для конфигурации, JWT access/refresh токены, кеш аналитики в Redis.
- Frontend: Vite + React + TypeScript (см. `frontend/`).
- Докер-окружение для разработки (`docker-compose-dev.yml`) и деплоя (`docker-compose-ci.yml`).
## Project layout
## Структура проекта
```
```text
app/
api/ # FastAPI routers and dependencies
core/ # Settings, database, security helpers
models/ # SQLAlchemy models and Pydantic schemas
repositories/ # Data access layer (SQLAlchemy ORM usage)
services/ # Business logic (auth, users, etc.)
api/ # FastAPI-роуты и зависимости
core/ # Настройки, база, безопасность
models/ # SQLAlchemy-модели
repositories/ # Работа с БД
services/ # Бизнес-правила и сценарии
frontend/ # Vite + React SPA
migrations/ # Alembic-миграции
tests/ # Pytest (unit + интеграции)
```
Add new routers under `app/api/v1`, repositories under `app/repositories`, and keep business rules inside `app/services`.
## Переменные окружения
## Redis analytics cache
1. Скопируйте шаблон: `cp .env.example .env`.
2. Обновите секреты (`DB_PASSWORD`, `JWT_SECRET_KEY`, и т.д.) перед запуском.
3. Все переменные описаны в `app/core/config.py`; Vite читает только ключи с префиксом `VITE_`.
Analytics endpoints can use a Redis cache (TTL 120 seconds). The cache is disabled by default, so the service falls back to the database.
### Backend
1. Start Redis and set the following variables:
| Переменная | Значение по умолчанию | Назначение |
| --- | --- | --- |
| `PROJECT_NAME` | `"Test Task CRM"` | Заголовки/метаданные API |
| `VERSION` | `"0.1.0"` | Версия приложения |
| `API_V1_PREFIX` | `/api/v1` | Базовый префикс REST |
| `DB_HOST` | `localhost` | Хост PostgreSQL |
| `DB_PORT` | `5432` | Порт PostgreSQL |
| `DB_NAME` | `test_task_crm` | Имя БД |
| `DB_USER` | `postgres` | Пользователь БД |
| `DB_PASSWORD` | `postgres` | Пароль пользователя |
| `DATABASE_URL` | — | Полный DSN (перекрывает `DB_*`), формат `postgresql+asyncpg://user:pass@host:port/db` |
| `SQLALCHEMY_ECHO` | `false` | Логирование SQL |
| `JWT_SECRET_KEY` | `change-me` | Секрет для подписи JWT |
| `JWT_ALGORITHM` | `HS256` | Алгоритм JWT |
| `ACCESS_TOKEN_EXPIRE_MINUTES` | `30` | TTL access-токена |
| `REFRESH_TOKEN_EXPIRE_DAYS` | `7` | TTL refresh-токена |
| `REDIS_ENABLED` | `false` | Включить кеш аналитики |
| `REDIS_URL` | `redis://localhost:6379/0` | Строка подключения к Redis |
| `ANALYTICS_CACHE_TTL_SECONDS` | `120` | TTL кэша аналитики |
| `ANALYTICS_CACHE_BACKOFF_MS` | `200` | max задержка при ретраях записи в кеш |
### Frontend (Vite)
| Переменная | Значение по умолчанию | Назначение |
| --- | --- | --- |
| `VITE_API_URL` | `http://localhost:8000``.env.example`), в `src/config/env.ts` дефолт `https://kitchen-crm.k1nq.tech` | Базовый URL бекенда (без завершающего `/`) |
| `VITE_APP_URL` | `http://localhost:5173` | URL SPA, используется для deeplink'ов и редиректов |
⚠️ В `frontend/src/config/env.ts` зашит production URL (`https://kitchen-crm.k1nq.tech`). При деплое в другое место обязательно обновите `VITE_API_URL`/`VITE_APP_URL` в `.env` или настройте переменные окружения на уровне хостинга.
## Локальный запуск без Docker
### Предварительные требования
- Python 3.10+ и [uv](https://github.com/astral-sh/uv).
- PostgreSQL 16+ (локально или через Docker).
- (Опционально) Redis 7+ для кеша аналитики.
### Backend (API)
```bash
# 1. Готовим окружение
cp .env.example .env
# отредактируйте .env: базы, секреты, VITE_* (если нужен фронтенд)
# 2. Устанавливаем зависимости
uv sync
# 3. Применяем миграции
uvx alembic upgrade head
# 4. Запускаем API (10 воркеров без hot-reload) или включаем reload при необходимости
uvx uvicorn app.main:app --host 0.0.0.0 --port 8000 --workers 10
```
PostgreSQL/Redis можно поднять вручную или командой `docker compose -f docker-compose-dev.yml up postgres redis -d`.
### Frontend
```bash
cd frontend
npm install
npm run dev -- --host
```
Фронтенд ожидает, что `VITE_API_URL` указывает на работающий бекенд (например, `http://localhost:8000`).
## Запуск через Docker Compose
### Локальная разработка (`docker-compose-dev.yml`)
- Собирает бекенд из `app/Dockerfile`, поднимает `postgres:16-alpine` и `redis:7-alpine`.
- Порты по умолчанию: API `8000`, Postgres `5432`, Redis `6379`.
- Все переменные берутся из `.env`.
```bash
docker compose -f docker-compose-dev.yml up --build
# или в фоне
docker compose -f docker-compose-dev.yml up --build -d
```
### Прод/CI стек (`docker-compose-ci.yml`)
1. Соберите образы и статику:
```bash
docker build -f app/Dockerfile -t <registry>/test-task-crm:app .
docker build -f migrations/Dockerfile -t <registry>/test-task-crm:migrations .
cd frontend && npm install && npm run build && cd ..
```
2. Залейте `frontend/dist` (используется как read-only volume) и задайте `GIT_HOST`, `GIT_USER`, `GIT_REPO` в `.env`.
3. Запустите стек:
```bash
docker compose -f docker-compose-ci.yml up -d
```
В этом режиме `migrations` прогоняется один раз, `app` слушает порт `80`, Postgres хранит данные на `/mnt/data/postgres` (смонтируйте хостовую директорию заранее).
## Redis для аналитики
Кеш аналитики выключен по умолчанию. Чтобы включить:
1. Поднимите Redis (`docker compose ... redis` или любым другим способом).
2. В `.env` выставьте:
- `REDIS_ENABLED=true`
- `REDIS_URL=redis://localhost:6379/0`
- `ANALYTICS_CACHE_TTL_SECONDS` (optional, defaults to 120)
- `ANALYTICS_CACHE_BACKOFF_MS` (max delay for write/delete retries, defaults to 200)
2. When Redis becomes unavailable, middleware logs the degradation and responses transparently fall back to database queries until connectivity is restored.
- `REDIS_URL=redis://<host>:6379/0`
- (опционально) `ANALYTICS_CACHE_TTL_SECONDS` и `ANALYTICS_CACHE_BACKOFF_MS`.
3. При недоступности Redis сервис автоматически возвращается к прямым запросам в PostgreSQL и пишет предупреждения в лог.
## Тестирование
Все тесты находятся в каталоге `tests/` (unit на бизнес-правила и интеграционные сценарии API). Запуск:
```bash
uv run pytest
```
Полезные варианты:
- Запустить только юнит-тесты сервисов: `uvx pytest tests/services -k service`.
- Запустить конкретный сценарий API: `uvx pytest tests/api/v1/test_deals.py -k won`.
Перед деплоем рекомендуется прогонять миграции на чистой БД и выполнять `uvx pytest` для проверки правил ролей/стадий.
## Линтинг и статический анализ
- `uv run ruff check app tests` — основной линтер (PEP8, сортировка импортов, дополнительные правила).
- `uv run ruff format app tests` — автоформатирование (аналог black) для единообразного стиля.
- `uv run mypy app services tests` — статическая проверка типов (строгий режим + плагин pydantic).
В CI/PR рекомендуется запускать команды именно в этом порядке, чтобы быстрее находить проблемы.

View File

@ -21,4 +21,4 @@ COPY --from=builder /opt/app/.venv /opt/app/.venv
COPY app ./app
EXPOSE 8000
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "10"]

View File

@ -1,9 +1,11 @@
"""Reusable FastAPI dependencies."""
from collections.abc import AsyncGenerator
import jwt
from fastapi import Depends, Header, HTTPException, status
from fastapi.security import OAuth2PasswordBearer
from redis.asyncio.client import Redis
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.cache import get_cache_client
@ -18,9 +20,9 @@ from app.repositories.deal_repo import DealRepository
from app.repositories.org_repo import OrganizationRepository
from app.repositories.task_repo import TaskRepository
from app.repositories.user_repo import UserRepository
from app.services.activity_service import ActivityService
from app.services.analytics_service import AnalyticsService
from app.services.auth_service import AuthService
from app.services.activity_service import ActivityService
from app.services.contact_service import ContactService
from app.services.deal_service import DealService
from app.services.organization_service import (
@ -30,7 +32,6 @@ from app.services.organization_service import (
OrganizationService,
)
from app.services.task_service import TaskService
from redis.asyncio.client import Redis
oauth2_scheme = OAuth2PasswordBearer(tokenUrl=f"{settings.api_v1_prefix}/auth/token")
@ -45,7 +46,9 @@ def get_user_repository(session: AsyncSession = Depends(get_db_session)) -> User
return UserRepository(session=session)
def get_organization_repository(session: AsyncSession = Depends(get_db_session)) -> OrganizationRepository:
def get_organization_repository(
session: AsyncSession = Depends(get_db_session),
) -> OrganizationRepository:
return OrganizationRepository(session=session)
@ -65,7 +68,9 @@ def get_activity_repository(session: AsyncSession = Depends(get_db_session)) ->
return ActivityRepository(session=session)
def get_analytics_repository(session: AsyncSession = Depends(get_db_session)) -> AnalyticsRepository:
def get_analytics_repository(
session: AsyncSession = Depends(get_db_session),
) -> AnalyticsRepository:
return AnalyticsRepository(session=session)

View File

@ -1,4 +1,5 @@
"""Root API router that aggregates versioned routers."""
from fastapi import APIRouter
from app.api.v1 import (

View File

@ -1,4 +1,5 @@
"""Version 1 API routers."""
from . import (
activities,
analytics,

View File

@ -1,4 +1,5 @@
"""Activity timeline endpoints and payload schemas."""
from __future__ import annotations
from typing import Literal

View File

@ -1,4 +1,5 @@
"""Analytics API endpoints for summaries and funnels."""
from __future__ import annotations
from decimal import Decimal
@ -16,6 +17,7 @@ def _decimal_to_str(value: Decimal) -> str:
normalized = value.normalize()
return format(normalized, "f")
router = APIRouter(prefix="/analytics", tags=["analytics"])
@ -92,4 +94,6 @@ async def deals_funnel(
"""Return funnel breakdown by stages and statuses."""
breakdowns: list[StageBreakdown] = await service.get_deal_funnel(context.organization_id)
return DealFunnelResponse(stages=[StageBreakdownModel.model_validate(item) for item in breakdowns])
return DealFunnelResponse(
stages=[StageBreakdownModel.model_validate(item) for item in breakdowns],
)

View File

@ -1,8 +1,9 @@
"""Authentication API endpoints and payloads."""
from __future__ import annotations
from pydantic import BaseModel, EmailStr
from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel, EmailStr
from sqlalchemy import select
from sqlalchemy.exc import IntegrityError
@ -41,7 +42,7 @@ async def register_user(
organization: Organization | None = None
if payload.organization_name:
existing_org = await repo.session.scalar(
select(Organization).where(Organization.name == payload.organization_name)
select(Organization).where(Organization.name == payload.organization_name),
)
if existing_org is not None:
raise HTTPException(

View File

@ -1,4 +1,5 @@
"""Contact API endpoints."""
from __future__ import annotations
from fastapi import APIRouter, Depends, HTTPException, Query, status
@ -81,7 +82,10 @@ async def create_contact(
context: OrganizationContext = Depends(get_organization_context),
service: ContactService = Depends(get_contact_service),
) -> ContactRead:
data = payload.to_domain(organization_id=context.organization_id, fallback_owner=context.user_id)
data = payload.to_domain(
organization_id=context.organization_id,
fallback_owner=context.user_id,
)
try:
contact = await service.create_contact(data, context=context)
except ContactForbiddenError as exc:

View File

@ -1,4 +1,5 @@
"""Deal API endpoints backed by DealService with inline payload schemas."""
from __future__ import annotations
from decimal import Decimal
@ -8,7 +9,7 @@ from pydantic import BaseModel
from app.api.deps import get_deal_repository, get_deal_service, get_organization_context
from app.models.deal import DealCreate, DealRead, DealStage, DealStatus
from app.repositories.deal_repo import DealRepository, DealAccessError, DealQueryParams
from app.repositories.deal_repo import DealAccessError, DealQueryParams, DealRepository
from app.services.deal_service import (
DealService,
DealStageTransitionError,
@ -66,7 +67,10 @@ async def list_deals(
statuses_value = [DealStatus(value) for value in status_filter] if status_filter else None
stage_value = DealStage(stage) if stage else None
except ValueError as exc:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid deal filter") from exc
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Invalid deal filter",
) from exc
params = DealQueryParams(
organization_id=context.organization_id,
@ -96,7 +100,10 @@ async def create_deal(
) -> DealRead:
"""Create a new deal within the current organization."""
data = payload.to_domain(organization_id=context.organization_id, fallback_owner=context.user_id)
data = payload.to_domain(
organization_id=context.organization_id,
fallback_owner=context.user_id,
)
try:
deal = await service.create_deal(data, context=context)
except DealAccessError as exc:

View File

@ -1,4 +1,5 @@
"""Organization-related API endpoints."""
from __future__ import annotations
from fastapi import APIRouter, Depends, HTTPException, status

View File

@ -1,4 +1,5 @@
"""Task API endpoints with inline schemas."""
from __future__ import annotations
from datetime import date, datetime, time, timezone

View File

@ -1,17 +1,18 @@
"""Redis cache utilities and availability tracking."""
from __future__ import annotations
import asyncio
import json
import logging
from typing import Any, Awaitable, Callable, Optional
from collections.abc import Awaitable, Callable
from typing import Any, cast
import redis.asyncio as redis
from app.core.config import settings
from redis.asyncio.client import Redis
from redis.exceptions import RedisError
from app.core.config import settings
logger = logging.getLogger(__name__)
@ -44,7 +45,14 @@ class RedisCacheManager:
async with self._lock:
if self._client is not None:
return
self._client = redis.from_url(settings.redis_url, encoding="utf-8", decode_responses=False)
self._client = cast(
Redis,
redis.from_url( # type: ignore[no-untyped-call]
settings.redis_url,
encoding="utf-8",
decode_responses=False,
),
)
await self._refresh_availability()
async def shutdown(self) -> None:
@ -59,7 +67,14 @@ class RedisCacheManager:
return
async with self._lock:
if self._client is None:
self._client = redis.from_url(settings.redis_url, encoding="utf-8", decode_responses=False)
self._client = cast(
Redis,
redis.from_url( # type: ignore[no-untyped-call]
settings.redis_url,
encoding="utf-8",
decode_responses=False,
),
)
await self._refresh_availability()
async def _refresh_availability(self) -> None:
@ -67,7 +82,7 @@ class RedisCacheManager:
self._available = False
return
try:
await self._client.ping()
await cast(Awaitable[Any], self._client.ping())
except RedisError as exc: # pragma: no cover - logging only
self._available = False
logger.warning("Redis ping failed: %s", exc)
@ -95,7 +110,7 @@ async def shutdown_cache() -> None:
await cache_manager.shutdown()
def get_cache_client() -> Optional[Redis]:
def get_cache_client() -> Redis | None:
"""Expose the active Redis client for dependency injection."""
return cache_manager.get_client()
@ -113,17 +128,26 @@ async def read_json(client: Redis, key: str) -> Any | None:
cache_manager.mark_available()
try:
return json.loads(raw.decode("utf-8"))
except (UnicodeDecodeError, json.JSONDecodeError) as exc: # pragma: no cover - malformed payloads
except (
UnicodeDecodeError,
json.JSONDecodeError,
) as exc: # pragma: no cover - malformed payloads
logger.warning("Discarding malformed cache entry %s: %s", key, exc)
return None
async def write_json(client: Redis, key: str, value: Any, ttl_seconds: int, backoff_ms: int) -> None:
async def write_json(
client: Redis,
key: str,
value: Any,
ttl_seconds: int,
backoff_ms: int,
) -> None:
"""Serialize data to JSON and store it with TTL using retry/backoff."""
payload = json.dumps(value, separators=(",", ":"), ensure_ascii=True).encode("utf-8")
async def _operation() -> Any:
return await client.set(name=key, value=payload, ex=ttl_seconds)
async def _operation() -> None:
await client.set(name=key, value=payload, ex=ttl_seconds)
await _run_with_retry(_operation, backoff_ms)
@ -133,8 +157,8 @@ async def delete_keys(client: Redis, keys: list[str], backoff_ms: int) -> None:
if not keys:
return
async def _operation() -> Any:
return await client.delete(*keys)
async def _operation() -> None:
await client.delete(*keys)
await _run_with_retry(_operation, backoff_ms)

View File

@ -1,4 +1,5 @@
"""Application settings using Pydantic Settings."""
from pydantic import Field, SecretStr
from pydantic_settings import BaseSettings, SettingsConfigDict
@ -15,7 +16,10 @@ class Settings(BaseSettings):
db_port: int = Field(default=5432, description="Database port")
db_name: str = Field(default="test_task_crm", description="Database name")
db_user: str = Field(default="postgres", description="Database user")
db_password: SecretStr = Field(default=SecretStr("postgres"), description="Database user password")
db_password: SecretStr = Field(
default=SecretStr("postgres"),
description="Database user password",
)
database_url_override: str | None = Field(
default=None,
alias="DATABASE_URL",
@ -28,7 +32,11 @@ class Settings(BaseSettings):
refresh_token_expire_days: int = 7
redis_enabled: bool = Field(default=False, description="Toggle Redis-backed cache usage")
redis_url: str = Field(default="redis://localhost:6379/0", description="Redis connection URL")
analytics_cache_ttl_seconds: int = Field(default=120, ge=1, description="TTL for cached analytics responses")
analytics_cache_ttl_seconds: int = Field(
default=120,
ge=1,
description="TTL for cached analytics responses",
)
analytics_cache_backoff_ms: int = Field(
default=200,
ge=0,

View File

@ -1,11 +1,11 @@
"""Database utilities for async SQLAlchemy engine and sessions."""
from __future__ import annotations
from collections.abc import AsyncGenerator
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from app.core.config import settings
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
engine = create_async_engine(settings.database_url, echo=settings.sqlalchemy_echo)
AsyncSessionMaker = async_sessionmaker(bind=engine, expire_on_commit=False)

View File

@ -1,11 +1,12 @@
"""Middleware that logs cache availability transitions."""
from __future__ import annotations
import logging
from starlette.types import ASGIApp, Receive, Scope, Send
from app.core.cache import cache_manager
from app.core.config import settings
from starlette.types import ASGIApp, Receive, Scope, Send
logger = logging.getLogger(__name__)

View File

@ -1,13 +1,14 @@
"""Security helpers for hashing passwords and issuing JWT tokens."""
from __future__ import annotations
from collections.abc import Mapping
from datetime import datetime, timedelta, timezone
from typing import Any, Mapping
from typing import Any, cast
import jwt
from passlib.context import CryptContext # type: ignore
from app.core.config import settings
from passlib.context import CryptContext # type: ignore
class PasswordHasher:
@ -17,10 +18,10 @@ class PasswordHasher:
self._context = CryptContext(schemes=["pbkdf2_sha256"], deprecated="auto")
def hash(self, password: str) -> str:
return self._context.hash(password)
return cast(str, self._context.hash(password))
def verify(self, password: str, hashed_password: str) -> bool:
return self._context.verify(password, hashed_password)
return bool(self._context.verify(password, hashed_password))
class JWTService:
@ -44,10 +45,10 @@ class JWTService:
}
if claims:
payload.update(claims)
return jwt.encode(payload, self._secret_key, algorithm=self._algorithm)
return cast(str, jwt.encode(payload, self._secret_key, algorithm=self._algorithm))
def decode(self, token: str) -> dict[str, Any]:
return jwt.decode(token, self._secret_key, algorithms=[self._algorithm])
return cast(dict[str, Any], jwt.decode(token, self._secret_key, algorithms=[self._algorithm]))
password_hasher = PasswordHasher()

View File

@ -1,21 +1,29 @@
"""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.middleware.cors import CORSMiddleware
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
from app.core.config import settings
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,39 @@ 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",
# "*",
],
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

@ -1,4 +1,5 @@
"""Model exports for Alembic discovery."""
from app.models.activity import Activity, ActivityType
from app.models.base import Base
from app.models.contact import Contact

View File

@ -1,4 +1,5 @@
"""Activity timeline ORM model and schemas."""
from __future__ import annotations
from datetime import datetime
@ -6,10 +7,14 @@ from enum import StrEnum
from typing import Any
from pydantic import BaseModel, ConfigDict, Field
from sqlalchemy import DateTime, Enum as SqlEnum, ForeignKey, Integer, func, text
from sqlalchemy import DateTime, ForeignKey, Integer, func, text
from sqlalchemy import Enum as SqlEnum
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.types import JSON as GenericJSON, TypeDecorator
from sqlalchemy.engine import Dialect
from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy.sql.type_api import TypeEngine
from sqlalchemy.types import JSON as SA_JSON
from sqlalchemy.types import TypeDecorator
from app.models.base import Base, enum_values
@ -28,11 +33,11 @@ class JSONBCompat(TypeDecorator):
impl = JSONB
cache_ok = True
def load_dialect_impl(self, dialect): # type: ignore[override]
def load_dialect_impl(self, dialect: Dialect) -> TypeEngine[Any]:
if dialect.name == "sqlite":
from sqlalchemy.dialects.sqlite import JSON as SQLiteJSON # local import
from sqlalchemy.dialects.sqlite import JSON as SQLITE_JSON # local import
return dialect.type_descriptor(SQLiteJSON())
return dialect.type_descriptor(SQLITE_JSON())
return dialect.type_descriptor(JSONB())
@ -44,18 +49,22 @@ class Activity(Base):
id: Mapped[int] = mapped_column(Integer, primary_key=True)
deal_id: Mapped[int] = mapped_column(ForeignKey("deals.id", ondelete="CASCADE"))
author_id: Mapped[int | None] = mapped_column(
ForeignKey("users.id", ondelete="SET NULL"), nullable=True
ForeignKey("users.id", ondelete="SET NULL"),
nullable=True,
)
type: Mapped[ActivityType] = mapped_column(
SqlEnum(ActivityType, name="activity_type", values_callable=enum_values), nullable=False
SqlEnum(ActivityType, name="activity_type", values_callable=enum_values),
nullable=False,
)
payload: Mapped[dict[str, Any]] = mapped_column(
JSONBCompat().with_variant(GenericJSON(), "sqlite"),
JSONBCompat().with_variant(SA_JSON(), "sqlite"),
nullable=False,
server_default=text("'{}'"),
)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), nullable=False
DateTime(timezone=True),
server_default=func.now(),
nullable=False,
)
deal = relationship("Deal", back_populates="activities")

View File

@ -1,4 +1,5 @@
"""Declarative base for SQLAlchemy models."""
from __future__ import annotations
from enum import StrEnum
@ -13,7 +14,7 @@ class Base(DeclarativeBase):
"""Base class that configures naming conventions."""
@declared_attr.directive
def __tablename__(cls) -> str: # type: ignore[misc]
def __tablename__(cls) -> str: # noqa: N805 - SQLAlchemy expects cls
return cls.__name__.lower()

View File

@ -1,4 +1,5 @@
"""Contact ORM model and schemas."""
from __future__ import annotations
from datetime import datetime
@ -22,7 +23,9 @@ class Contact(Base):
email: Mapped[str | None] = mapped_column(String(320), nullable=True)
phone: Mapped[str | None] = mapped_column(String(64), nullable=True)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), nullable=False
DateTime(timezone=True),
server_default=func.now(),
nullable=False,
)
organization = relationship("Organization", back_populates="contacts")

View File

@ -1,4 +1,5 @@
"""Deal ORM model and schemas."""
from __future__ import annotations
from datetime import datetime
@ -6,7 +7,8 @@ from decimal import Decimal
from enum import StrEnum
from pydantic import BaseModel, ConfigDict
from sqlalchemy import DateTime, Enum as SqlEnum, ForeignKey, Integer, Numeric, String, func
from sqlalchemy import DateTime, ForeignKey, Integer, Numeric, String, func
from sqlalchemy import Enum as SqlEnum
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.models.base import Base, enum_values
@ -49,10 +51,15 @@ class Deal(Base):
default=DealStage.QUALIFICATION,
)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), nullable=False
DateTime(timezone=True),
server_default=func.now(),
nullable=False,
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False
DateTime(timezone=True),
server_default=func.now(),
onupdate=func.now(),
nullable=False,
)
organization = relationship("Organization", back_populates="deals")

View File

@ -1,4 +1,5 @@
"""Organization ORM model and schemas."""
from __future__ import annotations
from datetime import datetime
@ -18,7 +19,9 @@ class Organization(Base):
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
name: Mapped[str] = mapped_column(String(255), nullable=False, unique=True)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), nullable=False
DateTime(timezone=True),
server_default=func.now(),
nullable=False,
)
members = relationship(

View File

@ -1,11 +1,13 @@
"""Organization member ORM model."""
from __future__ import annotations
from datetime import datetime
from enum import StrEnum
from pydantic import BaseModel, ConfigDict
from sqlalchemy import DateTime, Enum as SqlEnum, ForeignKey, Integer, UniqueConstraint, func
from sqlalchemy import DateTime, ForeignKey, Integer, UniqueConstraint, func
from sqlalchemy import Enum as SqlEnum
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.models.base import Base, enum_values
@ -39,7 +41,9 @@ class OrganizationMember(Base):
default=OrganizationRole.MEMBER,
)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), nullable=False
DateTime(timezone=True),
server_default=func.now(),
nullable=False,
)
organization = relationship("Organization", back_populates="members")

View File

@ -1,4 +1,5 @@
"""Task ORM model and schemas."""
from __future__ import annotations
from datetime import datetime
@ -22,7 +23,9 @@ class Task(Base):
due_date: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
is_done: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), nullable=False
DateTime(timezone=True),
server_default=func.now(),
nullable=False,
)
deal = relationship("Deal", back_populates="tasks")

View File

@ -1,4 +1,5 @@
"""Token-related Pydantic schemas."""
from __future__ import annotations
from datetime import datetime
@ -15,7 +16,7 @@ class TokenPayload(BaseModel):
class TokenResponse(BaseModel):
access_token: str
refresh_token: str
token_type: str = "bearer"
token_type: str = "bearer" # noqa: S105 -- OAuth2 spec default value
expires_in: int
refresh_expires_in: int

View File

@ -1,4 +1,5 @@
"""User ORM model and Pydantic schemas."""
from __future__ import annotations
from datetime import datetime
@ -25,13 +26,22 @@ class User(Base):
name: Mapped[str] = mapped_column(String(255), nullable=False)
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), nullable=False
DateTime(timezone=True),
server_default=func.now(),
nullable=False,
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False
DateTime(timezone=True),
server_default=func.now(),
onupdate=func.now(),
nullable=False,
)
memberships = relationship("OrganizationMember", back_populates="user", cascade="all, delete-orphan")
memberships = relationship(
"OrganizationMember",
back_populates="user",
cascade="all, delete-orphan",
)
owned_contacts = relationship("Contact", back_populates="owner")
owned_deals = relationship("Deal", back_populates="owner")
activities = relationship("Activity", back_populates="author")

View File

@ -1,4 +1,5 @@
"""Repository helpers for deal activities."""
from __future__ import annotations
from collections.abc import Sequence
@ -39,7 +40,10 @@ class ActivityRepository:
stmt = (
select(Activity)
.join(Deal, Deal.id == Activity.deal_id)
.where(Activity.deal_id == params.deal_id, Deal.organization_id == params.organization_id)
.where(
Activity.deal_id == params.deal_id,
Deal.organization_id == params.organization_id,
)
.order_by(Activity.created_at)
)
stmt = self._apply_window(stmt, params)

View File

@ -1,4 +1,5 @@
"""Analytics-specific data access helpers."""
from __future__ import annotations
from dataclasses import dataclass
@ -58,7 +59,7 @@ class AnalyticsRepository:
deal_count=int(count or 0),
amount_sum=_to_decimal(amount_sum),
amount_count=int(amount_count or 0),
)
),
)
return rollup

View File

@ -1,4 +1,5 @@
"""Repository helpers for contacts with role-aware access."""
from __future__ import annotations
from collections.abc import Mapping, Sequence
@ -44,7 +45,9 @@ class ContactRepository:
role: OrganizationRole,
user_id: int,
) -> Sequence[Contact]:
stmt: Select[tuple[Contact]] = select(Contact).where(Contact.organization_id == params.organization_id)
stmt: Select[tuple[Contact]] = select(Contact).where(
Contact.organization_id == params.organization_id,
)
stmt = self._apply_filters(stmt, params, role, user_id)
offset = (max(params.page, 1) - 1) * params.page_size
stmt = stmt.order_by(Contact.created_at.desc()).offset(offset).limit(params.page_size)
@ -59,7 +62,10 @@ class ContactRepository:
role: OrganizationRole,
user_id: int,
) -> Contact | None:
stmt = select(Contact).where(Contact.id == contact_id, Contact.organization_id == organization_id)
stmt = select(Contact).where(
Contact.id == contact_id,
Contact.organization_id == organization_id,
)
result = await self._session.scalars(stmt)
return result.first()
@ -117,7 +123,7 @@ class ContactRepository:
pattern = f"%{params.search.lower()}%"
stmt = stmt.where(
func.lower(Contact.name).like(pattern)
| func.lower(func.coalesce(Contact.email, "")).like(pattern)
| func.lower(func.coalesce(Contact.email, "")).like(pattern),
)
if params.owner_id is not None:
if role == OrganizationRole.MEMBER:

View File

@ -1,4 +1,5 @@
"""Deal repository with access-aware CRUD helpers."""
from __future__ import annotations
from collections.abc import Mapping, Sequence
@ -12,7 +13,6 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.models.deal import Deal, DealCreate, DealStage, DealStatus
from app.models.organization_member import OrganizationRole
ORDERABLE_COLUMNS: dict[str, Any] = {
"created_at": Deal.created_at,
"amount": Deal.amount,
@ -147,7 +147,11 @@ class DealRepository:
return stmt.where(Deal.owner_id == user_id)
return stmt
def _apply_ordering(self, stmt: Select[tuple[Deal]], params: DealQueryParams) -> Select[tuple[Deal]]:
def _apply_ordering(
self,
stmt: Select[tuple[Deal]],
params: DealQueryParams,
) -> Select[tuple[Deal]]:
column = ORDERABLE_COLUMNS.get(params.order_by or "created_at", Deal.created_at)
order_func = desc if params.order_desc else asc
return stmt.order_by(order_func(column))

View File

@ -1,11 +1,12 @@
"""Organization repository for database operations."""
from __future__ import annotations
from collections.abc import Sequence
from sqlalchemy import select
from sqlalchemy.orm import selectinload
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.models.organization import Organization, OrganizationCreate
from app.models.organization_member import OrganizationMember

View File

@ -1,10 +1,11 @@
"""Task repository providing role-aware CRUD helpers."""
from __future__ import annotations
from collections.abc import Mapping, Sequence
from dataclasses import dataclass
from datetime import datetime
from typing import Any
from typing import Any, cast
from sqlalchemy import Select, select
from sqlalchemy.ext.asyncio import AsyncSession
@ -105,7 +106,11 @@ class TaskRepository:
await self._session.flush()
return task
def _apply_filters(self, stmt: Select[tuple[Task]], params: TaskQueryParams) -> Select[tuple[Task]]:
def _apply_filters(
self,
stmt: Select[tuple[Task]],
params: TaskQueryParams,
) -> Select[tuple[Task]]:
if params.deal_id is not None:
stmt = stmt.where(Task.deal_id == params.deal_id)
if params.only_open:
@ -118,6 +123,9 @@ class TaskRepository:
async def _resolve_task_owner(self, task: Task) -> int | None:
if task.deal is not None:
return task.deal.owner_id
return int(task.deal.owner_id)
stmt = select(Deal.owner_id).where(Deal.id == task.deal_id)
return await self._session.scalar(stmt)
owner_id_raw: Any = await self._session.scalar(stmt)
if owner_id_raw is None:
return None
return cast(int, owner_id_raw)

View File

@ -1,4 +1,5 @@
"""User repository handling database operations."""
from __future__ import annotations
from collections.abc import Sequence

View File

@ -1,4 +1,5 @@
"""Business logic services."""
from .activity_service import ( # noqa: F401
ActivityForbiddenError,
ActivityListFilters,

View File

@ -1,4 +1,5 @@
"""Business logic for timeline activities."""
from __future__ import annotations
from collections.abc import Sequence

View File

@ -1,11 +1,13 @@
"""Analytics-related business logic."""
from __future__ import annotations
import logging
from collections.abc import Iterable
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
from decimal import Decimal, InvalidOperation
from typing import Any, Iterable
from typing import Any
from redis.asyncio.client import Redis
from redis.exceptions import RedisError
@ -105,9 +107,7 @@ class AnalyticsService:
won_amount_count = row.amount_count
won_count = row.deal_count
won_average = (
(won_amount_sum / won_amount_count) if won_amount_count > 0 else Decimal("0")
)
won_average = (won_amount_sum / won_amount_count) if won_amount_count > 0 else Decimal("0")
window_threshold = _threshold_from_days(days)
new_deals = await self._repository.count_new_deals_since(organization_id, window_threshold)
@ -137,7 +137,7 @@ class AnalyticsService:
breakdowns: list[StageBreakdown] = []
totals = {stage: sum(by_status.values()) for stage, by_status in stage_map.items()}
for index, stage in enumerate(_STAGE_ORDER):
by_status = stage_map.get(stage, {status: 0 for status in DealStatus})
by_status = stage_map.get(stage, dict.fromkeys(DealStatus, 0))
total = totals.get(stage, 0)
conversion = None
if index < len(_STAGE_ORDER) - 1:
@ -151,7 +151,7 @@ class AnalyticsService:
total=total,
by_status=by_status,
conversion_to_next=conversion,
)
),
)
await self._store_funnel_cache(organization_id, breakdowns)
return breakdowns
@ -168,7 +168,12 @@ class AnalyticsService:
return None
return _deserialize_summary(payload)
async def _store_summary_cache(self, organization_id: int, days: int, summary: DealSummary) -> None:
async def _store_summary_cache(
self,
organization_id: int,
days: int,
summary: DealSummary,
) -> None:
if not self._is_cache_enabled() or self._cache is None:
return
key = _summary_cache_key(organization_id, days)
@ -184,7 +189,11 @@ class AnalyticsService:
return None
return _deserialize_funnel(payload)
async def _store_funnel_cache(self, organization_id: int, breakdowns: list[StageBreakdown]) -> None:
async def _store_funnel_cache(
self,
organization_id: int,
breakdowns: list[StageBreakdown],
) -> None:
if not self._is_cache_enabled() or self._cache is None:
return
key = _funnel_cache_key(organization_id)
@ -198,11 +207,10 @@ def _threshold_from_days(days: int) -> datetime:
def _build_stage_map(rollup: Iterable[StageStatusRollup]) -> dict[DealStage, dict[DealStatus, int]]:
stage_map: dict[DealStage, dict[DealStatus, int]] = {
stage: {status: 0 for status in DealStatus}
for stage in _STAGE_ORDER
stage: dict.fromkeys(DealStatus, 0) for stage in _STAGE_ORDER
}
for item in rollup:
stage_map.setdefault(item.stage, {status: 0 for status in DealStatus})
stage_map.setdefault(item.stage, dict.fromkeys(DealStatus, 0))
stage_map[item.stage][item.status] = item.deal_count
return stage_map
@ -263,7 +271,7 @@ def _deserialize_summary(payload: Any) -> DealSummary | None:
status=DealStatus(item["status"]),
count=int(item["count"]),
amount_sum=Decimal(item["amount_sum"]),
)
),
)
won = WonStatistics(
count=int(won_payload["count"]),
@ -289,7 +297,7 @@ def _serialize_funnel(breakdowns: list[StageBreakdown]) -> list[dict[str, Any]]:
"total": item.total,
"by_status": {status.value: count for status, count in item.by_status.items()},
"conversion_to_next": item.conversion_to_next,
}
},
)
return serialized
@ -307,15 +315,21 @@ def _deserialize_funnel(payload: Any) -> list[StageBreakdown] | None:
stage=DealStage(item["stage"]),
total=int(item["total"]),
by_status=by_status,
conversion_to_next=float(item["conversion_to_next"]) if item["conversion_to_next"] is not None else None,
)
conversion_to_next=float(item["conversion_to_next"])
if item["conversion_to_next"] is not None
else None,
),
)
except (KeyError, TypeError, ValueError):
return None
return breakdowns
async def invalidate_analytics_cache(cache: Redis | None, organization_id: int, backoff_ms: int) -> None:
async def invalidate_analytics_cache(
cache: Redis | None,
organization_id: int,
backoff_ms: int,
) -> None:
"""Remove cached analytics payloads for the organization."""
if cache is None:

View File

@ -1,4 +1,5 @@
"""Authentication workflows."""
from __future__ import annotations
from datetime import timedelta

View File

@ -1,8 +1,10 @@
"""Business logic for contact workflows."""
from __future__ import annotations
from collections.abc import Sequence
from dataclasses import dataclass
from typing import cast
from sqlalchemy import select
@ -78,7 +80,11 @@ class ContactService:
owner_id=filters.owner_id,
)
try:
return await self._repository.list(params=params, role=context.role, user_id=context.user_id)
return await self._repository.list(
params=params,
role=context.role,
user_id=context.user_id,
)
except ContactAccessError as exc:
raise ContactForbiddenError(str(exc)) from exc
@ -122,7 +128,12 @@ class ContactService:
if not payload:
return contact
try:
return await self._repository.update(contact, payload, role=context.role, user_id=context.user_id)
return await self._repository.update(
contact,
payload,
role=context.role,
user_id=context.user_id,
)
except ContactAccessError as exc:
raise ContactForbiddenError(str(exc)) from exc
@ -141,11 +152,11 @@ class ContactService:
def _build_update_mapping(self, updates: ContactUpdateData) -> dict[str, str | None]:
payload: dict[str, str | None] = {}
if updates.name is not UNSET:
payload["name"] = updates.name
payload["name"] = cast(str | None, updates.name)
if updates.email is not UNSET:
payload["email"] = updates.email
payload["email"] = cast(str | None, updates.email)
if updates.phone is not UNSET:
payload["phone"] = updates.phone
payload["phone"] = cast(str | None, updates.phone)
return payload
async def _ensure_no_related_deals(self, contact_id: int) -> None:

View File

@ -1,4 +1,5 @@
"""Business logic for deals."""
from __future__ import annotations
from collections.abc import Iterable
@ -16,7 +17,6 @@ from app.repositories.deal_repo import DealRepository
from app.services.analytics_service import invalidate_analytics_cache
from app.services.organization_service import OrganizationContext
STAGE_ORDER = {
stage: index
for index, stage in enumerate(
@ -25,7 +25,7 @@ STAGE_ORDER = {
DealStage.PROPOSAL,
DealStage.NEGOTIATION,
DealStage.CLOSED,
]
],
)
}
@ -78,7 +78,11 @@ class DealService:
self._ensure_same_organization(data.organization_id, context)
await self._ensure_contact_in_organization(data.contact_id, context.organization_id)
deal = await self._repository.create(data=data, role=context.role, user_id=context.user_id)
await invalidate_analytics_cache(self._cache, context.organization_id, self._cache_backoff_ms)
await invalidate_analytics_cache(
self._cache,
context.organization_id,
self._cache_backoff_ms,
)
return deal
async def update_deal(
@ -117,13 +121,22 @@ class DealService:
if not changes:
return deal
updated = await self._repository.update(deal, changes, role=context.role, user_id=context.user_id)
updated = await self._repository.update(
deal,
changes,
role=context.role,
user_id=context.user_id,
)
await self._log_activities(
deal_id=deal.id,
author_id=context.user_id,
activities=[activity for activity in [stage_activity, status_activity] if activity],
)
await invalidate_analytics_cache(self._cache, context.organization_id, self._cache_backoff_ms)
await invalidate_analytics_cache(
self._cache,
context.organization_id,
self._cache_backoff_ms,
)
return updated
async def ensure_contact_can_be_deleted(self, contact_id: int) -> None:
@ -143,7 +156,12 @@ class DealService:
if not entries:
return
for activity_type, payload in entries:
activity = Activity(deal_id=deal_id, author_id=author_id, type=activity_type, payload=payload)
activity = Activity(
deal_id=deal_id,
author_id=author_id,
type=activity_type,
payload=payload,
)
self._repository.session.add(activity)
await self._repository.session.flush()
@ -151,7 +169,11 @@ class DealService:
if organization_id != context.organization_id:
raise DealOrganizationMismatchError("Operation targets a different organization")
async def _ensure_contact_in_organization(self, contact_id: int, organization_id: int) -> Contact:
async def _ensure_contact_in_organization(
self,
contact_id: int,
organization_id: int,
) -> Contact:
contact = await self._repository.session.get(Contact, contact_id)
if contact is None or contact.organization_id != organization_id:
raise DealOrganizationMismatchError("Contact belongs to another organization")
@ -174,4 +196,6 @@ class DealService:
return
effective_amount = updates.amount if updates.amount is not None else deal.amount
if effective_amount is None or Decimal(effective_amount) <= Decimal("0"):
raise DealStatusValidationError("Amount must be greater than zero to mark a deal as won")
raise DealStatusValidationError(
"Amount must be greater than zero to mark a deal as won",
)

View File

@ -1,4 +1,5 @@
"""Organization-related business rules."""
from __future__ import annotations
from dataclasses import dataclass
@ -54,7 +55,12 @@ class OrganizationService:
def __init__(self, repository: OrganizationRepository) -> None:
self._repository = repository
async def get_context(self, *, user_id: int, organization_id: int | None) -> OrganizationContext:
async def get_context(
self,
*,
user_id: int,
organization_id: int | None,
) -> OrganizationContext:
"""Resolve request context ensuring the user belongs to the given organization."""
if organization_id is None:
@ -66,7 +72,12 @@ class OrganizationService:
return OrganizationContext(organization=membership.organization, membership=membership)
def ensure_entity_in_context(self, *, entity_organization_id: int, context: OrganizationContext) -> None:
def ensure_entity_in_context(
self,
*,
entity_organization_id: int,
context: OrganizationContext,
) -> None:
"""Make sure a resource belongs to the current organization."""
if entity_organization_id != context.organization_id:

View File

@ -1,4 +1,5 @@
"""Business logic for tasks linked to deals."""
from __future__ import annotations
from collections.abc import Mapping, Sequence
@ -9,10 +10,14 @@ from typing import Any
from app.models.activity import ActivityCreate, ActivityType
from app.models.organization_member import OrganizationRole
from app.models.task import Task, TaskCreate
from app.repositories.activity_repo import ActivityRepository, ActivityOrganizationMismatchError
from app.repositories.activity_repo import ActivityOrganizationMismatchError, ActivityRepository
from app.repositories.task_repo import (
TaskAccessError as RepoTaskAccessError,
)
from app.repositories.task_repo import (
TaskOrganizationMismatchError as RepoTaskOrganizationMismatchError,
)
from app.repositories.task_repo import (
TaskQueryParams,
TaskRepository,
)
@ -182,5 +187,5 @@ class TaskService:
)
try:
await self._activity_repository.create(data, organization_id=context.organization_id)
except ActivityOrganizationMismatchError: # pragma: no cover - defensive
raise TaskOrganizationError("Activity target does not belong to organization")
except ActivityOrganizationMismatchError as exc: # pragma: no cover - defensive
raise TaskOrganizationError("Activity target does not belong to organization") from exc

View File

@ -2,7 +2,7 @@ services:
app:
image: ${GIT_HOST}/${GIT_USER}/${GIT_REPO}:app
restart: unless-stopped
command: uvicorn app.main:app --host 0.0.0.0 --port 8000
command: uvicorn app.main:app --host 0.0.0.0 --port 8000 --workers 10
env_file:
- .env
environment:
@ -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

@ -3,14 +3,14 @@ services:
build:
context: .
dockerfile: app/Dockerfile
command: uvicorn app.main:app --host 0.0.0.0 --port 8000
command: uvicorn app.main:app --host 0.0.0.0 --port 8000 --workers 10
env_file:
- .env
environment:
PROJECT_NAME: ${PROJECT_NAME}
VERSION: ${VERSION}
API_V1_PREFIX: ${API_V1_PREFIX}
DB_HOST: ${DB_HOST:-postgres}
DB_HOST: postgres
DB_PORT: ${DB_PORT}
DB_NAME: ${DB_NAME}
DB_USER: ${DB_USER}

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 }

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