Compare commits

..

No commits in common. "master" and "v0.0.1" have entirely different histories.

170 changed files with 545 additions and 23302 deletions

View File

@ -1,31 +0,0 @@
# --- 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,39 +13,9 @@ jobs:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 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 - name: Login to registry
run: echo "${{ secrets.TOKEN }}" | docker login ${{ secrets.GIT_HOST }} -u ${{ secrets.USERNAME }} --password-stdin 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 - name: Build and push app
run: | run: |
docker build -t ${{ secrets.GIT_HOST }}/${{ gitea.repository }}:app -f app/Dockerfile . docker build -t ${{ secrets.GIT_HOST }}/${{ gitea.repository }}:app -f app/Dockerfile .
@ -74,19 +44,6 @@ jobs:
- name: Create remote deployment directory - name: Create remote deployment directory
run: ssh ${{ secrets.LXC_USER }}@${{ secrets.LXC_HOST }} "mkdir -p /srv/app" 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 - name: Deploy docker-compose-ci.yml
run: scp docker-compose-ci.yml ${{ secrets.LXC_USER }}@${{ secrets.LXC_HOST }}:/srv/app/docker-compose.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/ downloads/
eggs/ eggs/
.eggs/ .eggs/
# lib/ lib/
lib64/ lib64/
parts/ parts/
sdist/ sdist/
@ -161,30 +161,3 @@ cython_debug/
#.idea/ #.idea/
task.instructions.md 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?

170
README.md
View File

@ -1,166 +1,40 @@
# Test Task CRM # Test Task CRM
Многопользовательская mini-CRM на FastAPI + PostgreSQL c сервисно-репозиторной архитектурой, JWT-аутентификацией, Alembic-миграциями и готовым React/Vite фронтендом. 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.
## Стек и особенности ## Quick start
- 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`).
## Структура проекта
```text
app/
api/ # FastAPI-роуты и зависимости
core/ # Настройки, база, безопасность
models/ # SQLAlchemy-модели
repositories/ # Работа с БД
services/ # Бизнес-правила и сценарии
frontend/ # Vite + React SPA
migrations/ # Alembic-миграции
tests/ # Pytest (unit + интеграции)
```
## Переменные окружения
1. Скопируйте шаблон: `cp .env.example .env`.
2. Обновите секреты (`DB_PASSWORD`, `JWT_SECRET_KEY`, и т.д.) перед запуском.
3. Все переменные описаны в `app/core/config.py`; Vite читает только ключи с префиксом `VITE_`.
### Backend
| Переменная | Значение по умолчанию | Назначение |
| --- | --- | --- |
| `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)
1. Create a `.env` file based on variables in `app/core/config.py`.
2. Install dependencies:
```bash ```bash
# 1. Готовим окружение
cp .env.example .env
# отредактируйте .env: базы, секреты, VITE_* (если нужен фронтенд)
# 2. Устанавливаем зависимости
uv sync 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
``` ```
3. Run the development server:
PostgreSQL/Redis можно поднять вручную или командой `docker compose -f docker-compose-dev.yml up postgres redis -d`.
### Frontend
```bash ```bash
cd frontend uv run python main.py
npm install
npm run dev -- --host
``` ```
Фронтенд ожидает, что `VITE_API_URL` указывает на работающий бекенд (например, `http://localhost:8000`). ## Project layout
## Запуск через Docker Compose ```
app/
### Локальная разработка (`docker-compose-dev.yml`) api/ # FastAPI routers and dependencies
core/ # Settings, database, security helpers
- Собирает бекенд из `app/Dockerfile`, поднимает `postgres:16-alpine` и `redis:7-alpine`. models/ # SQLAlchemy models and Pydantic schemas
- Порты по умолчанию: API `8000`, Postgres `5432`, Redis `6379`. repositories/ # Data access layer (SQLAlchemy ORM usage)
- Все переменные берутся из `.env`. services/ # Business logic (auth, users, etc.)
```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`) Add new routers under `app/api/v1`, repositories under `app/repositories`, and keep business rules inside `app/services`.
1. Соберите образы и статику: ## Redis analytics cache
```bash Analytics endpoints can use a Redis cache (TTL 120 seconds). The cache is disabled by default, so the service falls back to the database.
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`. 1. Start Redis and set the following variables:
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_ENABLED=true`
- `REDIS_URL=redis://<host>:6379/0` - `REDIS_URL=redis://localhost:6379/0`
- (опционально) `ANALYTICS_CACHE_TTL_SECONDS` и `ANALYTICS_CACHE_BACKOFF_MS`. - `ANALYTICS_CACHE_TTL_SECONDS` (optional, defaults to 120)
3. При недоступности Redis сервис автоматически возвращается к прямым запросам в PostgreSQL и пишет предупреждения в лог. - `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.
## Тестирование
Все тесты находятся в каталоге `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 COPY app ./app
EXPOSE 8000 EXPOSE 8000
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "10"] CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,29 +1,21 @@
"""FastAPI application factory.""" """FastAPI application factory."""
from __future__ import annotations from __future__ import annotations
from collections.abc import AsyncIterator from collections.abc import AsyncIterator
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from pathlib import Path
from fastapi import FastAPI, HTTPException from fastapi import FastAPI
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.api.routes import api_router
from app.core.cache import init_cache, shutdown_cache from app.core.cache import init_cache, shutdown_cache
from app.core.config import settings from app.core.config import settings
from app.core.middleware.cache_monitor import CacheAvailabilityMiddleware 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: def create_app() -> FastAPI:
"""Build FastAPI application instance.""" """Build FastAPI application instance."""
@asynccontextmanager @asynccontextmanager
async def lifespan(_: FastAPI) -> AsyncIterator[None]: async def lifespan(_: FastAPI) -> AsyncIterator[None]:
await init_cache() await init_cache()
@ -37,39 +29,11 @@ def create_app() -> FastAPI:
application.add_middleware(CacheAvailabilityMiddleware) application.add_middleware(CacheAvailabilityMiddleware)
application.add_middleware( application.add_middleware(
CORSMiddleware, CORSMiddleware,
allow_origins=[ allow_origins=["https://kitchen-crm.k1nq.tech", "http://192.168.31.51"],
"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_credentials=True,
allow_methods=["*"], # Разрешить все HTTP-методы allow_methods=["*"], # Разрешить все HTTP-методы
allow_headers=["*"], # Разрешить все заголовки 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 return application

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -2,7 +2,7 @@ services:
app: app:
image: ${GIT_HOST}/${GIT_USER}/${GIT_REPO}:app image: ${GIT_HOST}/${GIT_USER}/${GIT_REPO}:app
restart: unless-stopped restart: unless-stopped
command: uvicorn app.main:app --host 0.0.0.0 --port 8000 --workers 10 command: uvicorn app.main:app --host 0.0.0.0 --port 8000
env_file: env_file:
- .env - .env
environment: environment:
@ -25,8 +25,6 @@ services:
ANALYTICS_CACHE_BACKOFF_MS: ${ANALYTICS_CACHE_BACKOFF_MS} ANALYTICS_CACHE_BACKOFF_MS: ${ANALYTICS_CACHE_BACKOFF_MS}
ports: ports:
- "80:8000" - "80:8000"
volumes:
- ./frontend/dist:/opt/app/frontend/dist:ro
healthcheck: healthcheck:
test: ["CMD", "wget", "-qO-", "http://localhost:8000/health"] test: ["CMD", "wget", "-qO-", "http://localhost:8000/health"]
interval: 30s interval: 30s

View File

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

View File

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

View File

@ -1,73 +0,0 @@
# 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

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

View File

@ -1,105 +0,0 @@
/**
* 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

View File

@ -1,58 +0,0 @@
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',
},
},
])

View File

@ -1,33 +0,0 @@
<!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

File diff suppressed because it is too large Load Diff

View File

@ -1,85 +0,0 @@
{
"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

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

View File

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

View File

@ -1,28 +0,0 @@
{
"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.

Before

Width:  |  Height:  |  Size: 329 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 355 B

View File

@ -1,15 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 666 B

View File

@ -1 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 1.5 KiB

View File

@ -1,42 +0,0 @@
#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;
}

View File

@ -1,16 +0,0 @@
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

@ -1,22 +0,0 @@
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

@ -1 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 4.0 KiB

View File

@ -1,19 +0,0 @@
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

@ -1,22 +0,0 @@
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

@ -1,12 +0,0 @@
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

@ -1,39 +0,0 @@
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

@ -1,84 +0,0 @@
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

@ -1,18 +0,0 @@
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

@ -1,26 +0,0 @@
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

@ -1,56 +0,0 @@
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

@ -1,75 +0,0 @@
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

@ -1,8 +0,0 @@
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

@ -1,17 +0,0 @@
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

@ -1,14 +0,0 @@
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

@ -1,31 +0,0 @@
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

@ -1,32 +0,0 @@
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

@ -1,48 +0,0 @@
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

@ -1,47 +0,0 @@
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

@ -1,178 +0,0 @@
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

@ -1,108 +0,0 @@
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

@ -1,22 +0,0 @@
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

@ -1,18 +0,0 @@
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

@ -1,100 +0,0 @@
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

@ -1,20 +0,0 @@
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

@ -1,78 +0,0 @@
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

@ -1,9 +0,0 @@
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

@ -1,27 +0,0 @@
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

@ -1,51 +0,0 @@
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

@ -1,43 +0,0 @@
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

@ -1,23 +0,0 @@
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

@ -1,97 +0,0 @@
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

@ -1,24 +0,0 @@
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

@ -1,25 +0,0 @@
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