feat: global platform v2 update
This commit is contained in:
525
API_DOCS.md
Normal file
525
API_DOCS.md
Normal file
@@ -0,0 +1,525 @@
|
|||||||
|
# TGEX Backend API v2
|
||||||
|
|
||||||
|
## Аутентификация
|
||||||
|
|
||||||
|
**JWT Bearer Token** в заголовке `Authorization: Bearer <token>`
|
||||||
|
|
||||||
|
### Получение токена через Telegram Bot
|
||||||
|
|
||||||
|
Логика точно такая же как и раньше
|
||||||
|
|
||||||
|
### Эндпоинты
|
||||||
|
|
||||||
|
| Метод | Эндпоинт | Описание | Auth |
|
||||||
|
|-------|----------|----------|------|
|
||||||
|
| `GET` | `/auth/complete` | Обмен временного токена на JWT | ❌ |
|
||||||
|
| `GET` | `/auth/me` | Получить текущего пользователя | ✅ |
|
||||||
|
|
||||||
|
**Response** `/auth/me`:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": "uuid",
|
||||||
|
"telegram_id": 123456789,
|
||||||
|
"username": "string | null"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Workspaces (Рабочие пространства)
|
||||||
|
|
||||||
|
Воркспейсы - изолированные пространства для команд. У пользователя может быть несколько воркспейсов.
|
||||||
|
|
||||||
|
| Метод | Эндпоинт | Описание |
|
||||||
|
|-------|----------|----------|
|
||||||
|
| `GET` | `/workspaces` | Список воркспейсов пользователя (пагинация) |
|
||||||
|
| `POST` | `/workspaces` | Создать воркспейс |
|
||||||
|
| `PATCH` | `/workspaces/{workspace_id}` | Обновить название |
|
||||||
|
| `DELETE` | `/workspaces/{workspace_id}` | Удалить воркспейс |
|
||||||
|
|
||||||
|
**Body** `POST /workspaces`:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"name": "string"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": "uuid",
|
||||||
|
"name": "string"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Workspace Members (Участники воркспейса)
|
||||||
|
|
||||||
|
| Метод | Эндпоинт | Описание |
|
||||||
|
|-------|----------|----------|
|
||||||
|
| `GET` | `/workspaces/{workspace_id}/members` | Список участников (пагинация) |
|
||||||
|
| `PUT` | `/workspaces/{workspace_id}/members/{user_id}/permissions` | Обновить права участника |
|
||||||
|
|
||||||
|
**Права доступа** (`PermissionKey`):
|
||||||
|
- `admin_full` - полный доступ
|
||||||
|
- `projects_read` - чтение проектов
|
||||||
|
- `projects_write` - запись проектов
|
||||||
|
- `placements_read` - чтение размещений
|
||||||
|
- `placements_write` - запись размещений
|
||||||
|
- `analytics_read` - чтение аналитики
|
||||||
|
|
||||||
|
**Scope types**: `project` (ограничение прав на конкретный проект)
|
||||||
|
|
||||||
|
**Body** `PUT permissions`:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"permissions": [
|
||||||
|
{
|
||||||
|
"key": "admin_full"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "projects_write",
|
||||||
|
"scopes": [
|
||||||
|
{"type": "project", "id": "project-uuid"}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Workspace Invites (Приглашения)
|
||||||
|
|
||||||
|
| Метод | Эндпоинт | Описание |
|
||||||
|
|-------|----------|----------|
|
||||||
|
| `GET` | `/workspaces/{workspace_id}/invites` | Список приглашений (пагинация) |
|
||||||
|
| `POST` | `/workspaces/{workspace_id}/invites` | Создать приглашение |
|
||||||
|
|
||||||
|
**Body** `POST /invites`:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"username": "telegram_username" // без @
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Статусы приглашения**: `PENDING`, `ACCEPTED`, `REJECTED`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Projects (Проекты / Целевые каналы)
|
||||||
|
|
||||||
|
Проекты = целевые Telegram каналы, которые продвигаются. Создаются через Telegram бота.
|
||||||
|
|
||||||
|
| Метод | Эндпоинт | Описание |
|
||||||
|
|-------|----------|----------|
|
||||||
|
| `GET` | `/workspaces/{workspace_id}/projects` | Список проектов (пагинация) |
|
||||||
|
|
||||||
|
**Response**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": "uuid",
|
||||||
|
"telegram_id": -1001234567890,
|
||||||
|
"title": "string",
|
||||||
|
"username": "string | null",
|
||||||
|
"status": "ACTIVE | INACTIVE | ARCHIVED"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Управление проектами**: через Telegram бота (добавить/удалить канал)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Channels (Каналы для размещений)
|
||||||
|
|
||||||
|
Каталог Telegram каналов, где можно размещать рекламу.
|
||||||
|
|
||||||
|
| Метод | Эндпоинт | Описание | Фильтры |
|
||||||
|
|-------|----------|----------|---------|
|
||||||
|
| `GET` | `/channels` | Список каналов (пагинация) | `?username=...` |
|
||||||
|
|
||||||
|
**Response**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": "uuid",
|
||||||
|
"telegram_id": -1002345678901,
|
||||||
|
"title": "string",
|
||||||
|
"username": "string | null",
|
||||||
|
"description": "string | null",
|
||||||
|
"subscribers_count": 15000
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Purchase Plan (План закупа)
|
||||||
|
|
||||||
|
План закупа - список каналов для покупки рекламы в рамках проекта.
|
||||||
|
|
||||||
|
| Метод | Эндпоинт | Описание |
|
||||||
|
|-------|----------|----------|
|
||||||
|
| `GET` | `/workspaces/{ws}/projects/{proj}/purchase-plan/channels` | Список каналов плана (пагинация) |
|
||||||
|
| `POST` | `/workspaces/{ws}/projects/{proj}/purchase-plan/channels` | Добавить канал в план |
|
||||||
|
| `PATCH` | `/workspaces/{ws}/projects/{proj}/purchase-plan/channels/{id}` | Обновить параметры канала |
|
||||||
|
| `DELETE` | `/workspaces/{ws}/projects/{proj}/purchase-plan/channels/{id}` | Удалить из плана |
|
||||||
|
|
||||||
|
**Body** `POST`:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"channel_id": "uuid",
|
||||||
|
"planned_date": "2025-01-15T12:00:00Z", // optional
|
||||||
|
"price": 5000.50, // optional
|
||||||
|
"comment": "string" // optional
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Body** `PATCH`:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"planned_date": "2025-01-15T12:00:00Z", // optional
|
||||||
|
"price": 5000.50, // optional
|
||||||
|
"comment": "string" // optional
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": "uuid",
|
||||||
|
"status": "PLANNED | COMPLETED | CANCELLED",
|
||||||
|
"planned_date": "2025-01-15T12:00:00Z | null",
|
||||||
|
"price": 5000.50,
|
||||||
|
"comment": "string | null",
|
||||||
|
"channel": {
|
||||||
|
"id": "uuid",
|
||||||
|
"title": "string",
|
||||||
|
"username": "string | null",
|
||||||
|
"subscribers_count": 15000
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Creatives (Креативы)
|
||||||
|
|
||||||
|
Креативы = рекламные материалы (текст с пригласительной ссылкой).
|
||||||
|
|
||||||
|
| Метод | Эндпоинт | Описание | Фильтры |
|
||||||
|
|-------|----------|----------|---------|
|
||||||
|
| `GET` | `/workspaces/{ws}/creatives` | Список креативов (пагинация) | `?project_id=...&include_archived=false` |
|
||||||
|
| `GET` | `/workspaces/{ws}/creatives/{id}` | Один креатив | |
|
||||||
|
| `POST` | `/workspaces/{ws}/creatives?project_id=...` | Создать креатив | |
|
||||||
|
| `PATCH` | `/workspaces/{ws}/creatives/{id}` | Обновить креатив | |
|
||||||
|
| `DELETE` | `/workspaces/{ws}/creatives/{id}` | Удалить (архивировать) | |
|
||||||
|
|
||||||
|
**Body** `POST/PATCH`:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"name": "string",
|
||||||
|
"text": "Текст с {invite_link} тегом"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": "uuid",
|
||||||
|
"name": "string",
|
||||||
|
"text": "string",
|
||||||
|
"status": "ACTIVE | ARCHIVED",
|
||||||
|
"placements_count": 5
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Важно**: `{invite_link}` в тексте заменяется на уникальную ссылку при создании размещения.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Placements (Размещения)
|
||||||
|
|
||||||
|
Размещения = факт размещения креатива в канале.
|
||||||
|
|
||||||
|
| Метод | Эндпоинт | Описание | Фильтры |
|
||||||
|
|-------|----------|----------|---------|
|
||||||
|
| `GET` | `/workspaces/{ws}/placements` | Список размещений (пагинация) | `?project_id=...&placement_channel_id=...&creative_id=...&include_archived=false` |
|
||||||
|
| `GET` | `/workspaces/{ws}/placements/{id}` | Одно размещение | |
|
||||||
|
| `POST` | `/workspaces/{ws}/placements` | Создать размещение | |
|
||||||
|
| `PATCH` | `/workspaces/{ws}/placements/{id}` | Обновить размещение | |
|
||||||
|
| `DELETE` | `/workspaces/{ws}/placements/{id}` | Удалить (архивировать) | |
|
||||||
|
|
||||||
|
**Body** `POST`:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"project_id": "uuid",
|
||||||
|
"placement_channel_id": "uuid",
|
||||||
|
"creative_id": "uuid",
|
||||||
|
"placement_date": "2025-01-15T12:00:00Z",
|
||||||
|
"cost": 5000.50, // optional
|
||||||
|
"comment": "string", // optional
|
||||||
|
"ad_post_url": "https://t.me/channel/123", // optional
|
||||||
|
"invite_link_type": "PUBLIC | APPROVAL"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Body** `PATCH`:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"placement_date": "2025-01-15T12:00:00Z", // optional
|
||||||
|
"cost": 5000.50, // optional
|
||||||
|
"comment": "string", // optional
|
||||||
|
"ad_post_url": "https://t.me/channel/123" // optional
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": "uuid",
|
||||||
|
"placement_date": "2025-01-15T12:00:00Z",
|
||||||
|
"cost": 5000.50,
|
||||||
|
"comment": "string | null",
|
||||||
|
"ad_post_url": "string | null",
|
||||||
|
"invite_link_type": "PUBLIC | APPROVAL",
|
||||||
|
"invite_link": "https://t.me/+UniqueCode",
|
||||||
|
"status": "ACTIVE | ARCHIVED",
|
||||||
|
"subscriptions_count": 42,
|
||||||
|
"views_count": 1850,
|
||||||
|
"views_availability": "UNKNOWN | AVAILABLE | UNAVAILABLE | MANUAL",
|
||||||
|
"last_views_fetch_at": "2025-01-15T14:30:00Z",
|
||||||
|
"project": {
|
||||||
|
"id": "uuid",
|
||||||
|
"title": "string",
|
||||||
|
"username": "string | null"
|
||||||
|
},
|
||||||
|
"placement_channel": {
|
||||||
|
"id": "uuid",
|
||||||
|
"title": "string",
|
||||||
|
"username": "string | null",
|
||||||
|
"subscribers_count": 15000
|
||||||
|
},
|
||||||
|
"creative": {
|
||||||
|
"id": "uuid",
|
||||||
|
"name": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Типы пригласительных ссылок**:
|
||||||
|
- `PUBLIC` - открытая ссылка
|
||||||
|
- `APPROVAL` - с одобрением через бота
|
||||||
|
|
||||||
|
**Статус просмотров**:
|
||||||
|
- `UNKNOWN` - еще не проверялось
|
||||||
|
- `AVAILABLE` - просмотры доступны (автообновление)
|
||||||
|
- `UNAVAILABLE` - нет доступа к просмотрам
|
||||||
|
- `MANUAL` - ручной ввод
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Views History (История просмотров)
|
||||||
|
|
||||||
|
| Метод | Эндпоинт | Описание | Фильтры |
|
||||||
|
|-------|----------|----------|---------|
|
||||||
|
| `GET` | `/workspaces/{ws}/placements/{id}/views/history` | История просмотров (пагинация) | `?from_date=...&to_date=...` |
|
||||||
|
|
||||||
|
**Response**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": "uuid",
|
||||||
|
"views_count": 1850,
|
||||||
|
"fetched_at": "2025-01-15T14:30:00Z"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Analytics (Аналитика)
|
||||||
|
|
||||||
|
Все эндпоинты поддерживают фильтр `?project_id=...` для конкретного проекта.
|
||||||
|
|
||||||
|
### Аналитика размещений
|
||||||
|
|
||||||
|
| Метод | Эндпоинт | Описание |
|
||||||
|
|-------|----------|----------|
|
||||||
|
| `GET` | `/workspaces/{ws}/analytics/placements` | Аналитика по размещениям (пагинация) |
|
||||||
|
|
||||||
|
**Response**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"placement_id": "uuid",
|
||||||
|
"placement_channel_title": "string",
|
||||||
|
"placement_channel_username": "string | null",
|
||||||
|
"creative_name": "string",
|
||||||
|
"placement_date": "2025-01-15T12:00:00Z",
|
||||||
|
"cost": 5000.50,
|
||||||
|
"subscriptions_count": 42,
|
||||||
|
"views_count": 1850,
|
||||||
|
"cps": 119.05, // cost per subscription
|
||||||
|
"cpm": 2702.70 // cost per mille views
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Аналитика креативов
|
||||||
|
|
||||||
|
| Метод | Эндпоинт | Описание |
|
||||||
|
|-------|----------|----------|
|
||||||
|
| `GET` | `/workspaces/{ws}/analytics/creatives` | Аналитика по креативам (пагинация) |
|
||||||
|
|
||||||
|
**Response**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"creative_id": "uuid",
|
||||||
|
"creative_name": "string",
|
||||||
|
"placements_count": 5,
|
||||||
|
"total_cost": 25000.00,
|
||||||
|
"total_subscriptions": 210,
|
||||||
|
"total_views": 9250,
|
||||||
|
"avg_cps": 119.05,
|
||||||
|
"avg_cpm": 2702.70
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Аналитика каналов
|
||||||
|
|
||||||
|
| Метод | Эндпоинт | Описание |
|
||||||
|
|-------|----------|----------|
|
||||||
|
| `GET` | `/workspaces/{ws}/analytics/channels` | Аналитика по каналам (пагинация) |
|
||||||
|
|
||||||
|
**Response**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"channel_id": "uuid",
|
||||||
|
"channel_title": "string",
|
||||||
|
"channel_username": "string | null",
|
||||||
|
"placements_count": 3,
|
||||||
|
"total_cost": 15000.00,
|
||||||
|
"total_subscriptions": 126,
|
||||||
|
"total_views": 5550,
|
||||||
|
"avg_cps": 119.05,
|
||||||
|
"avg_cpm": 2702.70
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Аналитика расходов
|
||||||
|
|
||||||
|
| Метод | Эндпоинт | Описание | Фильтры |
|
||||||
|
|-------|----------|----------|---------|
|
||||||
|
| `GET` | `/workspaces/{ws}/analytics/spending` | Динамика расходов | `?date_from=...&date_to=...&grouping=DAY\|WEEK\|MONTH` |
|
||||||
|
|
||||||
|
**Response**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"date": "2025-01-15",
|
||||||
|
"cost": 5000.50,
|
||||||
|
"subscriptions": 42,
|
||||||
|
"views": 1850
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"total_cost": 25000.00,
|
||||||
|
"total_subscriptions": 210,
|
||||||
|
"total_views": 9250
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Группировка** (`DateGrouping`): `DAY`, `WEEK`, `MONTH`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Пагинация
|
||||||
|
|
||||||
|
Все эндпоинты с пагинацией возвращают:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"items": [...],
|
||||||
|
"total": 42,
|
||||||
|
"page": 1,
|
||||||
|
"size": 50,
|
||||||
|
"pages": 1
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Query параметры**: `?page=1&size=50`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Статусы и Enum
|
||||||
|
|
||||||
|
### WorkspaceUserStatus
|
||||||
|
- `ACTIVE` - активный участник
|
||||||
|
- `INVITED` - приглашён, но не принял
|
||||||
|
|
||||||
|
### WorkspaceInviteStatus
|
||||||
|
- `PENDING` - ожидает принятия
|
||||||
|
- `ACCEPTED` - принято
|
||||||
|
- `REJECTED` - отклонено
|
||||||
|
|
||||||
|
### ProjectStatus / ChannelStatus
|
||||||
|
- `ACTIVE` - активен
|
||||||
|
- `INACTIVE` - неактивен
|
||||||
|
- `ARCHIVED` - архивирован
|
||||||
|
|
||||||
|
### CreativeStatus / PlacementStatus
|
||||||
|
- `ACTIVE` - активен
|
||||||
|
- `ARCHIVED` - архивирован
|
||||||
|
|
||||||
|
### PurchasePlanStatus
|
||||||
|
- `PLANNED` - запланирован
|
||||||
|
- `COMPLETED` - завершён
|
||||||
|
- `CANCELLED` - отменён
|
||||||
|
|
||||||
|
### InviteLinkType
|
||||||
|
- `PUBLIC` - открытая ссылка
|
||||||
|
- `APPROVAL` - с одобрением бота
|
||||||
|
|
||||||
|
### PostViewsAvailability
|
||||||
|
- `UNKNOWN` - неизвестно (ещё не проверялось)
|
||||||
|
- `AVAILABLE` - доступны (автообновление)
|
||||||
|
- `UNAVAILABLE` - недоступны
|
||||||
|
- `MANUAL` - ручной ввод
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Коды ошибок HTTP
|
||||||
|
|
||||||
|
- `200` - OK
|
||||||
|
- `201` - Created
|
||||||
|
- `204` - No Content (успешное удаление)
|
||||||
|
- `400` - Bad Request (валидация)
|
||||||
|
- `401` - Unauthorized (нет токена)
|
||||||
|
- `403` - Forbidden (нет прав)
|
||||||
|
- `404` - Not Found
|
||||||
|
- `500` - Internal Server Error
|
||||||
|
|
||||||
|
**Формат ошибки**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"detail": "Error message"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Особенности
|
||||||
|
|
||||||
|
1. **Все UUID в формате**: `xxxxxxxx-xxxx-4xxx-xxxx-xxxxxxxxxxxx`
|
||||||
|
2. **Даты в ISO 8601**: `2025-01-15T12:00:00Z` (UTC)
|
||||||
|
3. **Метрики автообновляются**: просмотры обновляются воркером каждые 60 секунд (если `views_availability = AVAILABLE`)
|
||||||
|
4. **Подписки отслеживаются**: через Telegram бота при присоединении по invite_link
|
||||||
|
5. **Права проверяются**: все эндпоинты под воркспейсами требуют соответствующих прав
|
||||||
|
6. **Soft delete**: удаление = архивирование (статус `ARCHIVED`)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Telegram Bot Integration
|
||||||
|
|
||||||
|
- Создание проектов (добавление целевых каналов)
|
||||||
|
- Создание креативов через диалог
|
||||||
|
- Приглашения в воркспейсы
|
||||||
|
- Отслеживание подписок через invite links
|
||||||
|
- Начальная аутентификация (`/start` → временный токен)
|
||||||
|
|
||||||
@@ -1,286 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// Analytics Costs Page
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
import React, { useEffect, useState } from "react";
|
|
||||||
import { DashboardHeader } from "@/components/layout/dashboard-header";
|
|
||||||
import {
|
|
||||||
Card,
|
|
||||||
CardContent,
|
|
||||||
CardDescription,
|
|
||||||
CardHeader,
|
|
||||||
CardTitle,
|
|
||||||
} from "@/components/ui/card";
|
|
||||||
import {
|
|
||||||
Select,
|
|
||||||
SelectContent,
|
|
||||||
SelectItem,
|
|
||||||
SelectTrigger,
|
|
||||||
SelectValue,
|
|
||||||
} from "@/components/ui/select";
|
|
||||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
|
||||||
import { analyticsApi } from "@/lib/api";
|
|
||||||
import { formatCurrency, formatNumber } from "@/lib/utils/format";
|
|
||||||
import { Loader2, AlertCircle, DollarSign } from "lucide-react";
|
|
||||||
import type { SpendingAnalytics, DateGrouping } from "@/lib/types/api";
|
|
||||||
import { useTargetChannel } from "@/components/providers/target-channel-provider";
|
|
||||||
import {
|
|
||||||
LineChart,
|
|
||||||
Line,
|
|
||||||
BarChart,
|
|
||||||
Bar,
|
|
||||||
XAxis,
|
|
||||||
YAxis,
|
|
||||||
CartesianGrid,
|
|
||||||
Tooltip,
|
|
||||||
Legend,
|
|
||||||
ResponsiveContainer,
|
|
||||||
} from "recharts";
|
|
||||||
|
|
||||||
export default function AnalyticsCostsPage() {
|
|
||||||
const { selectedChannel } = useTargetChannel();
|
|
||||||
const [report, setReport] = useState<SpendingAnalytics | null>(null);
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
const [grouping, setGrouping] = useState<DateGrouping>("week");
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (selectedChannel) {
|
|
||||||
loadData();
|
|
||||||
} else {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
}, [grouping, selectedChannel]);
|
|
||||||
|
|
||||||
const loadData = async () => {
|
|
||||||
if (!selectedChannel) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
setLoading(true);
|
|
||||||
const reportData = await analyticsApi.spending({
|
|
||||||
grouping,
|
|
||||||
target_channel_id: selectedChannel.id,
|
|
||||||
});
|
|
||||||
setReport(reportData);
|
|
||||||
} catch (err: any) {
|
|
||||||
setError(err?.error?.message || "Ошибка загрузки аналитики");
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<DashboardHeader
|
|
||||||
breadcrumbs={[
|
|
||||||
{ label: "Главная", href: "/" },
|
|
||||||
{ label: "Аналитика", href: "/analytics" },
|
|
||||||
{ label: "Затраты" },
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
<div className="flex flex-1 flex-col gap-4 p-4 pt-0 mt-4">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<h1 className="text-3xl font-bold tracking-tight">
|
|
||||||
Затраты на рекламу
|
|
||||||
</h1>
|
|
||||||
<p className="text-muted-foreground mt-1">
|
|
||||||
Динамика расходов по периодам
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{error && (
|
|
||||||
<Alert variant="destructive">
|
|
||||||
<AlertCircle className="h-4 w-4" />
|
|
||||||
<AlertDescription>{error}</AlertDescription>
|
|
||||||
</Alert>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>Фильтры</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-4">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<label className="text-sm font-medium">
|
|
||||||
Период группировки
|
|
||||||
</label>
|
|
||||||
<Select
|
|
||||||
value={grouping}
|
|
||||||
onValueChange={(value: DateGrouping) => setGrouping(value)}
|
|
||||||
>
|
|
||||||
<SelectTrigger className="max-w-xs">
|
|
||||||
<SelectValue />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="day">По дням</SelectItem>
|
|
||||||
<SelectItem value="week">По неделям</SelectItem>
|
|
||||||
<SelectItem value="month">По месяцам</SelectItem>
|
|
||||||
<SelectItem value="quarter">По кварталам</SelectItem>
|
|
||||||
<SelectItem value="year">По годам</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{loading ? (
|
|
||||||
<Card>
|
|
||||||
<CardContent className="flex items-center justify-center p-12">
|
|
||||||
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
) : report ? (
|
|
||||||
<>
|
|
||||||
<div className="grid gap-4 md:grid-cols-4">
|
|
||||||
<Card>
|
|
||||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
|
||||||
<CardTitle className="text-sm font-medium">
|
|
||||||
Всего затрат
|
|
||||||
</CardTitle>
|
|
||||||
<DollarSign className="h-4 w-4 text-muted-foreground" />
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<div className="text-2xl font-bold">
|
|
||||||
{formatCurrency(report.total_cost)}
|
|
||||||
</div>
|
|
||||||
<p className="text-xs text-muted-foreground mt-1">
|
|
||||||
За выбранный период
|
|
||||||
</p>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card>
|
|
||||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
|
||||||
<CardTitle className="text-sm font-medium">
|
|
||||||
Подписчики
|
|
||||||
</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<div className="text-2xl font-bold">
|
|
||||||
{formatNumber(report.total_subscriptions)}
|
|
||||||
</div>
|
|
||||||
<p className="text-xs text-muted-foreground mt-1">
|
|
||||||
Всего привлечено
|
|
||||||
</p>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card>
|
|
||||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
|
||||||
<CardTitle className="text-sm font-medium">
|
|
||||||
Просмотры
|
|
||||||
</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<div className="text-2xl font-bold">
|
|
||||||
{formatNumber(report.total_views)}
|
|
||||||
</div>
|
|
||||||
<p className="text-xs text-muted-foreground mt-1">
|
|
||||||
Всего просмотров
|
|
||||||
</p>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card>
|
|
||||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
|
||||||
<CardTitle className="text-sm font-medium">
|
|
||||||
Средний CPF
|
|
||||||
</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<div className="text-2xl font-bold">
|
|
||||||
{report.avg_cpf ? formatCurrency(report.avg_cpf) : "—"}
|
|
||||||
</div>
|
|
||||||
<p className="text-xs text-muted-foreground mt-1">
|
|
||||||
В среднем за период
|
|
||||||
</p>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>График затрат</CardTitle>
|
|
||||||
<CardDescription>Динамика расходов на рекламу</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<ResponsiveContainer width="100%" height={350}>
|
|
||||||
<BarChart data={report.chart_data}>
|
|
||||||
<CartesianGrid
|
|
||||||
strokeDasharray="3 3"
|
|
||||||
className="stroke-muted"
|
|
||||||
/>
|
|
||||||
<XAxis
|
|
||||||
dataKey="period"
|
|
||||||
className="text-xs"
|
|
||||||
tick={{ fill: "hsl(var(--muted-foreground))" }}
|
|
||||||
/>
|
|
||||||
<YAxis
|
|
||||||
className="text-xs"
|
|
||||||
tick={{ fill: "hsl(var(--muted-foreground))" }}
|
|
||||||
tickFormatter={(value) => `₽${formatNumber(value)}`}
|
|
||||||
/>
|
|
||||||
<Tooltip
|
|
||||||
contentStyle={{
|
|
||||||
backgroundColor: "hsl(var(--background))",
|
|
||||||
border: "1px solid hsl(var(--border))",
|
|
||||||
borderRadius: "8px",
|
|
||||||
}}
|
|
||||||
formatter={(value: any) => [
|
|
||||||
`₽${formatNumber(value)}`,
|
|
||||||
"Затраты",
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
<Legend />
|
|
||||||
<Bar
|
|
||||||
dataKey="cost"
|
|
||||||
name="Затраты"
|
|
||||||
fill="hsl(var(--primary))"
|
|
||||||
radius={[8, 8, 0, 0]}
|
|
||||||
/>
|
|
||||||
</BarChart>
|
|
||||||
</ResponsiveContainer>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>Детализация по периодам</CardTitle>
|
|
||||||
<CardDescription>Затраты и количество закупов</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<div className="space-y-3">
|
|
||||||
{report.chart_data.map((period) => (
|
|
||||||
<div
|
|
||||||
key={period.period}
|
|
||||||
className="flex items-center justify-between p-3 rounded-lg border"
|
|
||||||
>
|
|
||||||
<div>
|
|
||||||
<p className="font-medium">{period.period}</p>
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
{formatNumber(period.subscriptions)} подписок • {formatNumber(period.views)} просмотров
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="text-right">
|
|
||||||
<p className="text-lg font-bold">
|
|
||||||
{formatCurrency(period.cost)}
|
|
||||||
</p>
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
{period.cpf ? `CPF: ${formatCurrency(period.cpf)}` : "—"}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,606 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import React, { useState, useEffect } from "react";
|
|
||||||
import { DashboardHeader } from "@/components/layout/dashboard-header";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
|
||||||
import {
|
|
||||||
Table,
|
|
||||||
TableBody,
|
|
||||||
TableCell,
|
|
||||||
TableHead,
|
|
||||||
TableHeader,
|
|
||||||
TableRow,
|
|
||||||
} from "@/components/ui/table";
|
|
||||||
import {
|
|
||||||
Card,
|
|
||||||
CardContent,
|
|
||||||
CardDescription,
|
|
||||||
CardHeader,
|
|
||||||
CardTitle,
|
|
||||||
} from "@/components/ui/card";
|
|
||||||
import {
|
|
||||||
Select,
|
|
||||||
SelectContent,
|
|
||||||
SelectItem,
|
|
||||||
SelectTrigger,
|
|
||||||
SelectValue,
|
|
||||||
} from "@/components/ui/select";
|
|
||||||
import { Label } from "@/components/ui/label";
|
|
||||||
import { Calendar } from "@/components/ui/calendar";
|
|
||||||
import {
|
|
||||||
Popover,
|
|
||||||
PopoverContent,
|
|
||||||
PopoverTrigger,
|
|
||||||
} from "@/components/ui/popover";
|
|
||||||
import {
|
|
||||||
DropdownMenu,
|
|
||||||
DropdownMenuContent,
|
|
||||||
DropdownMenuItem,
|
|
||||||
DropdownMenuTrigger,
|
|
||||||
} from "@/components/ui/dropdown-menu";
|
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
import { format } from "date-fns";
|
|
||||||
import { ru } from "date-fns/locale";
|
|
||||||
import { channelsApi, analyticsApi } from "@/lib/api";
|
|
||||||
import { formatCurrency, formatNumber } from "@/lib/utils/format";
|
|
||||||
import {
|
|
||||||
Loader2,
|
|
||||||
AlertCircle,
|
|
||||||
CalendarIcon,
|
|
||||||
ArrowUpDown,
|
|
||||||
ArrowUp,
|
|
||||||
ArrowDown,
|
|
||||||
Download,
|
|
||||||
} from "lucide-react";
|
|
||||||
import type { TargetChannel, CreativeAnalytics } from "@/lib/types/api";
|
|
||||||
import * as XLSX from "xlsx";
|
|
||||||
|
|
||||||
type SortField =
|
|
||||||
| "name"
|
|
||||||
| "placements_count"
|
|
||||||
| "total_cost"
|
|
||||||
| "total_subscriptions"
|
|
||||||
| "total_views"
|
|
||||||
| "avg_cpf"
|
|
||||||
| "avg_cpm"
|
|
||||||
| null;
|
|
||||||
type SortDirection = "asc" | "desc" | null;
|
|
||||||
|
|
||||||
export default function CreativesAnalyticsPage() {
|
|
||||||
const [channels, setChannels] = useState<TargetChannel[]>([]);
|
|
||||||
const [selectedChannelId, setSelectedChannelId] = useState<string>("");
|
|
||||||
const [dateFrom, setDateFrom] = useState<Date | undefined>();
|
|
||||||
const [dateTo, setDateTo] = useState<Date | undefined>();
|
|
||||||
const [creatives, setCreatives] = useState<CreativeAnalytics[]>([]);
|
|
||||||
const [filteredCreatives, setFilteredCreatives] = useState<
|
|
||||||
CreativeAnalytics[]
|
|
||||||
>([]);
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [loadingData, setLoadingData] = useState(false);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
const [sortField, setSortField] = useState<SortField>(null);
|
|
||||||
const [sortDirection, setSortDirection] = useState<SortDirection>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
loadChannels();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const loadChannels = async () => {
|
|
||||||
try {
|
|
||||||
setLoading(true);
|
|
||||||
const response = await channelsApi.list();
|
|
||||||
const activeChannels = response.target_channels.filter((c) => c.is_active);
|
|
||||||
setChannels(activeChannels);
|
|
||||||
if (activeChannels.length > 0) {
|
|
||||||
setSelectedChannelId(activeChannels[0].id);
|
|
||||||
}
|
|
||||||
} catch (err: any) {
|
|
||||||
setError(err?.error?.message || "Ошибка загрузки каналов");
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const loadCreativesData = async () => {
|
|
||||||
if (!selectedChannelId) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
setLoadingData(true);
|
|
||||||
const response = await analyticsApi.creatives({
|
|
||||||
target_channel_id: selectedChannelId,
|
|
||||||
});
|
|
||||||
setCreatives(response.creatives);
|
|
||||||
setFilteredCreatives(response.creatives);
|
|
||||||
} catch (err: any) {
|
|
||||||
alert(err?.error?.message || "Ошибка загрузки данных");
|
|
||||||
} finally {
|
|
||||||
setLoadingData(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Сортировка
|
|
||||||
const handleSort = (field: SortField) => {
|
|
||||||
if (sortField === field) {
|
|
||||||
if (sortDirection === "asc") {
|
|
||||||
setSortDirection("desc");
|
|
||||||
} else if (sortDirection === "desc") {
|
|
||||||
setSortField(null);
|
|
||||||
setSortDirection(null);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
setSortField(field);
|
|
||||||
setSortDirection("asc");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const getSortIcon = (field: SortField) => {
|
|
||||||
if (sortField !== field) {
|
|
||||||
return <ArrowUpDown className="ml-2 h-4 w-4 inline" />;
|
|
||||||
}
|
|
||||||
if (sortDirection === "asc") {
|
|
||||||
return <ArrowUp className="ml-2 h-4 w-4 inline" />;
|
|
||||||
}
|
|
||||||
return <ArrowDown className="ml-2 h-4 w-4 inline" />;
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!sortField || !sortDirection) {
|
|
||||||
setFilteredCreatives([...creatives]);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const sorted = [...creatives].sort((a, b) => {
|
|
||||||
let aValue: string | number | null = 0;
|
|
||||||
let bValue: string | number | null = 0;
|
|
||||||
|
|
||||||
switch (sortField) {
|
|
||||||
case "name":
|
|
||||||
aValue = a.name;
|
|
||||||
bValue = b.name;
|
|
||||||
break;
|
|
||||||
case "placements_count":
|
|
||||||
aValue = a.placements_count;
|
|
||||||
bValue = b.placements_count;
|
|
||||||
break;
|
|
||||||
case "total_cost":
|
|
||||||
aValue = a.total_cost;
|
|
||||||
bValue = b.total_cost;
|
|
||||||
break;
|
|
||||||
case "total_subscriptions":
|
|
||||||
aValue = a.total_subscriptions;
|
|
||||||
bValue = b.total_subscriptions;
|
|
||||||
break;
|
|
||||||
case "total_views":
|
|
||||||
aValue = a.total_views;
|
|
||||||
bValue = b.total_views;
|
|
||||||
break;
|
|
||||||
case "avg_cpf":
|
|
||||||
aValue = a.avg_cpf || 0;
|
|
||||||
bValue = b.avg_cpf || 0;
|
|
||||||
break;
|
|
||||||
case "avg_cpm":
|
|
||||||
aValue = a.avg_cpm || 0;
|
|
||||||
bValue = b.avg_cpm || 0;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof aValue === "string" && typeof bValue === "string") {
|
|
||||||
return sortDirection === "asc"
|
|
||||||
? aValue.localeCompare(bValue)
|
|
||||||
: bValue.localeCompare(aValue);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof aValue === "number" && typeof bValue === "number") {
|
|
||||||
return sortDirection === "asc" ? aValue - bValue : bValue - aValue;
|
|
||||||
}
|
|
||||||
|
|
||||||
return 0;
|
|
||||||
});
|
|
||||||
|
|
||||||
setFilteredCreatives(sorted);
|
|
||||||
}, [sortField, sortDirection, creatives]);
|
|
||||||
|
|
||||||
// Экспорт
|
|
||||||
const exportToExcel = () => {
|
|
||||||
const data = filteredCreatives.map((creative) => ({
|
|
||||||
Название: creative.name,
|
|
||||||
Размещений: creative.placements_count,
|
|
||||||
"Общая стоимость": creative.total_cost,
|
|
||||||
Подписки: creative.total_subscriptions,
|
|
||||||
Просмотры: creative.total_views,
|
|
||||||
"Средний CPF": creative.avg_cpf || 0,
|
|
||||||
"Средний CPM": creative.avg_cpm || 0,
|
|
||||||
}));
|
|
||||||
|
|
||||||
const ws = XLSX.utils.json_to_sheet(data);
|
|
||||||
const wb = XLSX.utils.book_new();
|
|
||||||
XLSX.utils.book_append_sheet(wb, ws, "Креативы");
|
|
||||||
|
|
||||||
const colWidths = [
|
|
||||||
{ wch: 30 },
|
|
||||||
{ wch: 12 },
|
|
||||||
{ wch: 15 },
|
|
||||||
{ wch: 12 },
|
|
||||||
{ wch: 12 },
|
|
||||||
{ wch: 12 },
|
|
||||||
{ wch: 12 },
|
|
||||||
];
|
|
||||||
ws["!cols"] = colWidths;
|
|
||||||
|
|
||||||
XLSX.writeFile(
|
|
||||||
wb,
|
|
||||||
`Креативы_Аналитика_${new Date().toISOString().split("T")[0]}.xlsx`
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const exportToCSV = () => {
|
|
||||||
const data = filteredCreatives.map((creative) => ({
|
|
||||||
Название: creative.name,
|
|
||||||
Размещений: creative.placements_count,
|
|
||||||
"Общая стоимость": creative.total_cost,
|
|
||||||
Подписки: creative.total_subscriptions,
|
|
||||||
Просмотры: creative.total_views,
|
|
||||||
"Средний CPF": creative.avg_cpf || 0,
|
|
||||||
"Средний CPM": creative.avg_cpm || 0,
|
|
||||||
}));
|
|
||||||
|
|
||||||
const headers = Object.keys(data[0]);
|
|
||||||
const csvContent = [
|
|
||||||
headers.join(","),
|
|
||||||
...data.map((row) =>
|
|
||||||
headers
|
|
||||||
.map((header) => {
|
|
||||||
const value = row[header as keyof typeof row];
|
|
||||||
return value !== null && value !== undefined ? value : "";
|
|
||||||
})
|
|
||||||
.join(",")
|
|
||||||
),
|
|
||||||
].join("\n");
|
|
||||||
|
|
||||||
const blob = new Blob(["\ufeff" + csvContent], {
|
|
||||||
type: "text/csv;charset=utf-8;",
|
|
||||||
});
|
|
||||||
const link = document.createElement("a");
|
|
||||||
link.href = URL.createObjectURL(blob);
|
|
||||||
link.download = `Креативы_Аналитика_${new Date().toISOString().split("T")[0]}.csv`;
|
|
||||||
link.click();
|
|
||||||
URL.revokeObjectURL(link.href);
|
|
||||||
};
|
|
||||||
|
|
||||||
const exportToTXT = () => {
|
|
||||||
const data = filteredCreatives.map((creative) => ({
|
|
||||||
Название: creative.name,
|
|
||||||
Размещений: creative.placements_count,
|
|
||||||
"Общая стоимость": creative.total_cost,
|
|
||||||
Подписки: creative.total_subscriptions,
|
|
||||||
Просмотры: creative.total_views,
|
|
||||||
"Средний CPF": creative.avg_cpf || 0,
|
|
||||||
"Средний CPM": creative.avg_cpm || 0,
|
|
||||||
}));
|
|
||||||
|
|
||||||
const headers = Object.keys(data[0]);
|
|
||||||
const columnWidths = headers.map((header) => {
|
|
||||||
const maxLength = Math.max(
|
|
||||||
header.length,
|
|
||||||
...data.map((row) =>
|
|
||||||
String(row[header as keyof typeof row] || "").length
|
|
||||||
)
|
|
||||||
);
|
|
||||||
return Math.min(maxLength + 2, 30);
|
|
||||||
});
|
|
||||||
|
|
||||||
const txtContent = [
|
|
||||||
headers.map((header, i) => header.padEnd(columnWidths[i])).join(" | "),
|
|
||||||
columnWidths.map((width) => "-".repeat(width)).join("-+-"),
|
|
||||||
...data.map((row) =>
|
|
||||||
headers
|
|
||||||
.map((header, i) =>
|
|
||||||
String(row[header as keyof typeof row] || "").padEnd(columnWidths[i])
|
|
||||||
)
|
|
||||||
.join(" | ")
|
|
||||||
),
|
|
||||||
].join("\n");
|
|
||||||
|
|
||||||
const blob = new Blob([txtContent], { type: "text/plain;charset=utf-8" });
|
|
||||||
const link = document.createElement("a");
|
|
||||||
link.href = URL.createObjectURL(blob);
|
|
||||||
link.download = `Креативы_Аналитика_${new Date().toISOString().split("T")[0]}.txt`;
|
|
||||||
link.click();
|
|
||||||
URL.revokeObjectURL(link.href);
|
|
||||||
};
|
|
||||||
|
|
||||||
if (loading) {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<DashboardHeader
|
|
||||||
breadcrumbs={[
|
|
||||||
{ label: "Аналитика", href: "/analytics" },
|
|
||||||
{ label: "Креативы" },
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
<div className="flex items-center justify-center p-12">
|
|
||||||
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<DashboardHeader
|
|
||||||
breadcrumbs={[
|
|
||||||
{ label: "Аналитика", href: "/analytics" },
|
|
||||||
{ label: "Креативы" },
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
<div className="flex flex-1 flex-col gap-4 p-4 pt-0 mt-4">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<h1 className="text-3xl font-bold tracking-tight">
|
|
||||||
Аналитика креативов
|
|
||||||
</h1>
|
|
||||||
<p className="text-muted-foreground mt-1">
|
|
||||||
Производительность рекламных креативов
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{error && (
|
|
||||||
<Alert variant="destructive">
|
|
||||||
<AlertCircle className="h-4 w-4" />
|
|
||||||
<AlertDescription>{error}</AlertDescription>
|
|
||||||
</Alert>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{channels.length === 0 ? (
|
|
||||||
<Alert>
|
|
||||||
<AlertCircle className="h-4 w-4" />
|
|
||||||
<AlertDescription>
|
|
||||||
Нет активных целевых каналов. Добавьте канал для начала работы с
|
|
||||||
аналитикой.
|
|
||||||
</AlertDescription>
|
|
||||||
</Alert>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
{/* Фильтры */}
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>Фильтры</CardTitle>
|
|
||||||
<CardDescription>
|
|
||||||
Настройте параметры для отображения данных
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-4">
|
|
||||||
<div className="grid gap-4 md:grid-cols-3">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label>Целевой канал</Label>
|
|
||||||
<Select
|
|
||||||
value={selectedChannelId}
|
|
||||||
onValueChange={setSelectedChannelId}
|
|
||||||
>
|
|
||||||
<SelectTrigger>
|
|
||||||
<SelectValue />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{channels.map((channel) => (
|
|
||||||
<SelectItem key={channel.id} value={channel.id}>
|
|
||||||
{channel.title}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label>Дата от</Label>
|
|
||||||
<Popover>
|
|
||||||
<PopoverTrigger asChild>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
className={cn(
|
|
||||||
"w-full justify-start text-left font-normal",
|
|
||||||
!dateFrom && "text-muted-foreground"
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<CalendarIcon className="mr-2 h-4 w-4" />
|
|
||||||
{dateFrom ? (
|
|
||||||
format(dateFrom, "d MMM yyyy", { locale: ru })
|
|
||||||
) : (
|
|
||||||
<span>Выберите дату</span>
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
</PopoverTrigger>
|
|
||||||
<PopoverContent className="w-auto p-0">
|
|
||||||
<Calendar
|
|
||||||
mode="single"
|
|
||||||
selected={dateFrom}
|
|
||||||
onSelect={setDateFrom}
|
|
||||||
initialFocus
|
|
||||||
/>
|
|
||||||
</PopoverContent>
|
|
||||||
</Popover>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label>Дата до</Label>
|
|
||||||
<Popover>
|
|
||||||
<PopoverTrigger asChild>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
className={cn(
|
|
||||||
"w-full justify-start text-left font-normal",
|
|
||||||
!dateTo && "text-muted-foreground"
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<CalendarIcon className="mr-2 h-4 w-4" />
|
|
||||||
{dateTo ? (
|
|
||||||
format(dateTo, "d MMM yyyy", { locale: ru })
|
|
||||||
) : (
|
|
||||||
<span>Выберите дату</span>
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
</PopoverTrigger>
|
|
||||||
<PopoverContent className="w-auto p-0">
|
|
||||||
<Calendar
|
|
||||||
mode="single"
|
|
||||||
selected={dateTo}
|
|
||||||
onSelect={setDateTo}
|
|
||||||
initialFocus
|
|
||||||
/>
|
|
||||||
</PopoverContent>
|
|
||||||
</Popover>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Button onClick={loadCreativesData} disabled={!selectedChannelId}>
|
|
||||||
Применить фильтры
|
|
||||||
</Button>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Таблица */}
|
|
||||||
{loadingData ? (
|
|
||||||
<Card>
|
|
||||||
<CardContent className="flex items-center justify-center p-12">
|
|
||||||
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
) : filteredCreatives.length > 0 ? (
|
|
||||||
<Card>
|
|
||||||
<CardHeader className="flex flex-row justify-between">
|
|
||||||
<div>
|
|
||||||
<CardTitle>Креативы</CardTitle>
|
|
||||||
<CardDescription>
|
|
||||||
Показатели производительности креативов
|
|
||||||
</CardDescription>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<DropdownMenu>
|
|
||||||
<DropdownMenuTrigger asChild>
|
|
||||||
<Button variant="outline">
|
|
||||||
<Download className="mr-2 h-4 w-4" />
|
|
||||||
Экспорт
|
|
||||||
</Button>
|
|
||||||
</DropdownMenuTrigger>
|
|
||||||
<DropdownMenuContent align="end">
|
|
||||||
<DropdownMenuItem onClick={exportToExcel}>
|
|
||||||
Экспорт в Excel (.xlsx)
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuItem onClick={exportToCSV}>
|
|
||||||
Экспорт в CSV (.csv)
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuItem onClick={exportToTXT}>
|
|
||||||
Экспорт в TXT (.txt)
|
|
||||||
</DropdownMenuItem>
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<Table>
|
|
||||||
<TableHeader>
|
|
||||||
<TableRow>
|
|
||||||
<TableHead
|
|
||||||
className="cursor-pointer hover:bg-muted/50"
|
|
||||||
onClick={() => handleSort("name")}
|
|
||||||
>
|
|
||||||
Название
|
|
||||||
{getSortIcon("name")}
|
|
||||||
</TableHead>
|
|
||||||
<TableHead
|
|
||||||
className="text-right cursor-pointer hover:bg-muted/50"
|
|
||||||
onClick={() => handleSort("placements_count")}
|
|
||||||
>
|
|
||||||
Размещений
|
|
||||||
{getSortIcon("placements_count")}
|
|
||||||
</TableHead>
|
|
||||||
<TableHead
|
|
||||||
className="text-right cursor-pointer hover:bg-muted/50"
|
|
||||||
onClick={() => handleSort("total_cost")}
|
|
||||||
>
|
|
||||||
Общая стоимость
|
|
||||||
{getSortIcon("total_cost")}
|
|
||||||
</TableHead>
|
|
||||||
<TableHead
|
|
||||||
className="text-right cursor-pointer hover:bg-muted/50"
|
|
||||||
onClick={() => handleSort("total_subscriptions")}
|
|
||||||
>
|
|
||||||
Подписки
|
|
||||||
{getSortIcon("total_subscriptions")}
|
|
||||||
</TableHead>
|
|
||||||
<TableHead
|
|
||||||
className="text-right cursor-pointer hover:bg-muted/50"
|
|
||||||
onClick={() => handleSort("total_views")}
|
|
||||||
>
|
|
||||||
Просмотры
|
|
||||||
{getSortIcon("total_views")}
|
|
||||||
</TableHead>
|
|
||||||
<TableHead
|
|
||||||
className="text-right cursor-pointer hover:bg-muted/50"
|
|
||||||
onClick={() => handleSort("avg_cpf")}
|
|
||||||
>
|
|
||||||
Средний CPF
|
|
||||||
{getSortIcon("avg_cpf")}
|
|
||||||
</TableHead>
|
|
||||||
<TableHead
|
|
||||||
className="text-right cursor-pointer hover:bg-muted/50"
|
|
||||||
onClick={() => handleSort("avg_cpm")}
|
|
||||||
>
|
|
||||||
Средний CPM
|
|
||||||
{getSortIcon("avg_cpm")}
|
|
||||||
</TableHead>
|
|
||||||
</TableRow>
|
|
||||||
</TableHeader>
|
|
||||||
<TableBody>
|
|
||||||
{filteredCreatives.map((creative) => (
|
|
||||||
<TableRow key={creative.id}>
|
|
||||||
<TableCell className="font-medium">
|
|
||||||
{creative.name}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="text-right">
|
|
||||||
{formatNumber(creative.placements_count)}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="text-right">
|
|
||||||
{formatCurrency(creative.total_cost)}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="text-right">
|
|
||||||
{formatNumber(creative.total_subscriptions)}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="text-right">
|
|
||||||
{formatNumber(creative.total_views)}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="text-right">
|
|
||||||
{creative.avg_cpf !== null
|
|
||||||
? formatCurrency(creative.avg_cpf)
|
|
||||||
: "—"}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="text-right">
|
|
||||||
{creative.avg_cpm !== null
|
|
||||||
? formatCurrency(creative.avg_cpm)
|
|
||||||
: "—"}
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
))}
|
|
||||||
</TableBody>
|
|
||||||
</Table>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
) : (
|
|
||||||
creatives.length === 0 &&
|
|
||||||
!loadingData && (
|
|
||||||
<Alert>
|
|
||||||
<AlertCircle className="h-4 w-4" />
|
|
||||||
<AlertDescription>
|
|
||||||
Нет данных по креативам. Примените фильтры для отображения
|
|
||||||
данных.
|
|
||||||
</AlertDescription>
|
|
||||||
</Alert>
|
|
||||||
)
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -1,242 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// Analytics Overview Page
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
import React, { useEffect, useState } from "react";
|
|
||||||
import { DashboardHeader } from "@/components/layout/dashboard-header";
|
|
||||||
import {
|
|
||||||
Card,
|
|
||||||
CardContent,
|
|
||||||
CardDescription,
|
|
||||||
CardHeader,
|
|
||||||
CardTitle,
|
|
||||||
} from "@/components/ui/card";
|
|
||||||
import { Badge } from "@/components/ui/badge";
|
|
||||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
|
||||||
import { analyticsApi } from "@/lib/api";
|
|
||||||
import {
|
|
||||||
formatNumber,
|
|
||||||
formatMetric,
|
|
||||||
formatCurrency,
|
|
||||||
formatPercent,
|
|
||||||
} from "@/lib/utils/format";
|
|
||||||
import {
|
|
||||||
BarChart3,
|
|
||||||
Loader2,
|
|
||||||
AlertCircle,
|
|
||||||
TrendingUp,
|
|
||||||
TrendingDown,
|
|
||||||
Users,
|
|
||||||
ShoppingCart,
|
|
||||||
DollarSign,
|
|
||||||
} from "lucide-react";
|
|
||||||
import type { SpendingAnalytics } from "@/lib/types/api";
|
|
||||||
import { useTargetChannel } from "@/components/providers/target-channel-provider";
|
|
||||||
import {
|
|
||||||
LineChart,
|
|
||||||
Line,
|
|
||||||
BarChart,
|
|
||||||
Bar,
|
|
||||||
XAxis,
|
|
||||||
YAxis,
|
|
||||||
CartesianGrid,
|
|
||||||
Tooltip,
|
|
||||||
Legend,
|
|
||||||
ResponsiveContainer,
|
|
||||||
} from "recharts";
|
|
||||||
|
|
||||||
export default function AnalyticsPage() {
|
|
||||||
const { selectedChannel } = useTargetChannel();
|
|
||||||
const [spending, setSpending] = useState<SpendingAnalytics | null>(null);
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (selectedChannel) {
|
|
||||||
loadData();
|
|
||||||
} else {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
}, [selectedChannel]);
|
|
||||||
|
|
||||||
const loadData = async () => {
|
|
||||||
if (!selectedChannel) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
setLoading(true);
|
|
||||||
const data = await analyticsApi.spending({
|
|
||||||
grouping: "week",
|
|
||||||
target_channel_id: selectedChannel.id,
|
|
||||||
});
|
|
||||||
setSpending(data);
|
|
||||||
} catch (err: any) {
|
|
||||||
setError(err?.error?.message || "Ошибка загрузки аналитики");
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if (loading) {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<DashboardHeader
|
|
||||||
breadcrumbs={[
|
|
||||||
{ label: "Главная", href: "/" },
|
|
||||||
{ label: "Аналитика" },
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
<div className="flex flex-1 items-center justify-center">
|
|
||||||
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (error || !spending) {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<DashboardHeader
|
|
||||||
breadcrumbs={[
|
|
||||||
{ label: "Главная", href: "/" },
|
|
||||||
{ label: "Аналитика" },
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
<div className="flex flex-1 flex-col gap-4 p-4 pt-0 mt-4">
|
|
||||||
<Alert variant="destructive">
|
|
||||||
<AlertCircle className="h-4 w-4" />
|
|
||||||
<AlertDescription>
|
|
||||||
{error || "Не удалось загрузить аналитику"}
|
|
||||||
</AlertDescription>
|
|
||||||
</Alert>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<DashboardHeader
|
|
||||||
breadcrumbs={[
|
|
||||||
{ label: "Главная", href: "/" },
|
|
||||||
{ label: "Аналитика" },
|
|
||||||
{ label: "Обзор" },
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
<div className="flex flex-1 flex-col gap-4 p-4 pt-0 mt-4">
|
|
||||||
<div>
|
|
||||||
<h1 className="text-3xl font-bold tracking-tight">Аналитика</h1>
|
|
||||||
<p className="text-muted-foreground mt-1">
|
|
||||||
Сводная статистика за последний месяц
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
|
||||||
<Card>
|
|
||||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
|
||||||
<CardTitle className="text-sm font-medium">
|
|
||||||
Всего затрат
|
|
||||||
</CardTitle>
|
|
||||||
<DollarSign className="h-4 w-4 text-muted-foreground" />
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<div className="text-2xl font-bold">
|
|
||||||
{formatCurrency(spending.total_cost)}
|
|
||||||
</div>
|
|
||||||
<p className="text-xs text-muted-foreground mt-1">
|
|
||||||
За выбранный период
|
|
||||||
</p>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card>
|
|
||||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
|
||||||
<CardTitle className="text-sm font-medium">
|
|
||||||
Всего подписчиков
|
|
||||||
</CardTitle>
|
|
||||||
<Users className="h-4 w-4 text-muted-foreground" />
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<div className="text-2xl font-bold">
|
|
||||||
{formatNumber(spending.total_subscriptions)}
|
|
||||||
</div>
|
|
||||||
<p className="text-xs text-muted-foreground mt-1">
|
|
||||||
Всего привлечено
|
|
||||||
</p>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card>
|
|
||||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
|
||||||
<CardTitle className="text-sm font-medium">Средний CPF</CardTitle>
|
|
||||||
<BarChart3 className="h-4 w-4 text-muted-foreground" />
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<div className="text-2xl font-bold">
|
|
||||||
{spending.avg_cpf ? `₽${formatMetric(spending.avg_cpf)}` : "—"}
|
|
||||||
</div>
|
|
||||||
<p className="text-xs text-muted-foreground mt-1">
|
|
||||||
Стоимость подписки
|
|
||||||
</p>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card>
|
|
||||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
|
||||||
<CardTitle className="text-sm font-medium">Средний CPM</CardTitle>
|
|
||||||
<BarChart3 className="h-4 w-4 text-muted-foreground" />
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<div className="text-2xl font-bold">
|
|
||||||
{spending.avg_cpm ? `₽${formatMetric(spending.avg_cpm)}` : "—"}
|
|
||||||
</div>
|
|
||||||
<p className="text-xs text-muted-foreground mt-1">
|
|
||||||
За 1000 просмотров
|
|
||||||
</p>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>Динамика затрат</CardTitle>
|
|
||||||
<CardDescription>Расходы по периодам</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<ResponsiveContainer width="100%" height={300}>
|
|
||||||
<LineChart data={spending.chart_data}>
|
|
||||||
<CartesianGrid strokeDasharray="3 3" className="stroke-muted" />
|
|
||||||
<XAxis
|
|
||||||
dataKey="period"
|
|
||||||
className="text-xs"
|
|
||||||
tick={{ fill: "hsl(var(--muted-foreground))" }}
|
|
||||||
/>
|
|
||||||
<YAxis
|
|
||||||
className="text-xs"
|
|
||||||
tick={{ fill: "hsl(var(--muted-foreground))" }}
|
|
||||||
tickFormatter={(value) => `₽${formatNumber(value)}`}
|
|
||||||
/>
|
|
||||||
<Tooltip
|
|
||||||
contentStyle={{
|
|
||||||
backgroundColor: "hsl(var(--background))",
|
|
||||||
border: "1px solid hsl(var(--border))",
|
|
||||||
borderRadius: "8px",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Line
|
|
||||||
type="monotone"
|
|
||||||
dataKey="cost"
|
|
||||||
stroke="hsl(var(--primary))"
|
|
||||||
strokeWidth={2}
|
|
||||||
name="Затраты"
|
|
||||||
/>
|
|
||||||
</LineChart>
|
|
||||||
</ResponsiveContainer>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,476 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import React, { useState, useEffect } from "react";
|
|
||||||
import { DashboardHeader } from "@/components/layout/dashboard-header";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
|
||||||
import { ChartConfigPanel } from "@/components/analytics/chart-config-panel";
|
|
||||||
import {
|
|
||||||
DndContext,
|
|
||||||
closestCenter,
|
|
||||||
KeyboardSensor,
|
|
||||||
PointerSensor,
|
|
||||||
useSensor,
|
|
||||||
useSensors,
|
|
||||||
DragEndEvent,
|
|
||||||
} from "@dnd-kit/core";
|
|
||||||
import {
|
|
||||||
arrayMove,
|
|
||||||
SortableContext,
|
|
||||||
sortableKeyboardCoordinates,
|
|
||||||
useSortable,
|
|
||||||
verticalListSortingStrategy,
|
|
||||||
} from "@dnd-kit/sortable";
|
|
||||||
import { CSS } from "@dnd-kit/utilities";
|
|
||||||
import {
|
|
||||||
Table,
|
|
||||||
TableBody,
|
|
||||||
TableCell,
|
|
||||||
TableHead,
|
|
||||||
TableHeader,
|
|
||||||
TableRow,
|
|
||||||
} from "@/components/ui/table";
|
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
|
||||||
import { channelsApi, analyticsApi } from "@/lib/api";
|
|
||||||
import { formatCurrency, formatNumber } from "@/lib/utils/format";
|
|
||||||
import { Plus, Loader2, AlertCircle, TrendingUp } from "lucide-react";
|
|
||||||
import type {
|
|
||||||
TargetChannel,
|
|
||||||
SpendingAnalytics,
|
|
||||||
AnalyticsMetric,
|
|
||||||
DateGrouping,
|
|
||||||
SpendingDataPoint,
|
|
||||||
} from "@/lib/types/api";
|
|
||||||
import { format } from "date-fns";
|
|
||||||
|
|
||||||
interface ChartData {
|
|
||||||
id: string;
|
|
||||||
config: {
|
|
||||||
targetChannelIds: string[];
|
|
||||||
metrics: AnalyticsMetric[];
|
|
||||||
dateFrom: Date | null;
|
|
||||||
dateTo: Date | null;
|
|
||||||
grouping: DateGrouping;
|
|
||||||
};
|
|
||||||
data: SpendingAnalytics | null;
|
|
||||||
loading: boolean;
|
|
||||||
width: "full" | "half";
|
|
||||||
}
|
|
||||||
|
|
||||||
// Компонент для sortable item
|
|
||||||
interface SortableChartItemProps {
|
|
||||||
chart: ChartData;
|
|
||||||
index: number;
|
|
||||||
channels: TargetChannel[];
|
|
||||||
chartsLength: number;
|
|
||||||
onBuild: (chartId: string, config: ChartData["config"]) => void;
|
|
||||||
onRemove: (chartId: string) => void;
|
|
||||||
onWidthChange: (chartId: string, width: "full" | "half") => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const SortableChartItem: React.FC<SortableChartItemProps> = ({
|
|
||||||
chart,
|
|
||||||
index,
|
|
||||||
channels,
|
|
||||||
chartsLength,
|
|
||||||
onBuild,
|
|
||||||
onRemove,
|
|
||||||
onWidthChange,
|
|
||||||
}) => {
|
|
||||||
const {
|
|
||||||
attributes,
|
|
||||||
listeners,
|
|
||||||
setNodeRef,
|
|
||||||
transform,
|
|
||||||
transition,
|
|
||||||
isDragging,
|
|
||||||
} = useSortable({ id: chart.id });
|
|
||||||
|
|
||||||
const style = {
|
|
||||||
transform: CSS.Transform.toString(transform),
|
|
||||||
transition,
|
|
||||||
opacity: isDragging ? 0.5 : 1,
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div ref={setNodeRef} style={style} className={chart.width === "half" ? "" : "col-span-full"}>
|
|
||||||
<ChartConfigPanel
|
|
||||||
channels={channels}
|
|
||||||
data={chart.data}
|
|
||||||
loading={chart.loading}
|
|
||||||
width={chart.width}
|
|
||||||
onBuild={(config) => onBuild(chart.id, config)}
|
|
||||||
onRemove={chartsLength > 1 ? () => onRemove(chart.id) : undefined}
|
|
||||||
onWidthChange={(width) => onWidthChange(chart.id, width)}
|
|
||||||
showRemoveButton={chartsLength > 1}
|
|
||||||
chartNumber={index + 1}
|
|
||||||
dragHandleProps={{ ...attributes, ...listeners }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function TargetChannelsAnalyticsPage() {
|
|
||||||
const [channels, setChannels] = useState<TargetChannel[]>([]);
|
|
||||||
const [charts, setCharts] = useState<ChartData[]>([
|
|
||||||
{
|
|
||||||
id: "chart-1",
|
|
||||||
config: {
|
|
||||||
targetChannelIds: [],
|
|
||||||
metrics: [],
|
|
||||||
dateFrom: null,
|
|
||||||
dateTo: null,
|
|
||||||
grouping: "day",
|
|
||||||
},
|
|
||||||
data: null,
|
|
||||||
loading: false,
|
|
||||||
width: "full",
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
|
|
||||||
const sensors = useSensors(
|
|
||||||
useSensor(PointerSensor),
|
|
||||||
useSensor(KeyboardSensor, {
|
|
||||||
coordinateGetter: sortableKeyboardCoordinates,
|
|
||||||
})
|
|
||||||
);
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
loadChannels();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const loadChannels = async () => {
|
|
||||||
try {
|
|
||||||
setLoading(true);
|
|
||||||
const response = await channelsApi.list();
|
|
||||||
const activeChannels = response.target_channels.filter((c) => c.is_active);
|
|
||||||
setChannels(activeChannels);
|
|
||||||
} catch (err: any) {
|
|
||||||
setError(err?.error?.message || "Ошибка загрузки каналов");
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleBuildChart = async (
|
|
||||||
chartId: string,
|
|
||||||
config: ChartData["config"]
|
|
||||||
) => {
|
|
||||||
const chartIndex = charts.findIndex((c) => c.id === chartId);
|
|
||||||
if (chartIndex === -1) return;
|
|
||||||
|
|
||||||
// Обновляем состояние графика
|
|
||||||
const updatedCharts = [...charts];
|
|
||||||
updatedCharts[chartIndex].loading = true;
|
|
||||||
updatedCharts[chartIndex].config = config;
|
|
||||||
setCharts(updatedCharts);
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Загружаем данные для каждого канала
|
|
||||||
const promises = config.targetChannelIds.map((channelId) =>
|
|
||||||
analyticsApi.spending({
|
|
||||||
target_channel_id: channelId,
|
|
||||||
date_from: config.dateFrom
|
|
||||||
? format(config.dateFrom, "yyyy-MM-dd'T'HH:mm:ss'Z'")
|
|
||||||
: undefined,
|
|
||||||
date_to: config.dateTo
|
|
||||||
? format(config.dateTo, "yyyy-MM-dd'T'HH:mm:ss'Z'")
|
|
||||||
: undefined,
|
|
||||||
grouping: config.grouping,
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
const results = await Promise.all(promises);
|
|
||||||
|
|
||||||
// Объединяем данные (если несколько каналов)
|
|
||||||
const combinedData: SpendingAnalytics = results.reduce(
|
|
||||||
(acc, result) => {
|
|
||||||
acc.total_cost += result.total_cost;
|
|
||||||
acc.total_subscriptions += result.total_subscriptions;
|
|
||||||
acc.total_views += result.total_views;
|
|
||||||
|
|
||||||
// Объединяем chart_data по периодам
|
|
||||||
result.chart_data.forEach((dataPoint) => {
|
|
||||||
const existing = acc.chart_data.find(
|
|
||||||
(d) => d.period === dataPoint.period
|
|
||||||
);
|
|
||||||
if (existing) {
|
|
||||||
existing.cost += dataPoint.cost;
|
|
||||||
existing.subscriptions += dataPoint.subscriptions;
|
|
||||||
existing.views += dataPoint.views;
|
|
||||||
// CPF и CPM пересчитаем после
|
|
||||||
} else {
|
|
||||||
acc.chart_data.push({ ...dataPoint });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return acc;
|
|
||||||
},
|
|
||||||
{
|
|
||||||
total_cost: 0,
|
|
||||||
total_subscriptions: 0,
|
|
||||||
total_views: 0,
|
|
||||||
avg_cpf: null,
|
|
||||||
avg_cpm: null,
|
|
||||||
chart_data: [] as SpendingDataPoint[],
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
// Пересчитываем CPF и CPM для каждого периода
|
|
||||||
combinedData.chart_data.forEach((dataPoint) => {
|
|
||||||
dataPoint.cpf =
|
|
||||||
dataPoint.subscriptions > 0
|
|
||||||
? dataPoint.cost / dataPoint.subscriptions
|
|
||||||
: null;
|
|
||||||
dataPoint.cpm =
|
|
||||||
dataPoint.views > 0 ? (dataPoint.cost / dataPoint.views) * 1000 : null;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Пересчитываем средние значения
|
|
||||||
combinedData.avg_cpf =
|
|
||||||
combinedData.total_subscriptions > 0
|
|
||||||
? combinedData.total_cost / combinedData.total_subscriptions
|
|
||||||
: null;
|
|
||||||
combinedData.avg_cpm =
|
|
||||||
combinedData.total_views > 0
|
|
||||||
? (combinedData.total_cost / combinedData.total_views) * 1000
|
|
||||||
: null;
|
|
||||||
|
|
||||||
// Сортируем по периодам
|
|
||||||
combinedData.chart_data.sort((a, b) =>
|
|
||||||
a.period.localeCompare(b.period)
|
|
||||||
);
|
|
||||||
|
|
||||||
// Обновляем данные графика
|
|
||||||
const finalCharts = [...updatedCharts];
|
|
||||||
finalCharts[chartIndex].data = combinedData;
|
|
||||||
finalCharts[chartIndex].loading = false;
|
|
||||||
setCharts(finalCharts);
|
|
||||||
} catch (err: any) {
|
|
||||||
alert(err?.error?.message || "Ошибка загрузки данных");
|
|
||||||
const finalCharts = [...updatedCharts];
|
|
||||||
finalCharts[chartIndex].loading = false;
|
|
||||||
setCharts(finalCharts);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleAddChart = () => {
|
|
||||||
setCharts([
|
|
||||||
...charts,
|
|
||||||
{
|
|
||||||
id: `chart-${Date.now()}`,
|
|
||||||
config: {
|
|
||||||
targetChannelIds: [],
|
|
||||||
metrics: [],
|
|
||||||
dateFrom: null,
|
|
||||||
dateTo: null,
|
|
||||||
grouping: "day",
|
|
||||||
},
|
|
||||||
data: null,
|
|
||||||
loading: false,
|
|
||||||
width: "full",
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleRemoveChart = (chartId: string) => {
|
|
||||||
if (charts.length === 1) {
|
|
||||||
alert("Должен остаться хотя бы один график");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setCharts(charts.filter((c) => c.id !== chartId));
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleWidthChange = (chartId: string, width: "full" | "half") => {
|
|
||||||
setCharts(
|
|
||||||
charts.map((chart) =>
|
|
||||||
chart.id === chartId ? { ...chart, width } : chart
|
|
||||||
)
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDragEnd = (event: DragEndEvent) => {
|
|
||||||
const { active, over } = event;
|
|
||||||
|
|
||||||
if (over && active.id !== over.id) {
|
|
||||||
setCharts((items) => {
|
|
||||||
const oldIndex = items.findIndex((item) => item.id === active.id);
|
|
||||||
const newIndex = items.findIndex((item) => item.id === over.id);
|
|
||||||
|
|
||||||
return arrayMove(items, oldIndex, newIndex);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Собираем все данные для таблицы
|
|
||||||
const getAllTableData = (): SpendingDataPoint[] => {
|
|
||||||
const allData: SpendingDataPoint[] = [];
|
|
||||||
const periodMap = new Map<string, SpendingDataPoint>();
|
|
||||||
|
|
||||||
charts.forEach((chart) => {
|
|
||||||
if (!chart.data) return;
|
|
||||||
chart.data.chart_data.forEach((dataPoint) => {
|
|
||||||
const existing = periodMap.get(dataPoint.period);
|
|
||||||
if (existing) {
|
|
||||||
existing.cost += dataPoint.cost;
|
|
||||||
existing.subscriptions += dataPoint.subscriptions;
|
|
||||||
existing.views += dataPoint.views;
|
|
||||||
// Пересчитываем CPF и CPM
|
|
||||||
existing.cpf =
|
|
||||||
existing.subscriptions > 0
|
|
||||||
? existing.cost / existing.subscriptions
|
|
||||||
: null;
|
|
||||||
existing.cpm =
|
|
||||||
existing.views > 0 ? (existing.cost / existing.views) * 1000 : null;
|
|
||||||
} else {
|
|
||||||
periodMap.set(dataPoint.period, { ...dataPoint });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
return Array.from(periodMap.values()).sort((a, b) =>
|
|
||||||
a.period.localeCompare(b.period)
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const tableData = getAllTableData();
|
|
||||||
|
|
||||||
if (loading) {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<DashboardHeader
|
|
||||||
breadcrumbs={[
|
|
||||||
{ label: "Аналитика", href: "/analytics" },
|
|
||||||
{ label: "Целевые каналы" },
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
<div className="flex items-center justify-center p-12">
|
|
||||||
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<DashboardHeader
|
|
||||||
breadcrumbs={[
|
|
||||||
{ label: "Аналитика", href: "/analytics" },
|
|
||||||
{ label: "Целевые каналы" },
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
<div className="flex flex-1 flex-col gap-4 p-4 pt-0 mt-4">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<h1 className="text-3xl font-bold tracking-tight">
|
|
||||||
Аналитика целевых каналов
|
|
||||||
</h1>
|
|
||||||
<p className="text-muted-foreground mt-1">
|
|
||||||
Создайте графики для анализа показателей
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<Button onClick={handleAddChart}>
|
|
||||||
<Plus className="mr-2 h-4 w-4" />
|
|
||||||
Добавить график
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{error && (
|
|
||||||
<Alert variant="destructive">
|
|
||||||
<AlertCircle className="h-4 w-4" />
|
|
||||||
<AlertDescription>{error}</AlertDescription>
|
|
||||||
</Alert>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{channels.length === 0 ? (
|
|
||||||
<Alert>
|
|
||||||
<AlertCircle className="h-4 w-4" />
|
|
||||||
<AlertDescription>
|
|
||||||
Нет активных целевых каналов. Добавьте канал для начала работы с
|
|
||||||
аналитикой.
|
|
||||||
</AlertDescription>
|
|
||||||
</Alert>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
{/* Графики */}
|
|
||||||
<DndContext
|
|
||||||
sensors={sensors}
|
|
||||||
collisionDetection={closestCenter}
|
|
||||||
onDragEnd={handleDragEnd}
|
|
||||||
>
|
|
||||||
<SortableContext
|
|
||||||
items={charts.map((c) => c.id)}
|
|
||||||
strategy={verticalListSortingStrategy}
|
|
||||||
>
|
|
||||||
<div className="grid grid-cols-2 gap-6">
|
|
||||||
{charts.map((chart, index) => (
|
|
||||||
<SortableChartItem
|
|
||||||
key={chart.id}
|
|
||||||
chart={chart}
|
|
||||||
index={index}
|
|
||||||
channels={channels}
|
|
||||||
chartsLength={charts.length}
|
|
||||||
onBuild={handleBuildChart}
|
|
||||||
onRemove={handleRemoveChart}
|
|
||||||
onWidthChange={handleWidthChange}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</SortableContext>
|
|
||||||
</DndContext>
|
|
||||||
|
|
||||||
{/* Таблица */}
|
|
||||||
{tableData.length > 0 && (
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>Табличное представление</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<Table>
|
|
||||||
<TableHeader>
|
|
||||||
<TableRow>
|
|
||||||
<TableHead>Период</TableHead>
|
|
||||||
<TableHead className="text-right">Расходы</TableHead>
|
|
||||||
<TableHead className="text-right">Подписки</TableHead>
|
|
||||||
<TableHead className="text-right">Просмотры</TableHead>
|
|
||||||
<TableHead className="text-right">CPF</TableHead>
|
|
||||||
<TableHead className="text-right">CPM</TableHead>
|
|
||||||
</TableRow>
|
|
||||||
</TableHeader>
|
|
||||||
<TableBody>
|
|
||||||
{tableData.map((row) => (
|
|
||||||
<TableRow key={row.period}>
|
|
||||||
<TableCell className="font-medium">
|
|
||||||
{row.period}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="text-right">
|
|
||||||
{formatCurrency(row.cost)}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="text-right">
|
|
||||||
{formatNumber(row.subscriptions)}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="text-right">
|
|
||||||
{formatNumber(row.views)}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="text-right">
|
|
||||||
{row.cpf !== null ? formatCurrency(row.cpf) : "—"}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="text-right">
|
|
||||||
{row.cpm !== null ? formatCurrency(row.cpm) : "—"}
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
))}
|
|
||||||
</TableBody>
|
|
||||||
</Table>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// Target Channel Detail Page
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
import React, { useEffect } from "react";
|
|
||||||
import { useRouter } from "next/navigation";
|
|
||||||
import { use } from "react";
|
|
||||||
|
|
||||||
interface PageProps {
|
|
||||||
params: Promise<{ id: string }>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function ChannelDetailPage({ params }: PageProps) {
|
|
||||||
const router = useRouter();
|
|
||||||
const resolvedParams = use(params);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
// Redirect back to channels list
|
|
||||||
// Detail page functionality not yet implemented in backend API
|
|
||||||
router.push("/channels");
|
|
||||||
}, [router, resolvedParams.id]);
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
@@ -1,249 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// Target Channels List Page
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
import React, { useEffect, useState } from "react";
|
|
||||||
import Link from "next/link";
|
|
||||||
import { DashboardHeader } from "@/components/layout/dashboard-header";
|
|
||||||
import {
|
|
||||||
Card,
|
|
||||||
CardContent,
|
|
||||||
CardDescription,
|
|
||||||
CardHeader,
|
|
||||||
CardTitle,
|
|
||||||
} from "@/components/ui/card";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { Badge } from "@/components/ui/badge";
|
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
|
||||||
import { channelsApi } from "@/lib/api";
|
|
||||||
import { formatNumber, formatMetric, formatUsername } from "@/lib/utils/format";
|
|
||||||
import {
|
|
||||||
Target,
|
|
||||||
Users,
|
|
||||||
TrendingUp,
|
|
||||||
ExternalLink as ExternalLinkIcon,
|
|
||||||
Loader2,
|
|
||||||
AlertCircle,
|
|
||||||
Plus,
|
|
||||||
Eye,
|
|
||||||
} from "lucide-react";
|
|
||||||
import type { TargetChannel } from "@/lib/types/api";
|
|
||||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
|
||||||
|
|
||||||
export default function ChannelsPage() {
|
|
||||||
const [channels, setChannels] = useState<TargetChannel[]>([]);
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
const [activeTab, setActiveTab] = useState<"all" | "active" | "inactive">(
|
|
||||||
"all"
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const loadChannels = async () => {
|
|
||||||
try {
|
|
||||||
setLoading(true);
|
|
||||||
const response = await channelsApi.list();
|
|
||||||
setChannels(response.target_channels);
|
|
||||||
} catch (err: any) {
|
|
||||||
setError(
|
|
||||||
err?.error?.message || err?.detail || "Ошибка загрузки каналов"
|
|
||||||
);
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
loadChannels();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const filteredChannels = channels.filter((channel) => {
|
|
||||||
if (activeTab === "active") return channel.is_active;
|
|
||||||
if (activeTab === "inactive") return !channel.is_active;
|
|
||||||
return true;
|
|
||||||
});
|
|
||||||
|
|
||||||
const handleDisconnect = async (id: string, title: string) => {
|
|
||||||
if (!confirm(`Вы уверены, что хотите отключить канал "${title}"?`)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
await channelsApi.delete(id);
|
|
||||||
setChannels((prev) => prev.filter((ch) => ch.id !== id));
|
|
||||||
} catch (err: any) {
|
|
||||||
alert(err?.error?.message || err?.detail || "Ошибка отключения канала");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<DashboardHeader
|
|
||||||
breadcrumbs={[
|
|
||||||
{ label: "Главная", href: "/" },
|
|
||||||
{ label: "Целевые каналы" },
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
<div className="flex flex-1 flex-col gap-4 p-4 pt-0 mt-4">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<h1 className="text-3xl font-bold tracking-tight">
|
|
||||||
Целевые каналы
|
|
||||||
</h1>
|
|
||||||
<p className="text-muted-foreground mt-1">
|
|
||||||
Каналы, в которые вы приводите подписчиков
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<CardTitle>Подключение нового канала</CardTitle>
|
|
||||||
<CardDescription>
|
|
||||||
Добавьте бота в свой Telegram-канал с правами администратора
|
|
||||||
</CardDescription>
|
|
||||||
</div>
|
|
||||||
<Button asChild>
|
|
||||||
<a
|
|
||||||
href="https://t.me/tgex_bot?startgroup=admin"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
>
|
|
||||||
<Plus className="mr-2 h-4 w-4" />
|
|
||||||
Подключить канал
|
|
||||||
</a>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<div className="rounded-lg border bg-muted/50 p-4">
|
|
||||||
<ol className="list-decimal list-inside space-y-2 text-sm">
|
|
||||||
<li>Откройте свой Telegram-канал</li>
|
|
||||||
<li>Добавьте @tgex_bot в администраторы канала</li>
|
|
||||||
<li>
|
|
||||||
Предоставьте боту права:{" "}
|
|
||||||
<strong>создавать инвайт-ссылки</strong> и{" "}
|
|
||||||
<strong>видеть вступления</strong>
|
|
||||||
</li>
|
|
||||||
<li>Канал автоматически появится в списке ниже</li>
|
|
||||||
</ol>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{error && (
|
|
||||||
<Alert variant="destructive">
|
|
||||||
<AlertCircle className="h-4 w-4" />
|
|
||||||
<AlertDescription>{error}</AlertDescription>
|
|
||||||
</Alert>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Tabs
|
|
||||||
value={activeTab}
|
|
||||||
onValueChange={(v) => setActiveTab(v as any)}
|
|
||||||
className="w-full"
|
|
||||||
>
|
|
||||||
<TabsList>
|
|
||||||
<TabsTrigger value="all">Все ({channels.length})</TabsTrigger>
|
|
||||||
<TabsTrigger value="active">
|
|
||||||
Активные ({channels.filter((c) => c.is_active).length})
|
|
||||||
</TabsTrigger>
|
|
||||||
<TabsTrigger value="inactive">
|
|
||||||
Отключенные ({channels.filter((c) => !c.is_active).length})
|
|
||||||
</TabsTrigger>
|
|
||||||
</TabsList>
|
|
||||||
|
|
||||||
<TabsContent value={activeTab} className="mt-4">
|
|
||||||
{loading ? (
|
|
||||||
<div className="flex items-center justify-center p-12">
|
|
||||||
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
|
||||||
</div>
|
|
||||||
) : filteredChannels.length === 0 ? (
|
|
||||||
<Card>
|
|
||||||
<CardContent className="flex flex-col items-center justify-center p-12">
|
|
||||||
<Target className="h-12 w-12 text-muted-foreground mb-4" />
|
|
||||||
<p className="text-lg font-medium text-muted-foreground">
|
|
||||||
{activeTab === "all" && "Нет подключенных каналов"}
|
|
||||||
{activeTab === "active" && "Нет активных каналов"}
|
|
||||||
{activeTab === "inactive" && "Нет отключенных каналов"}
|
|
||||||
</p>
|
|
||||||
<p className="text-sm text-muted-foreground mt-1">
|
|
||||||
Добавьте бота в свой канал, чтобы начать работу
|
|
||||||
</p>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
) : (
|
|
||||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
|
||||||
{filteredChannels.map((channel) => (
|
|
||||||
<Card
|
|
||||||
key={channel.id}
|
|
||||||
className="hover:shadow-md transition-shadow"
|
|
||||||
>
|
|
||||||
<CardHeader>
|
|
||||||
<div className="flex items-start justify-between">
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<CardTitle className="truncate flex items-center gap-2">
|
|
||||||
<Target className="h-4 w-4 shrink-0" />
|
|
||||||
{channel.title}
|
|
||||||
</CardTitle>
|
|
||||||
<CardDescription className="truncate mt-1">
|
|
||||||
{channel.username ? (
|
|
||||||
<a
|
|
||||||
href={`https://t.me/${channel.username.replace(
|
|
||||||
"@",
|
|
||||||
""
|
|
||||||
)}`}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="hover:underline"
|
|
||||||
>
|
|
||||||
{formatUsername(channel.username)}
|
|
||||||
</a>
|
|
||||||
) : (
|
|
||||||
<span className="text-muted-foreground">
|
|
||||||
Приватный канал
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</CardDescription>
|
|
||||||
</div>
|
|
||||||
<Badge
|
|
||||||
variant={channel.is_active ? "default" : "secondary"}
|
|
||||||
>
|
|
||||||
{channel.is_active ? "Активен" : "Отключен"}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-3">
|
|
||||||
<div className="text-sm text-muted-foreground">
|
|
||||||
Telegram ID:{" "}
|
|
||||||
<code className="text-xs bg-muted px-1 rounded">
|
|
||||||
{channel.telegram_id}
|
|
||||||
</code>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex gap-2 pt-2">
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={() =>
|
|
||||||
handleDisconnect(channel.id, channel.title)
|
|
||||||
}
|
|
||||||
className="flex-1"
|
|
||||||
>
|
|
||||||
Отключить канал
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</TabsContent>
|
|
||||||
</Tabs>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,286 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// Creative Detail Page
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
import React, { useEffect, useState } from "react";
|
|
||||||
import { use } from "react";
|
|
||||||
import Link from "next/link";
|
|
||||||
import { useRouter } from "next/navigation";
|
|
||||||
import { DashboardHeader } from "@/components/layout/dashboard-header";
|
|
||||||
import {
|
|
||||||
Card,
|
|
||||||
CardContent,
|
|
||||||
CardDescription,
|
|
||||||
CardHeader,
|
|
||||||
CardTitle,
|
|
||||||
} from "@/components/ui/card";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { Badge } from "@/components/ui/badge";
|
|
||||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
|
||||||
import {
|
|
||||||
Table,
|
|
||||||
TableBody,
|
|
||||||
TableCell,
|
|
||||||
TableHead,
|
|
||||||
TableHeader,
|
|
||||||
TableRow,
|
|
||||||
} from "@/components/ui/table";
|
|
||||||
import { creativesApi } from "@/lib/api";
|
|
||||||
import {
|
|
||||||
formatNumber,
|
|
||||||
formatMetric,
|
|
||||||
formatCurrency,
|
|
||||||
formatDate,
|
|
||||||
} from "@/lib/utils/format";
|
|
||||||
import {
|
|
||||||
Folder,
|
|
||||||
ArrowLeft,
|
|
||||||
Loader2,
|
|
||||||
AlertCircle,
|
|
||||||
Archive,
|
|
||||||
Trash2,
|
|
||||||
BarChart3,
|
|
||||||
Users,
|
|
||||||
TrendingUp,
|
|
||||||
} from "lucide-react";
|
|
||||||
import type { Creative } from "@/lib/types/api";
|
|
||||||
|
|
||||||
interface PageProps {
|
|
||||||
params: Promise<{ id: string }>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function CreativeDetailPage({ params }: PageProps) {
|
|
||||||
const resolvedParams = use(params);
|
|
||||||
const router = useRouter();
|
|
||||||
const [creative, setCreative] = useState<Creative | null>(null);
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const loadCreative = async () => {
|
|
||||||
try {
|
|
||||||
setLoading(true);
|
|
||||||
const data = await creativesApi.get(resolvedParams.id);
|
|
||||||
setCreative(data);
|
|
||||||
} catch (err: any) {
|
|
||||||
setError(err?.error?.message || "Ошибка загрузки креатива");
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
loadCreative();
|
|
||||||
}, [resolvedParams.id]);
|
|
||||||
|
|
||||||
const handleArchive = async () => {
|
|
||||||
if (!creative) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
await creativesApi.update(creative.id, {
|
|
||||||
status: creative.status === "active" ? "archived" : "active",
|
|
||||||
});
|
|
||||||
setCreative({
|
|
||||||
...creative,
|
|
||||||
status: creative.status === "active" ? "archived" : "active",
|
|
||||||
});
|
|
||||||
} catch (err: any) {
|
|
||||||
alert(err?.error?.message || "Ошибка обновления креатива");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDelete = async () => {
|
|
||||||
if (!creative) return;
|
|
||||||
if (!confirm(`Удалить креатив "${creative.name}"?`)) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
await creativesApi.delete(creative.id);
|
|
||||||
router.push("/creatives");
|
|
||||||
} catch (err: any) {
|
|
||||||
alert(err?.error?.message || "Ошибка удаления креатива");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if (loading) {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<DashboardHeader
|
|
||||||
breadcrumbs={[
|
|
||||||
{ label: "Креативы", href: "/creatives" },
|
|
||||||
{ label: "Загрузка..." },
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
<div className="flex flex-1 items-center justify-center">
|
|
||||||
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (error || !creative) {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<DashboardHeader
|
|
||||||
breadcrumbs={[
|
|
||||||
{ label: "Креативы", href: "/creatives" },
|
|
||||||
{ label: "Ошибка" },
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
<div className="flex flex-1 flex-col gap-4 p-4 pt-0 mt-4">
|
|
||||||
<Alert variant="destructive">
|
|
||||||
<AlertCircle className="h-4 w-4" />
|
|
||||||
<AlertDescription>{error || "Креатив не найден"}</AlertDescription>
|
|
||||||
</Alert>
|
|
||||||
<Button asChild variant="outline">
|
|
||||||
<Link href="/creatives">
|
|
||||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
|
||||||
Вернуться к креативам
|
|
||||||
</Link>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const previewText = creative.text.replace(
|
|
||||||
"{link}",
|
|
||||||
"https://t.me/+InviteLinkExample"
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<DashboardHeader
|
|
||||||
breadcrumbs={[
|
|
||||||
{ label: "Главная", href: "/" },
|
|
||||||
{ label: "Креативы", href: "/creatives" },
|
|
||||||
{ label: creative.name },
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
<div className="flex flex-1 flex-col gap-4 p-4 pt-0 mt-4">
|
|
||||||
<div className="flex items-start justify-between">
|
|
||||||
<div className="flex-1">
|
|
||||||
<div className="flex items-center gap-2 mb-2">
|
|
||||||
<Folder className="h-6 w-6" />
|
|
||||||
<h1 className="text-3xl font-bold tracking-tight">
|
|
||||||
{creative.name}
|
|
||||||
</h1>
|
|
||||||
{creative.status === "archived" && (
|
|
||||||
<Badge variant="secondary">Архив</Badge>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="text-sm text-muted-foreground">
|
|
||||||
Целевой канал: {creative.target_channel_title}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<Button variant="outline" onClick={handleArchive}>
|
|
||||||
<Archive className="mr-2 h-4 w-4" />
|
|
||||||
{creative.status === "archived"
|
|
||||||
? "Разархивировать"
|
|
||||||
: "Архивировать"}
|
|
||||||
</Button>
|
|
||||||
<Button variant="destructive" onClick={handleDelete}>
|
|
||||||
<Trash2 className="mr-2 h-4 w-4" />
|
|
||||||
Удалить
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid gap-4 md:grid-cols-3">
|
|
||||||
<Card>
|
|
||||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
|
||||||
<CardTitle className="text-sm font-medium">
|
|
||||||
Всего закупов
|
|
||||||
</CardTitle>
|
|
||||||
<BarChart3 className="h-4 w-4 text-muted-foreground" />
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<div className="text-2xl font-bold">
|
|
||||||
{formatNumber(creative.placements_count)}
|
|
||||||
</div>
|
|
||||||
<p className="text-xs text-muted-foreground mt-1">
|
|
||||||
Всего размещений
|
|
||||||
</p>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card>
|
|
||||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
|
||||||
<CardTitle className="text-sm font-medium">
|
|
||||||
Целевой канал
|
|
||||||
</CardTitle>
|
|
||||||
<Users className="h-4 w-4 text-muted-foreground" />
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<div className="text-lg font-medium">
|
|
||||||
{creative.target_channel_title}
|
|
||||||
</div>
|
|
||||||
<p className="text-xs text-muted-foreground mt-1">
|
|
||||||
Канал для рекламы
|
|
||||||
</p>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid gap-4 md:grid-cols-2">
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>Текст креатива</CardTitle>
|
|
||||||
<CardDescription>Шаблон с переменной {"{link}"}</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<div className="rounded-lg border bg-muted/50 p-4">
|
|
||||||
<pre className="text-sm whitespace-pre-wrap font-sans">
|
|
||||||
{creative.text}
|
|
||||||
</pre>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>Предпросмотр</CardTitle>
|
|
||||||
<CardDescription>
|
|
||||||
С подставленной пригласительной ссылкой
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<div className="rounded-lg border bg-muted/50 p-4">
|
|
||||||
<p className="text-sm whitespace-pre-wrap">{previewText}</p>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>Информация о размещениях</CardTitle>
|
|
||||||
<CardDescription>Статистика использования креатива</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div>
|
|
||||||
<h4 className="text-sm font-medium mb-2">Всего размещений:</h4>
|
|
||||||
<p className="text-2xl font-bold">
|
|
||||||
{creative.placements_count}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h4 className="text-sm font-medium mb-2">Создан:</h4>
|
|
||||||
<p className="text-sm">{formatDate(creative.created_at)}</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<Button asChild variant="outline" className="w-full">
|
|
||||||
<Link href={`/purchases?creative_id=${creative.id}`}>
|
|
||||||
Посмотреть все размещения
|
|
||||||
</Link>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,227 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// Create Creative Page
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
import React, { useEffect, useState } from "react";
|
|
||||||
import { useRouter } from "next/navigation";
|
|
||||||
import { DashboardHeader } from "@/components/layout/dashboard-header";
|
|
||||||
import {
|
|
||||||
Card,
|
|
||||||
CardContent,
|
|
||||||
CardDescription,
|
|
||||||
CardHeader,
|
|
||||||
CardTitle,
|
|
||||||
} from "@/components/ui/card";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { Input } from "@/components/ui/input";
|
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
|
||||||
import { Label } from "@/components/ui/label";
|
|
||||||
import {
|
|
||||||
Select,
|
|
||||||
SelectContent,
|
|
||||||
SelectItem,
|
|
||||||
SelectTrigger,
|
|
||||||
SelectValue,
|
|
||||||
} from "@/components/ui/select";
|
|
||||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
|
||||||
import { creativesApi, channelsApi } from "@/lib/api";
|
|
||||||
import { AlertCircle, Loader2, ArrowLeft, Info } from "lucide-react";
|
|
||||||
import type { TargetChannel } from "@/lib/types/api";
|
|
||||||
import { useTargetChannel } from "@/components/providers/target-channel-provider";
|
|
||||||
|
|
||||||
export default function CreateCreativePage() {
|
|
||||||
const router = useRouter();
|
|
||||||
const { selectedChannel } = useTargetChannel();
|
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
|
|
||||||
const [formData, setFormData] = useState({
|
|
||||||
name: "",
|
|
||||||
text: "",
|
|
||||||
});
|
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
|
||||||
e.preventDefault();
|
|
||||||
setError(null);
|
|
||||||
|
|
||||||
// Validation
|
|
||||||
if (!formData.name.trim()) {
|
|
||||||
setError("Введите название креатива");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!formData.text.trim()) {
|
|
||||||
setError("Введите текст креатива");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!formData.text.includes("{link}")) {
|
|
||||||
setError("Текст должен содержать переменную {link} для вставки ссылки");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!selectedChannel) {
|
|
||||||
setError("Выберите целевой канал в верхнем меню");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
setIsLoading(true);
|
|
||||||
await creativesApi.create({
|
|
||||||
...formData,
|
|
||||||
target_channel_id: selectedChannel.id,
|
|
||||||
});
|
|
||||||
router.push("/creatives");
|
|
||||||
} catch (err: any) {
|
|
||||||
setError(err?.error?.message || "Ошибка создания креатива");
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const previewText = formData.text.replace(
|
|
||||||
"{link}",
|
|
||||||
"https://t.me/+InviteLinkExample"
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<DashboardHeader
|
|
||||||
breadcrumbs={[
|
|
||||||
{ label: "Главная", href: "/" },
|
|
||||||
{ label: "Креативы", href: "/creatives" },
|
|
||||||
{ label: "Создание" },
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
<div className="flex flex-1 flex-col gap-4 p-4 pt-0 mt-4 max-w-4xl mx-auto w-full">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<h1 className="text-3xl font-bold tracking-tight">
|
|
||||||
Создать креатив
|
|
||||||
</h1>
|
|
||||||
<p className="text-muted-foreground mt-1">
|
|
||||||
Новый шаблон рекламного сообщения
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<Button asChild variant="outline">
|
|
||||||
<a href="/creatives">
|
|
||||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
|
||||||
Назад
|
|
||||||
</a>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{error && (
|
|
||||||
<Alert variant="destructive">
|
|
||||||
<AlertCircle className="h-4 w-4" />
|
|
||||||
<AlertDescription>{error}</AlertDescription>
|
|
||||||
</Alert>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<form onSubmit={handleSubmit} className="space-y-4">
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>Основная информация</CardTitle>
|
|
||||||
<CardDescription>
|
|
||||||
Название и целевой канал креатива
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-4">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="name">Название креатива *</Label>
|
|
||||||
<Input
|
|
||||||
id="name"
|
|
||||||
value={formData.name}
|
|
||||||
onChange={(e) =>
|
|
||||||
setFormData({ ...formData, name: e.target.value })
|
|
||||||
}
|
|
||||||
placeholder='Например: Креатив "Присоединяйся"'
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{selectedChannel && (
|
|
||||||
<Alert>
|
|
||||||
<Info className="h-4 w-4" />
|
|
||||||
<AlertDescription>
|
|
||||||
Креатив будет создан для канала: <strong>{selectedChannel.title}</strong>
|
|
||||||
</AlertDescription>
|
|
||||||
</Alert>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>Текст сообщения</CardTitle>
|
|
||||||
<CardDescription>
|
|
||||||
Используйте переменную {"{link}"} для вставки ссылки
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-4">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="text">Текст креатива *</Label>
|
|
||||||
<Textarea
|
|
||||||
id="text"
|
|
||||||
value={formData.text}
|
|
||||||
onChange={(e) =>
|
|
||||||
setFormData({ ...formData, text: e.target.value })
|
|
||||||
}
|
|
||||||
placeholder="🔥 Присоединяйся к нашему сообществу! Узнавай первым о новых возможностях. 👉 {link}"
|
|
||||||
rows={8}
|
|
||||||
className="font-mono text-sm"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Alert>
|
|
||||||
<Info className="h-4 w-4" />
|
|
||||||
<AlertDescription className="text-sm">
|
|
||||||
<strong>Обязательно</strong> включите переменную {"{link}"} в
|
|
||||||
текст. На её место будет подставлена пригласительная ссылка в
|
|
||||||
целевой канал.
|
|
||||||
</AlertDescription>
|
|
||||||
</Alert>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{formData.text && (
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>Предпросмотр</CardTitle>
|
|
||||||
<CardDescription>
|
|
||||||
Так будет выглядеть сообщение для размещения
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<div className="rounded-lg border bg-muted/50 p-4">
|
|
||||||
<p className="text-sm whitespace-pre-wrap">{previewText}</p>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="flex justify-end gap-2">
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => router.push("/creatives")}
|
|
||||||
>
|
|
||||||
Отмена
|
|
||||||
</Button>
|
|
||||||
<Button type="submit" disabled={isLoading}>
|
|
||||||
{isLoading ? (
|
|
||||||
<>
|
|
||||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
|
||||||
Создание...
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
"Создать креатив"
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,266 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// Creatives List Page
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
import React, { useEffect, useState } from "react";
|
|
||||||
import Link from "next/link";
|
|
||||||
import { DashboardHeader } from "@/components/layout/dashboard-header";
|
|
||||||
import {
|
|
||||||
Card,
|
|
||||||
CardContent,
|
|
||||||
CardDescription,
|
|
||||||
CardHeader,
|
|
||||||
CardTitle,
|
|
||||||
} from "@/components/ui/card";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { Badge } from "@/components/ui/badge";
|
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
|
||||||
import { creativesApi } from "@/lib/api";
|
|
||||||
import {
|
|
||||||
formatNumber,
|
|
||||||
formatMetric,
|
|
||||||
truncate,
|
|
||||||
formatDate,
|
|
||||||
} from "@/lib/utils/format";
|
|
||||||
import {
|
|
||||||
Folder,
|
|
||||||
Loader2,
|
|
||||||
AlertCircle,
|
|
||||||
Plus,
|
|
||||||
Eye,
|
|
||||||
Pencil,
|
|
||||||
Archive,
|
|
||||||
Trash2,
|
|
||||||
} from "lucide-react";
|
|
||||||
import type { Creative } from "@/lib/types/api";
|
|
||||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
|
||||||
import { useTargetChannel } from "@/components/providers/target-channel-provider";
|
|
||||||
|
|
||||||
export default function CreativesPage() {
|
|
||||||
const { selectedChannel } = useTargetChannel();
|
|
||||||
const [creatives, setCreatives] = useState<Creative[]>([]);
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
const [activeTab, setActiveTab] = useState<"all" | "active" | "archived">(
|
|
||||||
"active"
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (selectedChannel) {
|
|
||||||
loadCreatives();
|
|
||||||
} else {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
}, [selectedChannel]);
|
|
||||||
|
|
||||||
const loadCreatives = async () => {
|
|
||||||
if (!selectedChannel) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
setLoading(true);
|
|
||||||
const response = await creativesApi.list({
|
|
||||||
target_channel_id: selectedChannel.id,
|
|
||||||
});
|
|
||||||
setCreatives(response.creatives);
|
|
||||||
} catch (err: any) {
|
|
||||||
setError(err?.error?.message || "Ошибка загрузки креативов");
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleArchive = async (
|
|
||||||
id: string,
|
|
||||||
currentStatus: "active" | "archived"
|
|
||||||
) => {
|
|
||||||
try {
|
|
||||||
const newStatus = currentStatus === "active" ? "archived" : "active";
|
|
||||||
await creativesApi.update(id, { status: newStatus });
|
|
||||||
loadCreatives();
|
|
||||||
} catch (err: any) {
|
|
||||||
alert(err?.error?.message || "Ошибка обновления креатива");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDelete = async (id: string, name: string) => {
|
|
||||||
if (!confirm(`Удалить креатив "${name}"?`)) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
await creativesApi.delete(id);
|
|
||||||
loadCreatives();
|
|
||||||
} catch (err: any) {
|
|
||||||
alert(err?.error?.message || "Ошибка удаления креатива");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const filteredCreatives = creatives.filter((creative) => {
|
|
||||||
if (activeTab === "active") return creative.status === "active";
|
|
||||||
if (activeTab === "archived") return creative.status === "archived";
|
|
||||||
return true;
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<DashboardHeader
|
|
||||||
breadcrumbs={[{ label: "Главная", href: "/" }, { label: "Креативы" }]}
|
|
||||||
/>
|
|
||||||
<div className="flex flex-1 flex-col gap-4 p-4 pt-0 mt-4">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<h1 className="text-3xl font-bold tracking-tight">Креативы</h1>
|
|
||||||
<p className="text-muted-foreground mt-1">
|
|
||||||
Шаблоны рекламных сообщений
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<Button asChild>
|
|
||||||
<Link href="/creatives/new">
|
|
||||||
<Plus className="mr-2 h-4 w-4" />
|
|
||||||
Создать креатив
|
|
||||||
</Link>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{error && (
|
|
||||||
<Alert variant="destructive">
|
|
||||||
<AlertCircle className="h-4 w-4" />
|
|
||||||
<AlertDescription>{error}</AlertDescription>
|
|
||||||
</Alert>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Tabs
|
|
||||||
value={activeTab}
|
|
||||||
onValueChange={(v) => setActiveTab(v as any)}
|
|
||||||
className="w-full"
|
|
||||||
>
|
|
||||||
<TabsList>
|
|
||||||
<TabsTrigger value="active">
|
|
||||||
Активные ({creatives.filter((c) => c.status === "active").length})
|
|
||||||
</TabsTrigger>
|
|
||||||
<TabsTrigger value="archived">
|
|
||||||
Архив ({creatives.filter((c) => c.status === "archived").length})
|
|
||||||
</TabsTrigger>
|
|
||||||
<TabsTrigger value="all">Все ({creatives.length})</TabsTrigger>
|
|
||||||
</TabsList>
|
|
||||||
|
|
||||||
<TabsContent value={activeTab} className="mt-4">
|
|
||||||
{loading ? (
|
|
||||||
<div className="flex items-center justify-center p-12">
|
|
||||||
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
|
||||||
</div>
|
|
||||||
) : filteredCreatives.length === 0 ? (
|
|
||||||
<Card>
|
|
||||||
<CardContent className="flex flex-col items-center justify-center p-12">
|
|
||||||
<Folder className="h-12 w-12 text-muted-foreground mb-4" />
|
|
||||||
<p className="text-lg font-medium text-muted-foreground">
|
|
||||||
{activeTab === "all" && "Нет креативов"}
|
|
||||||
{activeTab === "active" && "Нет активных креативов"}
|
|
||||||
{activeTab === "archived" && "Нет архивных креативов"}
|
|
||||||
</p>
|
|
||||||
<p className="text-sm text-muted-foreground mt-1">
|
|
||||||
Создайте первый креатив для рекламных сообщений
|
|
||||||
</p>
|
|
||||||
{activeTab === "active" && (
|
|
||||||
<Button asChild className="mt-4">
|
|
||||||
<Link href="/creatives/new">
|
|
||||||
<Plus className="mr-2 h-4 w-4" />
|
|
||||||
Создать креатив
|
|
||||||
</Link>
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
) : (
|
|
||||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
|
||||||
{filteredCreatives.map((creative) => (
|
|
||||||
<Card
|
|
||||||
key={creative.id}
|
|
||||||
className="hover:shadow-md transition-shadow"
|
|
||||||
>
|
|
||||||
<CardHeader>
|
|
||||||
<div className="flex items-start justify-between gap-2">
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<div className="flex items-center gap-2 mb-1">
|
|
||||||
<CardTitle className="truncate text-base">
|
|
||||||
{creative.name}
|
|
||||||
</CardTitle>
|
|
||||||
{creative.status === "archived" && (
|
|
||||||
<Badge variant="secondary">Архив</Badge>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<CardDescription className="text-xs">
|
|
||||||
{creative.target_channel_title}
|
|
||||||
</CardDescription>
|
|
||||||
</div>
|
|
||||||
<Folder className="h-5 w-5 text-muted-foreground shrink-0" />
|
|
||||||
</div>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-3">
|
|
||||||
<div className="rounded-lg border bg-muted/50 p-3">
|
|
||||||
<p className="text-sm whitespace-pre-wrap line-clamp-4">
|
|
||||||
{creative.text}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-2 text-center">
|
|
||||||
<div className="rounded-lg border bg-muted/50 p-2">
|
|
||||||
<div className="text-sm font-medium">
|
|
||||||
{formatNumber(creative.placements_count)}
|
|
||||||
</div>
|
|
||||||
<div className="text-xs text-muted-foreground">
|
|
||||||
Размещений
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="rounded-lg border bg-muted/50 p-2">
|
|
||||||
<div className="text-xs font-medium">
|
|
||||||
{formatDate(creative.created_at)}
|
|
||||||
</div>
|
|
||||||
<div className="text-xs text-muted-foreground">
|
|
||||||
Создан
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex gap-2 pt-2">
|
|
||||||
<Button
|
|
||||||
asChild
|
|
||||||
variant="default"
|
|
||||||
size="sm"
|
|
||||||
className="flex-1"
|
|
||||||
>
|
|
||||||
<Link href={`/creatives/${creative.id}`}>
|
|
||||||
<Eye className="mr-2 h-4 w-4" />
|
|
||||||
Открыть
|
|
||||||
</Link>
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onClick={() =>
|
|
||||||
handleArchive(creative.id, creative.status)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Archive className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onClick={() =>
|
|
||||||
handleDelete(creative.id, creative.name)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Trash2 className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</TabsContent>
|
|
||||||
</Tabs>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// External Channel Detail Page
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
import React, { useEffect } from "react";
|
|
||||||
import { useRouter } from "next/navigation";
|
|
||||||
import { use } from "react";
|
|
||||||
|
|
||||||
interface PageProps {
|
|
||||||
params: Promise<{ id: string }>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function ExternalChannelDetailPage({ params }: PageProps) {
|
|
||||||
const router = useRouter();
|
|
||||||
const resolvedParams = use(params);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
// Redirect back to external channels list
|
|
||||||
// Detail page functionality not yet implemented in backend API
|
|
||||||
router.push("/external-channels");
|
|
||||||
}, [router, resolvedParams.id]);
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
@@ -1,527 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// External Channels Import Page
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
import React, { useState, useEffect } from "react";
|
|
||||||
import Link from "next/link";
|
|
||||||
import { useRouter } from "next/navigation";
|
|
||||||
import { DashboardHeader } from "@/components/layout/dashboard-header";
|
|
||||||
import {
|
|
||||||
Card,
|
|
||||||
CardContent,
|
|
||||||
CardDescription,
|
|
||||||
CardHeader,
|
|
||||||
CardTitle,
|
|
||||||
} from "@/components/ui/card";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
|
||||||
import { Progress } from "@/components/ui/progress";
|
|
||||||
import {
|
|
||||||
Select,
|
|
||||||
SelectContent,
|
|
||||||
SelectItem,
|
|
||||||
SelectTrigger,
|
|
||||||
SelectValue,
|
|
||||||
} from "@/components/ui/select";
|
|
||||||
import { externalChannelsApi, channelsApi } from "@/lib/api";
|
|
||||||
import {
|
|
||||||
parseChannelsFile,
|
|
||||||
downloadTemplate,
|
|
||||||
type ParsedChannel,
|
|
||||||
} from "@/lib/utils/file-parser";
|
|
||||||
import {
|
|
||||||
FileUp,
|
|
||||||
AlertCircle,
|
|
||||||
CheckCircle,
|
|
||||||
Loader2,
|
|
||||||
Download,
|
|
||||||
ArrowLeft,
|
|
||||||
X,
|
|
||||||
FileSpreadsheet,
|
|
||||||
} from "lucide-react";
|
|
||||||
import type { TargetChannel } from "@/lib/types/api";
|
|
||||||
|
|
||||||
interface ImportResult {
|
|
||||||
total: number;
|
|
||||||
successful: number;
|
|
||||||
failed: number;
|
|
||||||
errors: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function ImportExternalChannelsPage() {
|
|
||||||
const router = useRouter();
|
|
||||||
const [file, setFile] = useState<File | null>(null);
|
|
||||||
const [isDragging, setIsDragging] = useState(false);
|
|
||||||
const [isUploading, setIsUploading] = useState(false);
|
|
||||||
const [parsedChannels, setParsedChannels] = useState<ParsedChannel[]>([]);
|
|
||||||
const [parseErrors, setParseErrors] = useState<string[]>([]);
|
|
||||||
const [importResult, setImportResult] = useState<ImportResult | null>(null);
|
|
||||||
const [progress, setProgress] = useState(0);
|
|
||||||
const [currentChannel, setCurrentChannel] = useState<string>("");
|
|
||||||
const [targetChannels, setTargetChannels] = useState<TargetChannel[]>([]);
|
|
||||||
const [selectedTargetChannel, setSelectedTargetChannel] =
|
|
||||||
useState<string>("");
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
loadTargetChannels();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const loadTargetChannels = async () => {
|
|
||||||
try {
|
|
||||||
const response = await channelsApi.list();
|
|
||||||
setTargetChannels(response.target_channels);
|
|
||||||
if (response.target_channels.length > 0) {
|
|
||||||
setSelectedTargetChannel(response.target_channels[0].id);
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error("Error loading target channels:", err);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDragOver = (e: React.DragEvent) => {
|
|
||||||
e.preventDefault();
|
|
||||||
setIsDragging(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDragLeave = () => {
|
|
||||||
setIsDragging(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDrop = (e: React.DragEvent) => {
|
|
||||||
e.preventDefault();
|
|
||||||
setIsDragging(false);
|
|
||||||
|
|
||||||
const droppedFile = e.dataTransfer.files[0];
|
|
||||||
if (droppedFile) {
|
|
||||||
handleFileSelect(droppedFile);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleFileSelect = async (selectedFile: File) => {
|
|
||||||
if (!selectedFile.name.match(/\.(xlsx|xls|csv)$/i)) {
|
|
||||||
setParseErrors(["Пожалуйста, выберите файл Excel (.xlsx, .xls) или CSV"]);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setFile(selectedFile);
|
|
||||||
setParseErrors([]);
|
|
||||||
setParsedChannels([]);
|
|
||||||
setImportResult(null);
|
|
||||||
|
|
||||||
// Парсим файл
|
|
||||||
try {
|
|
||||||
const result = await parseChannelsFile(selectedFile);
|
|
||||||
setParsedChannels(result.channels);
|
|
||||||
setParseErrors(result.errors);
|
|
||||||
} catch (err) {
|
|
||||||
setParseErrors([
|
|
||||||
`Ошибка парсинга файла: ${
|
|
||||||
err instanceof Error ? err.message : "неизвестная ошибка"
|
|
||||||
}`,
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleFileInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
||||||
const selectedFile = e.target.files?.[0];
|
|
||||||
if (selectedFile) {
|
|
||||||
handleFileSelect(selectedFile);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const delay = (ms: number) =>
|
|
||||||
new Promise((resolve) => setTimeout(resolve, ms));
|
|
||||||
|
|
||||||
const handleImport = async () => {
|
|
||||||
if (!selectedTargetChannel || parsedChannels.length === 0) return;
|
|
||||||
|
|
||||||
setIsUploading(true);
|
|
||||||
setImportResult(null);
|
|
||||||
setProgress(0);
|
|
||||||
setCurrentChannel("");
|
|
||||||
|
|
||||||
const result: ImportResult = {
|
|
||||||
total: parsedChannels.length,
|
|
||||||
successful: 0,
|
|
||||||
failed: 0,
|
|
||||||
errors: [],
|
|
||||||
};
|
|
||||||
|
|
||||||
for (let i = 0; i < parsedChannels.length; i++) {
|
|
||||||
const channel = parsedChannels[i];
|
|
||||||
setCurrentChannel(channel.title);
|
|
||||||
setProgress(((i + 1) / parsedChannels.length) * 100);
|
|
||||||
|
|
||||||
try {
|
|
||||||
await externalChannelsApi.create({
|
|
||||||
telegram_id: channel.telegram_id || 0,
|
|
||||||
title: channel.title,
|
|
||||||
username: channel.username || null,
|
|
||||||
description: channel.description || null,
|
|
||||||
subscribers_count: channel.subscribers_count || null,
|
|
||||||
target_channel_ids: [selectedTargetChannel],
|
|
||||||
});
|
|
||||||
|
|
||||||
result.successful++;
|
|
||||||
} catch (err: any) {
|
|
||||||
result.failed++;
|
|
||||||
const errorMsg =
|
|
||||||
err?.detail || err?.error?.message || "Неизвестная ошибка";
|
|
||||||
result.errors.push(
|
|
||||||
`Строка ${channel.row} (${channel.title}): ${errorMsg}`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Задержка между запросами (300ms)
|
|
||||||
if (i < parsedChannels.length - 1) {
|
|
||||||
await delay(300);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
setImportResult(result);
|
|
||||||
setIsUploading(false);
|
|
||||||
setCurrentChannel("");
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleReset = () => {
|
|
||||||
setFile(null);
|
|
||||||
setParsedChannels([]);
|
|
||||||
setParseErrors([]);
|
|
||||||
setImportResult(null);
|
|
||||||
setProgress(0);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<DashboardHeader
|
|
||||||
breadcrumbs={[
|
|
||||||
{ label: "Главная", href: "/" },
|
|
||||||
{ label: "Внешние каналы", href: "/external-channels" },
|
|
||||||
{ label: "Импорт" },
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
<div className="flex flex-1 flex-col gap-4 p-4 pt-0 mt-4 max-w-4xl mx-auto w-full">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<h1 className="text-3xl font-bold tracking-tight">
|
|
||||||
Импорт внешних каналов
|
|
||||||
</h1>
|
|
||||||
<p className="text-muted-foreground mt-1">
|
|
||||||
Загрузите файл Excel или CSV с данными каналов
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<Button asChild variant="outline">
|
|
||||||
<Link href="/external-channels">
|
|
||||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
|
||||||
Назад
|
|
||||||
</Link>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Выбор целевого канала */}
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>Целевой канал</CardTitle>
|
|
||||||
<CardDescription>
|
|
||||||
Выберите целевой канал для привязки импортируемых каналов
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<Select
|
|
||||||
value={selectedTargetChannel}
|
|
||||||
onValueChange={setSelectedTargetChannel}
|
|
||||||
disabled={isUploading || targetChannels.length === 0}
|
|
||||||
>
|
|
||||||
<SelectTrigger>
|
|
||||||
<SelectValue placeholder="Выберите целевой канал" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{targetChannels.map((channel) => (
|
|
||||||
<SelectItem key={channel.id} value={channel.id}>
|
|
||||||
{channel.title}
|
|
||||||
{channel.username && ` (@${channel.username})`}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Шаблоны */}
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>Скачать шаблон</CardTitle>
|
|
||||||
<CardDescription>
|
|
||||||
Используйте шаблон для правильного заполнения данных
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="flex gap-2">
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => downloadTemplate("xlsx")}
|
|
||||||
disabled={isUploading}
|
|
||||||
>
|
|
||||||
<Download className="mr-2 h-4 w-4" />
|
|
||||||
Шаблон Excel
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => downloadTemplate("csv")}
|
|
||||||
disabled={isUploading}
|
|
||||||
>
|
|
||||||
<Download className="mr-2 h-4 w-4" />
|
|
||||||
Шаблон CSV
|
|
||||||
</Button>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Загрузка файла */}
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>Загрузка файла</CardTitle>
|
|
||||||
<CardDescription>
|
|
||||||
Перетащите файл или нажмите для выбора
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<div
|
|
||||||
className={`relative border-2 border-dashed rounded-lg p-12 text-center transition-colors ${
|
|
||||||
isDragging
|
|
||||||
? "border-primary bg-primary/5"
|
|
||||||
: "border-muted-foreground/25 hover:border-muted-foreground/50"
|
|
||||||
} ${isUploading ? "opacity-50 pointer-events-none" : ""}`}
|
|
||||||
onDragOver={handleDragOver}
|
|
||||||
onDragLeave={handleDragLeave}
|
|
||||||
onDrop={handleDrop}
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
type="file"
|
|
||||||
id="file-upload"
|
|
||||||
className="hidden"
|
|
||||||
accept=".xlsx,.xls,.csv"
|
|
||||||
onChange={handleFileInputChange}
|
|
||||||
disabled={isUploading}
|
|
||||||
/>
|
|
||||||
<label
|
|
||||||
htmlFor="file-upload"
|
|
||||||
className="cursor-pointer flex flex-col items-center"
|
|
||||||
>
|
|
||||||
<FileUp className="h-12 w-12 text-muted-foreground mb-4" />
|
|
||||||
{file ? (
|
|
||||||
<div className="space-y-2">
|
|
||||||
<p className="text-sm font-medium">{file.name}</p>
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
{(file.size / 1024).toFixed(2)} KB
|
|
||||||
</p>
|
|
||||||
{!isUploading && (
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
handleReset();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<X className="h-4 w-4 mr-1" />
|
|
||||||
Удалить
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
Перетащите файл сюда или нажмите для выбора
|
|
||||||
</p>
|
|
||||||
<p className="text-xs text-muted-foreground mt-1">
|
|
||||||
Поддерживаются форматы: .xlsx, .xls, .csv
|
|
||||||
</p>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Результаты парсинга */}
|
|
||||||
{parsedChannels.length > 0 && !importResult && (
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>Результаты парсинга</CardTitle>
|
|
||||||
<CardDescription>
|
|
||||||
Найдено каналов: {parsedChannels.length}
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-4">
|
|
||||||
{parseErrors.length > 0 && (
|
|
||||||
<Alert variant="destructive">
|
|
||||||
<AlertCircle className="h-4 w-4" />
|
|
||||||
<AlertDescription>
|
|
||||||
<strong>Ошибки при парсинге:</strong>
|
|
||||||
<ul className="list-disc list-inside mt-2">
|
|
||||||
{parseErrors.slice(0, 5).map((error, i) => (
|
|
||||||
<li key={i} className="text-sm">
|
|
||||||
{error}
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
{parseErrors.length > 5 && (
|
|
||||||
<li className="text-sm">
|
|
||||||
...и еще {parseErrors.length - 5}
|
|
||||||
</li>
|
|
||||||
)}
|
|
||||||
</ul>
|
|
||||||
</AlertDescription>
|
|
||||||
</Alert>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<Button
|
|
||||||
onClick={handleImport}
|
|
||||||
disabled={
|
|
||||||
isUploading ||
|
|
||||||
!selectedTargetChannel ||
|
|
||||||
parsedChannels.length === 0
|
|
||||||
}
|
|
||||||
className="flex-1"
|
|
||||||
>
|
|
||||||
<FileSpreadsheet className="mr-2 h-4 w-4" />
|
|
||||||
Импортировать {parsedChannels.length} каналов
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
onClick={handleReset}
|
|
||||||
disabled={isUploading}
|
|
||||||
>
|
|
||||||
Отмена
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Прогресс импорта */}
|
|
||||||
{isUploading && (
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>Импорт в процессе</CardTitle>
|
|
||||||
<CardDescription>
|
|
||||||
{currentChannel && `Импортируется: ${currentChannel}`}
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-4">
|
|
||||||
<Progress value={progress} className="w-full" />
|
|
||||||
<p className="text-sm text-center text-muted-foreground">
|
|
||||||
{Math.round(progress)}% завершено
|
|
||||||
</p>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Результаты импорта */}
|
|
||||||
{importResult && (
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>Результаты импорта</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-4">
|
|
||||||
<div className="grid grid-cols-3 gap-4 text-center">
|
|
||||||
<div className="p-4 border rounded-lg">
|
|
||||||
<div className="text-2xl font-bold">{importResult.total}</div>
|
|
||||||
<div className="text-xs text-muted-foreground">Всего</div>
|
|
||||||
</div>
|
|
||||||
<div className="p-4 border rounded-lg border-green-200 bg-green-50 dark:border-green-900 dark:bg-green-950">
|
|
||||||
<div className="text-2xl font-bold text-green-600 dark:text-green-400">
|
|
||||||
{importResult.successful}
|
|
||||||
</div>
|
|
||||||
<div className="text-xs text-muted-foreground">Успешно</div>
|
|
||||||
</div>
|
|
||||||
<div className="p-4 border rounded-lg border-red-200 bg-red-50 dark:border-red-900 dark:bg-red-950">
|
|
||||||
<div className="text-2xl font-bold text-red-600 dark:text-red-400">
|
|
||||||
{importResult.failed}
|
|
||||||
</div>
|
|
||||||
<div className="text-xs text-muted-foreground">Ошибок</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{importResult.errors.length > 0 && (
|
|
||||||
<Alert variant="destructive">
|
|
||||||
<AlertCircle className="h-4 w-4" />
|
|
||||||
<AlertDescription>
|
|
||||||
<strong>Ошибки импорта:</strong>
|
|
||||||
<ul className="list-disc list-inside mt-2 max-h-60 overflow-y-auto">
|
|
||||||
{importResult.errors.map((error, i) => (
|
|
||||||
<li key={i} className="text-sm">
|
|
||||||
{error}
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</AlertDescription>
|
|
||||||
</Alert>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{importResult.successful > 0 && (
|
|
||||||
<Alert>
|
|
||||||
<CheckCircle className="h-4 w-4" />
|
|
||||||
<AlertDescription>
|
|
||||||
Успешно импортировано каналов: {importResult.successful}
|
|
||||||
</AlertDescription>
|
|
||||||
</Alert>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<Button
|
|
||||||
onClick={() => router.push("/external-channels")}
|
|
||||||
className="flex-1"
|
|
||||||
>
|
|
||||||
Перейти к списку каналов
|
|
||||||
</Button>
|
|
||||||
<Button variant="outline" onClick={handleReset}>
|
|
||||||
Импортировать еще
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Инструкция */}
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>Формат файла</CardTitle>
|
|
||||||
<CardDescription>Требования к структуре файла</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<div className="space-y-2 text-sm">
|
|
||||||
<p>
|
|
||||||
<strong>Колонки (в порядке):</strong>
|
|
||||||
</p>
|
|
||||||
<ol className="list-decimal list-inside space-y-1 ml-2">
|
|
||||||
<li>
|
|
||||||
<strong>Название канала*</strong> - обязательное поле
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<strong>Username</strong> - без символа @ (опционально)
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<strong>Telegram ID</strong> - числовой ID канала
|
|
||||||
(опционально)
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<strong>Подписчиков</strong> - количество подписчиков
|
|
||||||
(опционально)
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<strong>Описание</strong> - описание канала (опционально)
|
|
||||||
</li>
|
|
||||||
</ol>
|
|
||||||
<p className="text-muted-foreground mt-4">
|
|
||||||
* Первая строка файла должна содержать заголовки и будет
|
|
||||||
пропущена при импорте
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,617 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// External Channels List Page
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
import React, { useEffect, useState } from "react";
|
|
||||||
import Link from "next/link";
|
|
||||||
import { DashboardHeader } from "@/components/layout/dashboard-header";
|
|
||||||
import {
|
|
||||||
Card,
|
|
||||||
CardContent,
|
|
||||||
CardDescription,
|
|
||||||
CardHeader,
|
|
||||||
CardTitle,
|
|
||||||
} from "@/components/ui/card";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { Input } from "@/components/ui/input";
|
|
||||||
import { Badge } from "@/components/ui/badge";
|
|
||||||
import { externalChannelsApi, channelsApi } from "@/lib/api";
|
|
||||||
import {
|
|
||||||
formatNumber,
|
|
||||||
formatMetric,
|
|
||||||
formatUsername,
|
|
||||||
formatCompactNumber,
|
|
||||||
} from "@/lib/utils/format";
|
|
||||||
import {
|
|
||||||
ExternalLink as ExternalLinkIcon,
|
|
||||||
Loader2,
|
|
||||||
AlertCircle,
|
|
||||||
Plus,
|
|
||||||
Search,
|
|
||||||
FileUp,
|
|
||||||
Eye,
|
|
||||||
Pencil,
|
|
||||||
Trash2,
|
|
||||||
Users,
|
|
||||||
ArrowUpDown,
|
|
||||||
ArrowUp,
|
|
||||||
ArrowDown,
|
|
||||||
LayoutGrid,
|
|
||||||
Table2,
|
|
||||||
} from "lucide-react";
|
|
||||||
import type { ExternalChannel } from "@/lib/types/api";
|
|
||||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
|
||||||
import {
|
|
||||||
Table,
|
|
||||||
TableBody,
|
|
||||||
TableCell,
|
|
||||||
TableHead,
|
|
||||||
TableHeader,
|
|
||||||
TableRow,
|
|
||||||
} from "@/components/ui/table";
|
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogDescription,
|
|
||||||
DialogFooter,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
DialogTrigger,
|
|
||||||
} from "@/components/ui/dialog";
|
|
||||||
import { Label } from "@/components/ui/label";
|
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
|
||||||
import { useTargetChannel } from "@/components/providers/target-channel-provider";
|
|
||||||
|
|
||||||
type ViewMode = "table" | "grid";
|
|
||||||
type SortField =
|
|
||||||
| "title"
|
|
||||||
| "username"
|
|
||||||
| "subscribers_count"
|
|
||||||
| "telegram_id"
|
|
||||||
| null;
|
|
||||||
type SortDirection = "asc" | "desc" | null;
|
|
||||||
|
|
||||||
export default function ExternalChannelsPage() {
|
|
||||||
const { selectedChannel } = useTargetChannel();
|
|
||||||
const [channels, setChannels] = useState<ExternalChannel[]>([]);
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
const [searchQuery, setSearchQuery] = useState("");
|
|
||||||
const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false);
|
|
||||||
const [isCreating, setIsCreating] = useState(false);
|
|
||||||
const [viewMode, setViewMode] = useState<ViewMode>("table");
|
|
||||||
const [sortField, setSortField] = useState<SortField>(null);
|
|
||||||
const [sortDirection, setSortDirection] = useState<SortDirection>(null);
|
|
||||||
|
|
||||||
// Form state
|
|
||||||
const [formData, setFormData] = useState({
|
|
||||||
title: "",
|
|
||||||
username: "",
|
|
||||||
link: "",
|
|
||||||
subscribers_count: "",
|
|
||||||
description: "",
|
|
||||||
});
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (selectedChannel) {
|
|
||||||
loadChannels();
|
|
||||||
} else {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
}, [selectedChannel]);
|
|
||||||
|
|
||||||
const loadChannels = async () => {
|
|
||||||
if (!selectedChannel) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
setLoading(true);
|
|
||||||
setError(null);
|
|
||||||
const response = await externalChannelsApi.list(selectedChannel.id);
|
|
||||||
setChannels(response.external_channels);
|
|
||||||
} catch (err: any) {
|
|
||||||
const errorMsg =
|
|
||||||
typeof err?.detail === "string"
|
|
||||||
? err.detail
|
|
||||||
: err?.error?.message || "Ошибка загрузки каналов";
|
|
||||||
setError(errorMsg);
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleCreate = async () => {
|
|
||||||
try {
|
|
||||||
setIsCreating(true);
|
|
||||||
// Получаем первый доступный target channel
|
|
||||||
const targetChannelsRes = await channelsApi.list();
|
|
||||||
if (targetChannelsRes.target_channels.length === 0) {
|
|
||||||
alert("Добавьте сначала целевой канал");
|
|
||||||
setIsCreating(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await externalChannelsApi.create({
|
|
||||||
telegram_id: 0, // Временно, пока не получим реальный ID
|
|
||||||
title: formData.title,
|
|
||||||
username: formData.username || null,
|
|
||||||
subscribers_count: formData.subscribers_count
|
|
||||||
? parseInt(formData.subscribers_count)
|
|
||||||
: null,
|
|
||||||
description: formData.description || null,
|
|
||||||
target_channel_ids: [targetChannelsRes.target_channels[0].id],
|
|
||||||
});
|
|
||||||
setIsCreateDialogOpen(false);
|
|
||||||
setFormData({
|
|
||||||
title: "",
|
|
||||||
username: "",
|
|
||||||
link: "",
|
|
||||||
subscribers_count: "",
|
|
||||||
description: "",
|
|
||||||
});
|
|
||||||
loadChannels();
|
|
||||||
} catch (err: any) {
|
|
||||||
const errorMsg =
|
|
||||||
typeof err?.detail === "string"
|
|
||||||
? err.detail
|
|
||||||
: err?.error?.message || "Ошибка создания канала";
|
|
||||||
alert(errorMsg);
|
|
||||||
} finally {
|
|
||||||
setIsCreating(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDelete = async (id: string, title: string) => {
|
|
||||||
if (!confirm(`Удалить канал "${title}"?`)) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
await externalChannelsApi.delete(id);
|
|
||||||
loadChannels();
|
|
||||||
} catch (err: any) {
|
|
||||||
alert(err?.error?.message || "Ошибка удаления канала");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Обработка сортировки
|
|
||||||
const handleSort = (field: SortField) => {
|
|
||||||
if (sortField === field) {
|
|
||||||
// Переключение: asc -> desc -> null -> asc
|
|
||||||
if (sortDirection === "asc") {
|
|
||||||
setSortDirection("desc");
|
|
||||||
} else if (sortDirection === "desc") {
|
|
||||||
setSortField(null);
|
|
||||||
setSortDirection(null);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
setSortField(field);
|
|
||||||
setSortDirection("asc");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const getSortIcon = (field: SortField) => {
|
|
||||||
if (sortField !== field) {
|
|
||||||
return <ArrowUpDown className="ml-2 h-4 w-4 inline" />;
|
|
||||||
}
|
|
||||||
if (sortDirection === "asc") {
|
|
||||||
return <ArrowUp className="ml-2 h-4 w-4 inline" />;
|
|
||||||
}
|
|
||||||
return <ArrowDown className="ml-2 h-4 w-4 inline" />;
|
|
||||||
};
|
|
||||||
|
|
||||||
const filteredChannels = channels
|
|
||||||
.filter(
|
|
||||||
(channel) =>
|
|
||||||
channel.title.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
|
||||||
channel.username?.toLowerCase().includes(searchQuery.toLowerCase())
|
|
||||||
)
|
|
||||||
.sort((a, b) => {
|
|
||||||
if (!sortField || !sortDirection) return 0;
|
|
||||||
|
|
||||||
let aValue: string | number | null = "";
|
|
||||||
let bValue: string | number | null = "";
|
|
||||||
|
|
||||||
switch (sortField) {
|
|
||||||
case "title":
|
|
||||||
aValue = a.title;
|
|
||||||
bValue = b.title;
|
|
||||||
break;
|
|
||||||
case "username":
|
|
||||||
aValue = a.username || "";
|
|
||||||
bValue = b.username || "";
|
|
||||||
break;
|
|
||||||
case "subscribers_count":
|
|
||||||
aValue = a.subscribers_count || 0;
|
|
||||||
bValue = b.subscribers_count || 0;
|
|
||||||
break;
|
|
||||||
case "telegram_id":
|
|
||||||
aValue = a.telegram_id;
|
|
||||||
bValue = b.telegram_id;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Сравнение строк
|
|
||||||
if (typeof aValue === "string" && typeof bValue === "string") {
|
|
||||||
return sortDirection === "asc"
|
|
||||||
? aValue.localeCompare(bValue)
|
|
||||||
: bValue.localeCompare(aValue);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Сравнение чисел
|
|
||||||
if (typeof aValue === "number" && typeof bValue === "number") {
|
|
||||||
return sortDirection === "asc" ? aValue - bValue : bValue - aValue;
|
|
||||||
}
|
|
||||||
|
|
||||||
return 0;
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<DashboardHeader
|
|
||||||
breadcrumbs={[
|
|
||||||
{ label: "Главная", href: "/" },
|
|
||||||
{ label: "Внешние каналы" },
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
<div className="flex flex-1 flex-col gap-4 p-4 pt-0 mt-4">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<h1 className="text-3xl font-bold tracking-tight">
|
|
||||||
Внешние каналы
|
|
||||||
</h1>
|
|
||||||
<p className="text-muted-foreground mt-1">
|
|
||||||
Каналы, в которых покупаете рекламу
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<Button asChild variant="outline">
|
|
||||||
<Link href="/external-channels/import">
|
|
||||||
<FileUp className="mr-2 h-4 w-4" />
|
|
||||||
Импорт из Excel
|
|
||||||
</Link>
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Dialog
|
|
||||||
open={isCreateDialogOpen}
|
|
||||||
onOpenChange={setIsCreateDialogOpen}
|
|
||||||
>
|
|
||||||
<DialogTrigger asChild>
|
|
||||||
<Button>
|
|
||||||
<Plus className="mr-2 h-4 w-4" />
|
|
||||||
Добавить канал
|
|
||||||
</Button>
|
|
||||||
</DialogTrigger>
|
|
||||||
<DialogContent className="sm:max-w-[500px]">
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>Добавить внешний канал</DialogTitle>
|
|
||||||
<DialogDescription>
|
|
||||||
Добавьте канал, в котором планируете покупать рекламу
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
<div className="grid gap-4 py-4">
|
|
||||||
<div className="grid gap-2">
|
|
||||||
<Label htmlFor="title">Название *</Label>
|
|
||||||
<Input
|
|
||||||
id="title"
|
|
||||||
value={formData.title}
|
|
||||||
onChange={(e) =>
|
|
||||||
setFormData({ ...formData, title: e.target.value })
|
|
||||||
}
|
|
||||||
placeholder="Название канала"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="grid gap-2">
|
|
||||||
<Label htmlFor="link">Ссылка *</Label>
|
|
||||||
<Input
|
|
||||||
id="link"
|
|
||||||
value={formData.link}
|
|
||||||
onChange={(e) =>
|
|
||||||
setFormData({ ...formData, link: e.target.value })
|
|
||||||
}
|
|
||||||
placeholder="https://t.me/channel_name"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="grid gap-2">
|
|
||||||
<Label htmlFor="username">Username</Label>
|
|
||||||
<Input
|
|
||||||
id="username"
|
|
||||||
value={formData.username}
|
|
||||||
onChange={(e) =>
|
|
||||||
setFormData({ ...formData, username: e.target.value })
|
|
||||||
}
|
|
||||||
placeholder="@channel_name"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="grid gap-2">
|
|
||||||
<Label htmlFor="subscribers">Количество подписчиков</Label>
|
|
||||||
<Input
|
|
||||||
id="subscribers"
|
|
||||||
type="number"
|
|
||||||
value={formData.subscribers_count}
|
|
||||||
onChange={(e) =>
|
|
||||||
setFormData({
|
|
||||||
...formData,
|
|
||||||
subscribers_count: e.target.value,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
placeholder="50000"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="grid gap-2">
|
|
||||||
<Label htmlFor="description">Описание</Label>
|
|
||||||
<Textarea
|
|
||||||
id="description"
|
|
||||||
value={formData.description}
|
|
||||||
onChange={(e) =>
|
|
||||||
setFormData({
|
|
||||||
...formData,
|
|
||||||
description: e.target.value,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
placeholder="Краткое описание канала"
|
|
||||||
rows={3}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<DialogFooter>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => setIsCreateDialogOpen(false)}
|
|
||||||
>
|
|
||||||
Отмена
|
|
||||||
</Button>
|
|
||||||
<Button onClick={handleCreate} disabled={isCreating}>
|
|
||||||
{isCreating ? (
|
|
||||||
<>
|
|
||||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
|
||||||
Создание...
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
"Создать"
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{error && (
|
|
||||||
<Alert variant="destructive">
|
|
||||||
<AlertCircle className="h-4 w-4" />
|
|
||||||
<AlertDescription>{error}</AlertDescription>
|
|
||||||
</Alert>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="flex items-center justify-between gap-2">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<div className="relative flex-1 max-w-sm">
|
|
||||||
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
|
|
||||||
<Input
|
|
||||||
placeholder="Поиск по названию или username..."
|
|
||||||
value={searchQuery}
|
|
||||||
onChange={(e) => setSearchQuery(e.target.value)}
|
|
||||||
className="pl-8 rounded-lg"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<Badge variant="secondary">
|
|
||||||
Всего: {formatNumber(filteredChannels.length)}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-1 border rounded-md">
|
|
||||||
<Button
|
|
||||||
variant={viewMode === "table" ? "default" : "ghost"}
|
|
||||||
size="sm"
|
|
||||||
onClick={() => setViewMode("table")}
|
|
||||||
className="rounded-r-none"
|
|
||||||
>
|
|
||||||
<Table2 className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant={viewMode === "grid" ? "default" : "ghost"}
|
|
||||||
size="sm"
|
|
||||||
onClick={() => setViewMode("grid")}
|
|
||||||
className="rounded-l-none"
|
|
||||||
>
|
|
||||||
<LayoutGrid className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{loading ? (
|
|
||||||
<div className="flex items-center justify-center p-12">
|
|
||||||
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
|
||||||
</div>
|
|
||||||
) : filteredChannels.length === 0 ? (
|
|
||||||
<Card>
|
|
||||||
<CardContent className="flex flex-col items-center justify-center p-12">
|
|
||||||
<ExternalLinkIcon className="h-12 w-12 text-muted-foreground mb-4" />
|
|
||||||
<p className="text-lg font-medium text-muted-foreground">
|
|
||||||
{searchQuery ? "Каналы не найдены" : "Нет внешних каналов"}
|
|
||||||
</p>
|
|
||||||
<p className="text-sm text-muted-foreground mt-1">
|
|
||||||
{searchQuery
|
|
||||||
? "Попробуйте изменить поисковый запрос"
|
|
||||||
: "Добавьте каналы вручную или импортируйте из Excel"}
|
|
||||||
</p>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
) : viewMode === "grid" ? (
|
|
||||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
|
||||||
{filteredChannels.map((channel) => (
|
|
||||||
<Card
|
|
||||||
key={channel.id}
|
|
||||||
className="hover:shadow-md transition-shadow"
|
|
||||||
>
|
|
||||||
<CardHeader>
|
|
||||||
<div className="flex items-start justify-between gap-2">
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<CardTitle className="truncate flex items-center gap-2">
|
|
||||||
<ExternalLinkIcon className="h-4 w-4 shrink-0" />
|
|
||||||
{channel.title}
|
|
||||||
</CardTitle>
|
|
||||||
<CardDescription className="truncate mt-1">
|
|
||||||
{channel.username ? (
|
|
||||||
<a
|
|
||||||
href={`https://t.me/${channel.username}`}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="hover:underline"
|
|
||||||
>
|
|
||||||
{formatUsername(channel.username)}
|
|
||||||
</a>
|
|
||||||
) : (
|
|
||||||
<span className="text-xs">
|
|
||||||
ID: {channel.telegram_id}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</CardDescription>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-3">
|
|
||||||
{channel.description && (
|
|
||||||
<p className="text-sm text-muted-foreground line-clamp-2">
|
|
||||||
{channel.description}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<div className="flex-1 rounded-lg border bg-muted/50 p-2">
|
|
||||||
<div className="flex items-center justify-center gap-1">
|
|
||||||
<Users className="h-3 w-3 text-muted-foreground" />
|
|
||||||
<div className="text-sm font-medium">
|
|
||||||
{channel.subscribers_count
|
|
||||||
? formatCompactNumber(channel.subscribers_count)
|
|
||||||
: "—"}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="text-xs text-muted-foreground text-center">
|
|
||||||
Подписчики
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex gap-2 pt-2">
|
|
||||||
<Button
|
|
||||||
variant="destructive"
|
|
||||||
size="sm"
|
|
||||||
className="flex-1"
|
|
||||||
onClick={() => handleDelete(channel.id, channel.title)}
|
|
||||||
>
|
|
||||||
<Trash2 className="mr-2 h-4 w-4" />
|
|
||||||
Удалить
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>Список внешних каналов</CardTitle>
|
|
||||||
<CardDescription>
|
|
||||||
Каналы, в которых покупаете рекламу
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<Table>
|
|
||||||
<TableHeader>
|
|
||||||
<TableRow>
|
|
||||||
<TableHead
|
|
||||||
className="cursor-pointer hover:bg-muted/50"
|
|
||||||
onClick={() => handleSort("title")}
|
|
||||||
>
|
|
||||||
Название
|
|
||||||
{getSortIcon("title")}
|
|
||||||
</TableHead>
|
|
||||||
<TableHead className="cursor-pointer hover:bg-muted/50">
|
|
||||||
Username
|
|
||||||
</TableHead>
|
|
||||||
<TableHead>Описание</TableHead>
|
|
||||||
<TableHead
|
|
||||||
className="text-right cursor-pointer hover:bg-muted/50"
|
|
||||||
onClick={() => handleSort("subscribers_count")}
|
|
||||||
>
|
|
||||||
Подписчики
|
|
||||||
{getSortIcon("subscribers_count")}
|
|
||||||
</TableHead>
|
|
||||||
<TableHead className="text-right cursor-pointer hover:bg-muted/50">
|
|
||||||
Telegram ID
|
|
||||||
</TableHead>
|
|
||||||
<TableHead></TableHead>
|
|
||||||
</TableRow>
|
|
||||||
</TableHeader>
|
|
||||||
<TableBody>
|
|
||||||
{filteredChannels.map((channel) => (
|
|
||||||
<TableRow key={channel.id}>
|
|
||||||
<TableCell className="font-medium">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<a
|
|
||||||
href={`https://t.me/${channel.username}`}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="flex items-center gap-2 hover:underline text-primary whitespace-nowrap"
|
|
||||||
tabIndex={0}
|
|
||||||
aria-label={`Открыть канал ${channel.title} в Telegram`}
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
onKeyDown={(e) => {
|
|
||||||
if (e.key === "Enter" || e.key === " ") {
|
|
||||||
window.open(
|
|
||||||
`https://t.me/${channel.username}`,
|
|
||||||
"_blank",
|
|
||||||
"noopener,noreferrer"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<span className="inline-flex items-center gap-2">
|
|
||||||
<span>{channel.title}</span>
|
|
||||||
<ExternalLinkIcon className="h-4 w-4 text-muted-foreground shrink-0" />
|
|
||||||
</span>
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
{channel.username ? (
|
|
||||||
<div>{formatUsername(channel.username)}</div>
|
|
||||||
) : (
|
|
||||||
"—"
|
|
||||||
)}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<div className="max-w-[300px] truncate text-muted-foreground">
|
|
||||||
{channel.description || "—"}
|
|
||||||
</div>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="text-right">
|
|
||||||
{channel.subscribers_count
|
|
||||||
? formatNumber(channel.subscribers_count)
|
|
||||||
: "—"}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="text-right">
|
|
||||||
{channel.telegram_id}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="text-right">
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onClick={() =>
|
|
||||||
handleDelete(channel.id, channel.title)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Trash2 className="h-4 w-4 text-destructive" />
|
|
||||||
</Button>
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
))}
|
|
||||||
</TableBody>
|
|
||||||
</Table>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
// ============================================================================
|
|
||||||
// Dashboard Layout
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
import { AppSidebar } from "@/components/layout/app-sidebar";
|
|
||||||
import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar";
|
|
||||||
import { ProtectedRoute } from "@/components/auth/protected-route";
|
|
||||||
|
|
||||||
export default function DashboardLayout({
|
|
||||||
children,
|
|
||||||
}: {
|
|
||||||
children: React.ReactNode;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<ProtectedRoute>
|
|
||||||
<SidebarProvider>
|
|
||||||
<AppSidebar />
|
|
||||||
<SidebarInset>{children}</SidebarInset>
|
|
||||||
</SidebarProvider>
|
|
||||||
</ProtectedRoute>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,365 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// Dashboard Home Page
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
import React, { useEffect, useState, useRef } from "react";
|
|
||||||
import { DashboardHeader } from "@/components/layout/dashboard-header";
|
|
||||||
import {
|
|
||||||
Card,
|
|
||||||
CardContent,
|
|
||||||
CardDescription,
|
|
||||||
CardHeader,
|
|
||||||
CardTitle,
|
|
||||||
} from "@/components/ui/card";
|
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogDescription,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
} from "@/components/ui/dialog";
|
|
||||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
|
||||||
import { analyticsApi, channelsApi } from "@/lib/api";
|
|
||||||
import {
|
|
||||||
formatCurrency,
|
|
||||||
formatNumber,
|
|
||||||
formatMetric,
|
|
||||||
formatPercent,
|
|
||||||
} from "@/lib/utils/format";
|
|
||||||
import {
|
|
||||||
BarChart3,
|
|
||||||
TrendingUp,
|
|
||||||
Users,
|
|
||||||
ShoppingCart,
|
|
||||||
Loader2,
|
|
||||||
ExternalLink,
|
|
||||||
CheckCircle2,
|
|
||||||
} from "lucide-react";
|
|
||||||
import type { SpendingAnalytics } from "@/lib/types/api";
|
|
||||||
import { useTargetChannel } from "@/components/providers/target-channel-provider";
|
|
||||||
import { BOT_USERNAME } from "@/lib/utils/constants";
|
|
||||||
|
|
||||||
export default function DashboardPage() {
|
|
||||||
const {
|
|
||||||
selectedChannel,
|
|
||||||
channels,
|
|
||||||
isLoading: channelsLoading,
|
|
||||||
} = useTargetChannel();
|
|
||||||
const [spending, setSpending] = useState<SpendingAnalytics | null>(null);
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
|
|
||||||
// Для модального окна добавления канала
|
|
||||||
const [showAddChannelDialog, setShowAddChannelDialog] = useState(false);
|
|
||||||
const [isSuccess, setIsSuccess] = useState(false);
|
|
||||||
const [initialChannelCount, setInitialChannelCount] = useState(0);
|
|
||||||
const pollingIntervalRef = useRef<NodeJS.Timeout | null>(null);
|
|
||||||
|
|
||||||
// Показываем модальное окно, если нет каналов
|
|
||||||
useEffect(() => {
|
|
||||||
if (!channelsLoading && channels.length === 0) {
|
|
||||||
setShowAddChannelDialog(true);
|
|
||||||
setInitialChannelCount(0);
|
|
||||||
}
|
|
||||||
}, [channelsLoading, channels]);
|
|
||||||
|
|
||||||
// Автоматический polling при открытом окне
|
|
||||||
useEffect(() => {
|
|
||||||
if (showAddChannelDialog && !isSuccess && initialChannelCount === 0) {
|
|
||||||
pollingIntervalRef.current = setInterval(() => {
|
|
||||||
checkForNewChannels();
|
|
||||||
}, 1000);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
if (pollingIntervalRef.current) {
|
|
||||||
clearInterval(pollingIntervalRef.current);
|
|
||||||
pollingIntervalRef.current = null;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}, [showAddChannelDialog, isSuccess, initialChannelCount]);
|
|
||||||
|
|
||||||
// Очистка при размонтировании
|
|
||||||
useEffect(() => {
|
|
||||||
return () => {
|
|
||||||
if (pollingIntervalRef.current) {
|
|
||||||
clearInterval(pollingIntervalRef.current);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const checkForNewChannels = async () => {
|
|
||||||
try {
|
|
||||||
const response = await channelsApi.list();
|
|
||||||
const activeChannels = response.target_channels.filter(
|
|
||||||
(c) => c.is_active
|
|
||||||
);
|
|
||||||
|
|
||||||
if (activeChannels.length > initialChannelCount) {
|
|
||||||
setIsSuccess(true);
|
|
||||||
|
|
||||||
if (pollingIntervalRef.current) {
|
|
||||||
clearInterval(pollingIntervalRef.current);
|
|
||||||
pollingIntervalRef.current = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
setTimeout(() => {
|
|
||||||
setShowAddChannelDialog(false);
|
|
||||||
setIsSuccess(false);
|
|
||||||
window.location.reload();
|
|
||||||
}, 2000);
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error("Error polling channels:", err);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!selectedChannel) {
|
|
||||||
setLoading(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const loadData = async () => {
|
|
||||||
try {
|
|
||||||
setLoading(true);
|
|
||||||
const data = await analyticsApi.spending({
|
|
||||||
grouping: "week",
|
|
||||||
target_channel_id: selectedChannel.id,
|
|
||||||
});
|
|
||||||
setSpending(data);
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error loading spending data:", error);
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
loadData();
|
|
||||||
}, [selectedChannel]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<DashboardHeader breadcrumbs={[{ label: "Главная" }]} />
|
|
||||||
|
|
||||||
{/* Модальное окно добавления первого канала */}
|
|
||||||
<Dialog open={showAddChannelDialog} onOpenChange={() => {}}>
|
|
||||||
<DialogContent
|
|
||||||
className="sm:max-w-md"
|
|
||||||
onInteractOutside={(e) => e.preventDefault()}
|
|
||||||
onEscapeKeyDown={(e) => e.preventDefault()}
|
|
||||||
>
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>Добро пожаловать!</DialogTitle>
|
|
||||||
<DialogDescription>
|
|
||||||
Для начала работы добавьте ваш первый целевой канал
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
|
|
||||||
{isSuccess ? (
|
|
||||||
<div className="flex flex-col items-center justify-center py-8 space-y-4">
|
|
||||||
<div className="rounded-full bg-green-100 dark:bg-green-900 p-3">
|
|
||||||
<CheckCircle2 className="h-8 w-8 text-green-600 dark:text-green-400" />
|
|
||||||
</div>
|
|
||||||
<div className="text-center">
|
|
||||||
<h3 className="text-lg font-semibold">
|
|
||||||
Канал успешно добавлен!
|
|
||||||
</h3>
|
|
||||||
<p className="text-sm text-muted-foreground mt-1">
|
|
||||||
Страница обновится автоматически...
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="space-y-4">
|
|
||||||
<Alert>
|
|
||||||
<AlertDescription className="space-y-2">
|
|
||||||
<p className="font-medium">Шаги для добавления канала:</p>
|
|
||||||
<ol className="list-decimal list-inside space-y-1.5 text-sm">
|
|
||||||
<li>Откройте свой Telegram канал</li>
|
|
||||||
<li>Перейдите в настройки канала → Администраторы</li>
|
|
||||||
<li>
|
|
||||||
Добавьте бота{" "}
|
|
||||||
<a
|
|
||||||
href={`https://t.me/${BOT_USERNAME}`}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="font-mono text-primary hover:underline inline-flex items-center gap-1"
|
|
||||||
>
|
|
||||||
@{BOT_USERNAME}
|
|
||||||
<ExternalLink className="h-3 w-3" />
|
|
||||||
</a>{" "}
|
|
||||||
как администратора
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
Предоставьте боту права:{" "}
|
|
||||||
<span className="font-medium">
|
|
||||||
Публикация сообщений, Удаление сообщений
|
|
||||||
</span>
|
|
||||||
</li>
|
|
||||||
</ol>
|
|
||||||
</AlertDescription>
|
|
||||||
</Alert>
|
|
||||||
|
|
||||||
<div className="flex flex-col items-center justify-center py-6 space-y-3">
|
|
||||||
<Loader2 className="h-10 w-10 animate-spin text-primary" />
|
|
||||||
<p className="text-sm text-muted-foreground text-center">
|
|
||||||
Ждем добавления бота...
|
|
||||||
<br />
|
|
||||||
<span className="text-xs">
|
|
||||||
Как только вы добавите бота, канал автоматически появится
|
|
||||||
</span>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
|
|
||||||
{!selectedChannel ? (
|
|
||||||
<div className="flex flex-1 items-center justify-center">
|
|
||||||
<div className="text-center">
|
|
||||||
<h2 className="text-2xl font-semibold mb-2">
|
|
||||||
Выберите целевой канал
|
|
||||||
</h2>
|
|
||||||
<p className="text-muted-foreground">
|
|
||||||
Для начала работы выберите целевой канал в верхнем меню
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="flex flex-1 flex-col gap-4 p-4 pt-0 mt-4">
|
|
||||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
|
||||||
<Card>
|
|
||||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
|
||||||
<CardTitle className="text-sm font-medium">
|
|
||||||
Всего закупов
|
|
||||||
</CardTitle>
|
|
||||||
<ShoppingCart className="h-4 w-4 text-muted-foreground" />
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
{loading ? (
|
|
||||||
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<div className="text-2xl font-bold">
|
|
||||||
{formatNumber(spending?.total_cost)}
|
|
||||||
</div>
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
{formatNumber(0 || 0, true)} за прошлый месяц
|
|
||||||
</p>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card>
|
|
||||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
|
||||||
<CardTitle className="text-sm font-medium">
|
|
||||||
Подписчиков привлечено
|
|
||||||
</CardTitle>
|
|
||||||
<Users className="h-4 w-4 text-muted-foreground" />
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
{loading ? (
|
|
||||||
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<div className="text-2xl font-bold">
|
|
||||||
{formatNumber(spending?.total_subscriptions)}
|
|
||||||
</div>
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
{formatNumber(0 || 0, true)} за прошлый месяц
|
|
||||||
</p>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card>
|
|
||||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
|
||||||
<CardTitle className="text-sm font-medium">
|
|
||||||
Средний CPF
|
|
||||||
</CardTitle>
|
|
||||||
<TrendingUp className="h-4 w-4 text-muted-foreground" />
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
{loading ? (
|
|
||||||
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<div className="text-2xl font-bold">
|
|
||||||
₽{formatMetric(spending?.avg_cpf)}
|
|
||||||
</div>
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
{formatPercent(0 || 0, true)} от прошлого месяца
|
|
||||||
</p>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card>
|
|
||||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
|
||||||
<CardTitle className="text-sm font-medium">
|
|
||||||
Средний CPM
|
|
||||||
</CardTitle>
|
|
||||||
<BarChart3 className="h-4 w-4 text-muted-foreground" />
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
{loading ? (
|
|
||||||
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<div className="text-2xl font-bold">
|
|
||||||
₽{formatMetric(spending?.avg_cpm)}
|
|
||||||
</div>
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
{formatPercent(0 || 0, true)} от прошлого месяца
|
|
||||||
</p>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid gap-4 md:grid-cols-1">
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>Быстрый старт</CardTitle>
|
|
||||||
<CardDescription>Начните с основных действий</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-2">
|
|
||||||
<a
|
|
||||||
href="/purchases/new"
|
|
||||||
className="block text-sm text-primary hover:underline"
|
|
||||||
>
|
|
||||||
→ Создать новый закуп
|
|
||||||
</a>
|
|
||||||
<a
|
|
||||||
href="/creatives/new"
|
|
||||||
className="block text-sm text-primary hover:underline"
|
|
||||||
>
|
|
||||||
→ Создать креатив
|
|
||||||
</a>
|
|
||||||
<a
|
|
||||||
href="/external-channels/import"
|
|
||||||
className="block text-sm text-primary hover:underline"
|
|
||||||
>
|
|
||||||
→ Импортировать каналы
|
|
||||||
</a>
|
|
||||||
<a
|
|
||||||
href="/analytics"
|
|
||||||
className="block text-sm text-primary hover:underline"
|
|
||||||
>
|
|
||||||
→ Посмотреть аналитику
|
|
||||||
</a>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,400 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// Purchase Detail Page
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
import React, { useEffect, useState } from "react";
|
|
||||||
import { use } from "react";
|
|
||||||
import Link from "next/link";
|
|
||||||
import { useRouter } from "next/navigation";
|
|
||||||
import { DashboardHeader } from "@/components/layout/dashboard-header";
|
|
||||||
import {
|
|
||||||
Card,
|
|
||||||
CardContent,
|
|
||||||
CardDescription,
|
|
||||||
CardHeader,
|
|
||||||
CardTitle,
|
|
||||||
} from "@/components/ui/card";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { Badge } from "@/components/ui/badge";
|
|
||||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
|
||||||
import { purchasesApi } from "@/lib/api";
|
|
||||||
import {
|
|
||||||
formatNumber,
|
|
||||||
formatMetric,
|
|
||||||
formatCurrency,
|
|
||||||
formatDate,
|
|
||||||
formatPercent,
|
|
||||||
formatUsername,
|
|
||||||
} from "@/lib/utils/format";
|
|
||||||
import {
|
|
||||||
ShoppingCart,
|
|
||||||
ArrowLeft,
|
|
||||||
Loader2,
|
|
||||||
AlertCircle,
|
|
||||||
Archive,
|
|
||||||
Trash2,
|
|
||||||
BarChart3,
|
|
||||||
Users,
|
|
||||||
TrendingUp,
|
|
||||||
Eye,
|
|
||||||
Copy,
|
|
||||||
ExternalLink as ExternalLinkIcon,
|
|
||||||
Check,
|
|
||||||
Clock,
|
|
||||||
CheckCircle,
|
|
||||||
} from "lucide-react";
|
|
||||||
import type { Placement } from "@/lib/types/api";
|
|
||||||
|
|
||||||
interface PageProps {
|
|
||||||
params: Promise<{ id: string }>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function PurchaseDetailPage({ params }: PageProps) {
|
|
||||||
const resolvedParams = use(params);
|
|
||||||
const router = useRouter();
|
|
||||||
const [purchase, setPurchase] = useState<Placement | null>(null);
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
const [copied, setCopied] = useState(false);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const loadPurchase = async () => {
|
|
||||||
try {
|
|
||||||
setLoading(true);
|
|
||||||
const data = await purchasesApi.get(resolvedParams.id);
|
|
||||||
setPurchase(data);
|
|
||||||
} catch (err: any) {
|
|
||||||
setError(err?.error?.message || "Ошибка загрузки закупа");
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
loadPurchase();
|
|
||||||
}, [resolvedParams.id]);
|
|
||||||
|
|
||||||
const handleArchive = async () => {
|
|
||||||
if (!purchase) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
await purchasesApi.update(purchase.id, {
|
|
||||||
status: purchase.status === "active" ? "archived" : "active",
|
|
||||||
});
|
|
||||||
setPurchase({
|
|
||||||
...purchase,
|
|
||||||
status: purchase.status === "active" ? "archived" : "active",
|
|
||||||
});
|
|
||||||
} catch (err: any) {
|
|
||||||
alert(err?.error?.message || "Ошибка обновления закупа");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDelete = async () => {
|
|
||||||
if (!purchase) return;
|
|
||||||
if (!confirm("Удалить закуп?")) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
await purchasesApi.delete(purchase.id);
|
|
||||||
router.push("/purchases");
|
|
||||||
} catch (err: any) {
|
|
||||||
alert(err?.error?.message || "Ошибка удаления закупа");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleCopyLink = () => {
|
|
||||||
if (!purchase) return;
|
|
||||||
navigator.clipboard.writeText(purchase.invite_link);
|
|
||||||
setCopied(true);
|
|
||||||
setTimeout(() => setCopied(false), 2000);
|
|
||||||
};
|
|
||||||
|
|
||||||
if (loading) {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<DashboardHeader
|
|
||||||
breadcrumbs={[
|
|
||||||
{ label: "Закупы", href: "/purchases" },
|
|
||||||
{ label: "Загрузка..." },
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
<div className="flex flex-1 items-center justify-center">
|
|
||||||
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (error || !purchase) {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<DashboardHeader
|
|
||||||
breadcrumbs={[
|
|
||||||
{ label: "Закупы", href: "/purchases" },
|
|
||||||
{ label: "Ошибка" },
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
<div className="flex flex-1 flex-col gap-4 p-4 pt-0 mt-4">
|
|
||||||
<Alert variant="destructive">
|
|
||||||
<AlertCircle className="h-4 w-4" />
|
|
||||||
<AlertDescription>{error || "Закуп не найден"}</AlertDescription>
|
|
||||||
</Alert>
|
|
||||||
<Button asChild variant="outline">
|
|
||||||
<Link href="/purchases">
|
|
||||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
|
||||||
Вернуться к закупам
|
|
||||||
</Link>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Remove message text generation as we don't have creative.text in Placement
|
|
||||||
const messageText = `Текст сообщения с ссылкой: ${purchase.invite_link}`;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<DashboardHeader
|
|
||||||
breadcrumbs={[
|
|
||||||
{ label: "Главная", href: "/" },
|
|
||||||
{ label: "Закупы", href: "/purchases" },
|
|
||||||
{ label: `Закуп #${purchase.id.slice(0, 8)}` },
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
<div className="flex flex-1 flex-col gap-4 p-4 pt-0 mt-4">
|
|
||||||
<div className="flex items-start justify-between">
|
|
||||||
<div className="flex-1">
|
|
||||||
<div className="flex items-center gap-2 mb-2">
|
|
||||||
<ShoppingCart className="h-6 w-6" />
|
|
||||||
<h1 className="text-3xl font-bold tracking-tight">
|
|
||||||
{purchase.external_channel_title}
|
|
||||||
</h1>
|
|
||||||
{purchase.status === "archived" ? (
|
|
||||||
<Badge variant="secondary">Архив</Badge>
|
|
||||||
) : (
|
|
||||||
<Badge variant="default">
|
|
||||||
<CheckCircle className="h-3 w-3 mr-1" />
|
|
||||||
Активно
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-wrap gap-4 text-sm text-muted-foreground">
|
|
||||||
<span>
|
|
||||||
Целевой канал:{" "}
|
|
||||||
<Link
|
|
||||||
href={`/channels`}
|
|
||||||
className="hover:underline font-medium"
|
|
||||||
>
|
|
||||||
{purchase.target_channel_title}
|
|
||||||
</Link>
|
|
||||||
</span>
|
|
||||||
<span>•</span>
|
|
||||||
<span>
|
|
||||||
Креатив:{" "}
|
|
||||||
<Link
|
|
||||||
href={`/creatives`}
|
|
||||||
className="hover:underline font-medium"
|
|
||||||
>
|
|
||||||
{purchase.creative_name}
|
|
||||||
</Link>
|
|
||||||
</span>
|
|
||||||
<span>•</span>
|
|
||||||
<span>
|
|
||||||
Дата размещения: {formatDate(purchase.placement_date)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<Button variant="outline" onClick={handleArchive}>
|
|
||||||
<Archive className="mr-2 h-4 w-4" />
|
|
||||||
{purchase.status === "archived"
|
|
||||||
? "Разархивировать"
|
|
||||||
: "Архивировать"}
|
|
||||||
</Button>
|
|
||||||
<Button variant="destructive" onClick={handleDelete}>
|
|
||||||
<Trash2 className="mr-2 h-4 w-4" />
|
|
||||||
Удалить
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-5">
|
|
||||||
<Card>
|
|
||||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
|
||||||
<CardTitle className="text-sm font-medium">Стоимость</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<div className="text-2xl font-bold">
|
|
||||||
{formatCurrency(purchase.cost)}
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card>
|
|
||||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
|
||||||
<CardTitle className="text-sm font-medium">Подписчики</CardTitle>
|
|
||||||
<Users className="h-4 w-4 text-muted-foreground" />
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<div className="text-2xl font-bold">
|
|
||||||
{formatNumber(purchase.subscriptions_count)}
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card>
|
|
||||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
|
||||||
<CardTitle className="text-sm font-medium">Просмотры</CardTitle>
|
|
||||||
<Eye className="h-4 w-4 text-muted-foreground" />
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<div className="text-2xl font-bold">
|
|
||||||
{purchase.views_count
|
|
||||||
? formatNumber(purchase.views_count)
|
|
||||||
: "—"}
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card>
|
|
||||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
|
||||||
<CardTitle className="text-sm font-medium">
|
|
||||||
Статус просмотров
|
|
||||||
</CardTitle>
|
|
||||||
<TrendingUp className="h-4 w-4 text-muted-foreground" />
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<div className="text-lg font-medium">
|
|
||||||
{purchase.views_availability === "available"
|
|
||||||
? "Доступны"
|
|
||||||
: purchase.views_availability === "manual"
|
|
||||||
? "Вручную"
|
|
||||||
: purchase.views_availability === "unavailable"
|
|
||||||
? "Недоступны"
|
|
||||||
: "Неизвестно"}
|
|
||||||
</div>
|
|
||||||
<p className="text-xs text-muted-foreground mt-1">
|
|
||||||
{purchase.last_views_fetch_at
|
|
||||||
? `Обновлено: ${formatDate(purchase.last_views_fetch_at)}`
|
|
||||||
: "Еще не обновлялись"}
|
|
||||||
</p>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card>
|
|
||||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
|
||||||
<CardTitle className="text-sm font-medium">Тип ссылки</CardTitle>
|
|
||||||
<BarChart3 className="h-4 w-4 text-muted-foreground" />
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<div className="text-lg font-medium">
|
|
||||||
{purchase.invite_link_type === "approval"
|
|
||||||
? "С одобрением"
|
|
||||||
: "Публичная"}
|
|
||||||
</div>
|
|
||||||
<p className="text-xs text-muted-foreground mt-1">
|
|
||||||
{purchase.invite_link_type === "approval"
|
|
||||||
? "Отслеживание подписчиков"
|
|
||||||
: "Без отслеживания"}
|
|
||||||
</p>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid gap-4 md:grid-cols-2">
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>Пригласительная ссылка</CardTitle>
|
|
||||||
<CardDescription>
|
|
||||||
Используется для отслеживания подписок
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-4">
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<code className="flex-1 p-2 bg-muted rounded text-sm break-all">
|
|
||||||
{purchase.invite_link}
|
|
||||||
</code>
|
|
||||||
<Button onClick={handleCopyLink} variant="outline" size="icon">
|
|
||||||
{copied ? (
|
|
||||||
<Check className="h-4 w-4" />
|
|
||||||
) : (
|
|
||||||
<Copy className="h-4 w-4" />
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
{purchase.ad_post_url && (
|
|
||||||
<div className="pt-2">
|
|
||||||
<Label className="text-sm text-muted-foreground">
|
|
||||||
Ссылка на рекламный пост:
|
|
||||||
</Label>
|
|
||||||
<a
|
|
||||||
href={purchase.ad_post_url}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="flex items-center gap-1 text-sm hover:underline mt-1"
|
|
||||||
>
|
|
||||||
{purchase.ad_post_url}
|
|
||||||
<ExternalLinkIcon className="h-3 w-3" />
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>Рекламное сообщение</CardTitle>
|
|
||||||
<CardDescription>Текст для размещения</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<div className="rounded-lg border bg-muted/50 p-4">
|
|
||||||
<p className="text-sm whitespace-pre-wrap">{messageText}</p>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{purchase.comment && (
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>Комментарий</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
{purchase.comment}
|
|
||||||
</p>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>Заметка</CardTitle>
|
|
||||||
<CardDescription>
|
|
||||||
Детальная информация о размещении доступна через раздел "Подписки"
|
|
||||||
в главном меню
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
Для просмотра списка подписчиков и истории просмотров используйте
|
|
||||||
соответствующие разделы в главном меню.
|
|
||||||
</p>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function Label({
|
|
||||||
children,
|
|
||||||
className,
|
|
||||||
}: {
|
|
||||||
children: React.ReactNode;
|
|
||||||
className?: string;
|
|
||||||
}) {
|
|
||||||
return <div className={className}>{children}</div>;
|
|
||||||
}
|
|
||||||
@@ -1,478 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// Create Purchase Page
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
import React, { useEffect, useState } from "react";
|
|
||||||
import { useRouter } from "next/navigation";
|
|
||||||
import Link from "next/link";
|
|
||||||
import { DashboardHeader } from "@/components/layout/dashboard-header";
|
|
||||||
import {
|
|
||||||
Card,
|
|
||||||
CardContent,
|
|
||||||
CardDescription,
|
|
||||||
CardHeader,
|
|
||||||
CardTitle,
|
|
||||||
} from "@/components/ui/card";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { Input } from "@/components/ui/input";
|
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
|
||||||
import { Label } from "@/components/ui/label";
|
|
||||||
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
|
|
||||||
import {
|
|
||||||
Select,
|
|
||||||
SelectContent,
|
|
||||||
SelectItem,
|
|
||||||
SelectTrigger,
|
|
||||||
SelectValue,
|
|
||||||
} from "@/components/ui/select";
|
|
||||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
|
||||||
import {
|
|
||||||
purchasesApi,
|
|
||||||
channelsApi,
|
|
||||||
externalChannelsApi,
|
|
||||||
creativesApi,
|
|
||||||
} from "@/lib/api";
|
|
||||||
import {
|
|
||||||
AlertCircle,
|
|
||||||
Loader2,
|
|
||||||
ArrowLeft,
|
|
||||||
Info,
|
|
||||||
Copy,
|
|
||||||
Check,
|
|
||||||
} from "lucide-react";
|
|
||||||
import type { ExternalChannel, Creative } from "@/lib/types/api";
|
|
||||||
import { useTargetChannel } from "@/components/providers/target-channel-provider";
|
|
||||||
|
|
||||||
export default function CreatePurchasePage() {
|
|
||||||
const router = useRouter();
|
|
||||||
const { selectedChannel } = useTargetChannel();
|
|
||||||
const [externalChannels, setExternalChannels] = useState<ExternalChannel[]>(
|
|
||||||
[]
|
|
||||||
);
|
|
||||||
const [creatives, setCreatives] = useState<Creative[]>([]);
|
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
const [inviteLink, setInviteLink] = useState<string>("");
|
|
||||||
const [copied, setCopied] = useState(false);
|
|
||||||
|
|
||||||
const [formData, setFormData] = useState({
|
|
||||||
external_channel_id: "",
|
|
||||||
creative_id: "",
|
|
||||||
placement_date: "",
|
|
||||||
cost: "",
|
|
||||||
comment: "",
|
|
||||||
post_link: "",
|
|
||||||
invite_link_type: "public" as "public" | "approval",
|
|
||||||
});
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (selectedChannel) {
|
|
||||||
loadData();
|
|
||||||
}
|
|
||||||
}, [selectedChannel]);
|
|
||||||
|
|
||||||
const loadData = async () => {
|
|
||||||
if (!selectedChannel) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const [externalChannelsRes, creativesRes] = await Promise.all([
|
|
||||||
externalChannelsApi.list(selectedChannel.id),
|
|
||||||
creativesApi.list({ target_channel_id: selectedChannel.id }),
|
|
||||||
]);
|
|
||||||
setExternalChannels(externalChannelsRes.external_channels);
|
|
||||||
setCreatives(
|
|
||||||
creativesRes.creatives.filter((c) => c.status === "active")
|
|
||||||
);
|
|
||||||
} catch (err) {
|
|
||||||
console.error("Error loading data:", err);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const selectedCreative = creatives.find((c) => c.id === formData.creative_id);
|
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
|
||||||
e.preventDefault();
|
|
||||||
setError(null);
|
|
||||||
|
|
||||||
// Validation
|
|
||||||
if (!selectedChannel) {
|
|
||||||
setError("Выберите целевой канал в верхнем меню");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!formData.external_channel_id) {
|
|
||||||
setError("Выберите внешний канал");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!formData.creative_id) {
|
|
||||||
setError("Выберите креатив");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!formData.placement_date) {
|
|
||||||
setError("Укажите дату размещения");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!formData.cost || parseFloat(formData.cost) <= 0) {
|
|
||||||
setError("Укажите корректную стоимость");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
setIsLoading(true);
|
|
||||||
const result = await purchasesApi.create({
|
|
||||||
target_channel_id: selectedChannel.id,
|
|
||||||
external_channel_id: formData.external_channel_id,
|
|
||||||
creative_id: formData.creative_id,
|
|
||||||
placement_date: formData.placement_date,
|
|
||||||
cost: parseFloat(formData.cost),
|
|
||||||
comment: formData.comment || undefined,
|
|
||||||
ad_post_url: formData.post_link || undefined,
|
|
||||||
invite_link_type: formData.invite_link_type,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Показываем сгенерированную ссылку
|
|
||||||
setInviteLink(result.invite_link);
|
|
||||||
} catch (err: any) {
|
|
||||||
setError(err?.error?.message || "Ошибка создания закупа");
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleCopyLink = () => {
|
|
||||||
navigator.clipboard.writeText(inviteLink);
|
|
||||||
setCopied(true);
|
|
||||||
setTimeout(() => setCopied(false), 2000);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleCopyMessage = () => {
|
|
||||||
if (!selectedCreative) return;
|
|
||||||
const message = selectedCreative.text.replace("{link}", inviteLink);
|
|
||||||
navigator.clipboard.writeText(message);
|
|
||||||
setCopied(true);
|
|
||||||
setTimeout(() => setCopied(false), 2000);
|
|
||||||
};
|
|
||||||
|
|
||||||
if (inviteLink) {
|
|
||||||
const messageText = selectedCreative
|
|
||||||
? selectedCreative.text.replace("{link}", inviteLink)
|
|
||||||
: "";
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<DashboardHeader
|
|
||||||
breadcrumbs={[
|
|
||||||
{ label: "Главная", href: "/" },
|
|
||||||
{ label: "Закупы", href: "/purchases" },
|
|
||||||
{ label: "Создание" },
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
<div className="flex flex-1 flex-col gap-4 p-4 pt-0 mt-4 max-w-4xl mx-auto w-full">
|
|
||||||
<Card className="border-green-500">
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle className="flex items-center gap-2 text-green-600">
|
|
||||||
<Check className="h-5 w-5" />
|
|
||||||
Закуп успешно создан!
|
|
||||||
</CardTitle>
|
|
||||||
<CardDescription>
|
|
||||||
Используйте ссылку и готовое сообщение для размещения
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-4">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label>Пригласительная ссылка</Label>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<Input value={inviteLink} readOnly className="font-mono" />
|
|
||||||
<Button onClick={handleCopyLink} variant="outline">
|
|
||||||
{copied ? (
|
|
||||||
<Check className="h-4 w-4" />
|
|
||||||
) : (
|
|
||||||
<Copy className="h-4 w-4" />
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label>Готовое сообщение для размещения</Label>
|
|
||||||
<div className="relative">
|
|
||||||
<Textarea
|
|
||||||
value={messageText}
|
|
||||||
readOnly
|
|
||||||
rows={8}
|
|
||||||
className="pr-12"
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
onClick={handleCopyMessage}
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
className="absolute top-2 right-2"
|
|
||||||
>
|
|
||||||
{copied ? (
|
|
||||||
<Check className="h-4 w-4" />
|
|
||||||
) : (
|
|
||||||
<Copy className="h-4 w-4" />
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Alert>
|
|
||||||
<Info className="h-4 w-4" />
|
|
||||||
<AlertDescription>
|
|
||||||
Скопируйте готовое сообщение и разместите его во внешнем
|
|
||||||
канале. Все переходы по ссылке будут автоматически
|
|
||||||
отслеживаться.
|
|
||||||
</AlertDescription>
|
|
||||||
</Alert>
|
|
||||||
|
|
||||||
<div className="flex gap-2 pt-4">
|
|
||||||
<Button asChild>
|
|
||||||
<Link href="/purchases">Перейти к закупам</Link>
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => {
|
|
||||||
setInviteLink("");
|
|
||||||
setFormData({
|
|
||||||
external_channel_id: "",
|
|
||||||
creative_id: "",
|
|
||||||
placement_date: "",
|
|
||||||
cost: "",
|
|
||||||
comment: "",
|
|
||||||
post_link: "",
|
|
||||||
invite_link_type: "public",
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Создать еще
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<DashboardHeader
|
|
||||||
breadcrumbs={[
|
|
||||||
{ label: "Главная", href: "/" },
|
|
||||||
{ label: "Закупы", href: "/purchases" },
|
|
||||||
{ label: "Создание" },
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
<div className="flex flex-1 flex-col gap-4 p-4 pt-0 mt-4 max-w-4xl mx-auto w-full">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<h1 className="text-3xl font-bold tracking-tight">Создать закуп</h1>
|
|
||||||
<p className="text-muted-foreground mt-1">
|
|
||||||
Новое рекламное размещение
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<Button asChild variant="outline">
|
|
||||||
<Link href="/purchases">
|
|
||||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
|
||||||
Назад
|
|
||||||
</Link>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{error && (
|
|
||||||
<Alert variant="destructive">
|
|
||||||
<AlertCircle className="h-4 w-4" />
|
|
||||||
<AlertDescription>{error}</AlertDescription>
|
|
||||||
</Alert>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<form onSubmit={handleSubmit} className="space-y-4">
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>Основная информация</CardTitle>
|
|
||||||
<CardDescription>
|
|
||||||
Выберите каналы и креатив для размещения
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-4">
|
|
||||||
{selectedChannel && (
|
|
||||||
<Alert>
|
|
||||||
<Info className="h-4 w-4" />
|
|
||||||
<AlertDescription>
|
|
||||||
Размещение будет создано для канала: <strong>{selectedChannel.title}</strong>
|
|
||||||
</AlertDescription>
|
|
||||||
</Alert>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="grid gap-4 md:grid-cols-2">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="external_channel_id">Внешний канал *</Label>
|
|
||||||
<Select
|
|
||||||
value={formData.external_channel_id}
|
|
||||||
onValueChange={(value) =>
|
|
||||||
setFormData({
|
|
||||||
...formData,
|
|
||||||
external_channel_id: value,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<SelectTrigger>
|
|
||||||
<SelectValue placeholder="Выберите внешний канал" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{externalChannels.map((channel) => (
|
|
||||||
<SelectItem key={channel.id} value={channel.id}>
|
|
||||||
{channel.title}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="creative_id">Креатив *</Label>
|
|
||||||
<Select
|
|
||||||
value={formData.creative_id}
|
|
||||||
onValueChange={(value) =>
|
|
||||||
setFormData({ ...formData, creative_id: value })
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<SelectTrigger>
|
|
||||||
<SelectValue placeholder="Выберите креатив" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{creatives.map((creative) => (
|
|
||||||
<SelectItem key={creative.id} value={creative.id}>
|
|
||||||
{creative.name}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>Детали размещения</CardTitle>
|
|
||||||
<CardDescription>
|
|
||||||
Стоимость, дата и параметры ссылки
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-4">
|
|
||||||
<div className="grid gap-4 md:grid-cols-2">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="placement_date">Дата размещения *</Label>
|
|
||||||
<Input
|
|
||||||
id="placement_date"
|
|
||||||
type="datetime-local"
|
|
||||||
value={formData.placement_date}
|
|
||||||
onChange={(e) =>
|
|
||||||
setFormData({
|
|
||||||
...formData,
|
|
||||||
placement_date: e.target.value,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="cost">Стоимость (₽) *</Label>
|
|
||||||
<Input
|
|
||||||
id="cost"
|
|
||||||
type="number"
|
|
||||||
step="0.01"
|
|
||||||
value={formData.cost}
|
|
||||||
onChange={(e) =>
|
|
||||||
setFormData({ ...formData, cost: e.target.value })
|
|
||||||
}
|
|
||||||
placeholder="1000"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="post_link">Ссылка на рекламное сообщение</Label>
|
|
||||||
<Input
|
|
||||||
id="post_link"
|
|
||||||
type="url"
|
|
||||||
value={formData.post_link}
|
|
||||||
onChange={(e) =>
|
|
||||||
setFormData({ ...formData, post_link: e.target.value })
|
|
||||||
}
|
|
||||||
placeholder="https://t.me/channel/123"
|
|
||||||
/>
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
Для автоматического отслеживания просмотров
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label>Тип пригласительной ссылки *</Label>
|
|
||||||
<RadioGroup
|
|
||||||
value={formData.invite_link_type}
|
|
||||||
onValueChange={(value: "public" | "approval") =>
|
|
||||||
setFormData({ ...formData, invite_link_type: value })
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<div className="flex items-center space-x-2">
|
|
||||||
<RadioGroupItem value="public" id="public" />
|
|
||||||
<Label htmlFor="public" className="font-normal">
|
|
||||||
Открытая (пользователь сразу вступает в канал)
|
|
||||||
</Label>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center space-x-2">
|
|
||||||
<RadioGroupItem value="approval" id="approval" />
|
|
||||||
<Label htmlFor="approval" className="font-normal">
|
|
||||||
С одобрением (запрос на вступление через бота)
|
|
||||||
</Label>
|
|
||||||
</div>
|
|
||||||
</RadioGroup>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="comment">Комментарий</Label>
|
|
||||||
<Textarea
|
|
||||||
id="comment"
|
|
||||||
value={formData.comment}
|
|
||||||
onChange={(e) =>
|
|
||||||
setFormData({ ...formData, comment: e.target.value })
|
|
||||||
}
|
|
||||||
placeholder="Дополнительные заметки о закупе"
|
|
||||||
rows={3}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<div className="flex justify-end gap-2">
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => router.push("/purchases")}
|
|
||||||
>
|
|
||||||
Отмена
|
|
||||||
</Button>
|
|
||||||
<Button type="submit" disabled={isLoading}>
|
|
||||||
{isLoading ? (
|
|
||||||
<>
|
|
||||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
|
||||||
Создание...
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
"Создать закуп"
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,964 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// Purchases List Page
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
import React, { useEffect, useState } from "react";
|
|
||||||
import Link from "next/link";
|
|
||||||
import { DashboardHeader } from "@/components/layout/dashboard-header";
|
|
||||||
import {
|
|
||||||
Card,
|
|
||||||
CardContent,
|
|
||||||
CardDescription,
|
|
||||||
CardHeader,
|
|
||||||
CardTitle,
|
|
||||||
} from "@/components/ui/card";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { Badge } from "@/components/ui/badge";
|
|
||||||
import { Input } from "@/components/ui/input";
|
|
||||||
import {
|
|
||||||
Select,
|
|
||||||
SelectContent,
|
|
||||||
SelectItem,
|
|
||||||
SelectTrigger,
|
|
||||||
SelectValue,
|
|
||||||
} from "@/components/ui/select";
|
|
||||||
import {
|
|
||||||
Table,
|
|
||||||
TableBody,
|
|
||||||
TableCell,
|
|
||||||
TableHead,
|
|
||||||
TableHeader,
|
|
||||||
TableRow,
|
|
||||||
TableFooter,
|
|
||||||
} from "@/components/ui/table";
|
|
||||||
import {
|
|
||||||
DropdownMenu,
|
|
||||||
DropdownMenuContent,
|
|
||||||
DropdownMenuItem,
|
|
||||||
DropdownMenuTrigger,
|
|
||||||
} from "@/components/ui/dropdown-menu";
|
|
||||||
import { purchasesApi, channelsApi } from "@/lib/api";
|
|
||||||
import {
|
|
||||||
formatNumber,
|
|
||||||
formatMetric,
|
|
||||||
formatCurrency,
|
|
||||||
formatDate,
|
|
||||||
formatPercent,
|
|
||||||
} from "@/lib/utils/format";
|
|
||||||
import {
|
|
||||||
ShoppingCart,
|
|
||||||
Loader2,
|
|
||||||
AlertCircle,
|
|
||||||
Plus,
|
|
||||||
Search,
|
|
||||||
Filter,
|
|
||||||
Eye,
|
|
||||||
CheckCircle,
|
|
||||||
Clock,
|
|
||||||
Archive,
|
|
||||||
ArrowUpDown,
|
|
||||||
ArrowUp,
|
|
||||||
ArrowDown,
|
|
||||||
Download,
|
|
||||||
} from "lucide-react";
|
|
||||||
import type { Purchase } from "@/lib/types/api";
|
|
||||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
|
||||||
import { useTargetChannel } from "@/components/providers/target-channel-provider";
|
|
||||||
import * as XLSX from "xlsx";
|
|
||||||
|
|
||||||
type SortField =
|
|
||||||
| "status"
|
|
||||||
| "placement_date"
|
|
||||||
| "cost"
|
|
||||||
| "subscriptions_count"
|
|
||||||
| "views_count"
|
|
||||||
| "cpm"
|
|
||||||
| "cpl"
|
|
||||||
| null;
|
|
||||||
type SortDirection = "asc" | "desc" | null;
|
|
||||||
|
|
||||||
type AggregateFunction = "sum" | "avg" | "median" | "max" | "min";
|
|
||||||
|
|
||||||
type AggregateConfig = {
|
|
||||||
cost: AggregateFunction;
|
|
||||||
subscriptions_count: AggregateFunction;
|
|
||||||
views_count: AggregateFunction;
|
|
||||||
cpm: AggregateFunction;
|
|
||||||
cpl: AggregateFunction;
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function PurchasesPage() {
|
|
||||||
const { selectedChannel } = useTargetChannel();
|
|
||||||
const [purchases, setPurchases] = useState<Purchase[]>([]);
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
const [searchQuery, setSearchQuery] = useState("");
|
|
||||||
const [filterStatus, setFilterStatus] = useState<string>("all");
|
|
||||||
const [sortField, setSortField] = useState<SortField>(null);
|
|
||||||
const [sortDirection, setSortDirection] = useState<SortDirection>(null);
|
|
||||||
const [aggregateConfig, setAggregateConfig] = useState<AggregateConfig>({
|
|
||||||
cost: "sum",
|
|
||||||
subscriptions_count: "sum",
|
|
||||||
views_count: "sum",
|
|
||||||
cpm: "avg",
|
|
||||||
cpl: "avg",
|
|
||||||
});
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (selectedChannel) {
|
|
||||||
loadData();
|
|
||||||
} else {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
}, [selectedChannel]);
|
|
||||||
|
|
||||||
const loadData = async () => {
|
|
||||||
if (!selectedChannel) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
setLoading(true);
|
|
||||||
const purchasesRes = await purchasesApi.list({
|
|
||||||
target_channel_id: selectedChannel.id,
|
|
||||||
});
|
|
||||||
setPurchases(purchasesRes.placements);
|
|
||||||
} catch (err: any) {
|
|
||||||
setError(err?.error?.message || "Ошибка загрузки данных");
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Вычисление метрик
|
|
||||||
const calculateCPM = (purchase: Purchase): number | null => {
|
|
||||||
if (!purchase.cost || !purchase.views_count || purchase.views_count === 0)
|
|
||||||
return null;
|
|
||||||
return (purchase.cost / purchase.views_count) * 1000;
|
|
||||||
};
|
|
||||||
|
|
||||||
const calculateCPL = (purchase: Purchase): number | null => {
|
|
||||||
if (
|
|
||||||
!purchase.cost ||
|
|
||||||
!purchase.subscriptions_count ||
|
|
||||||
purchase.subscriptions_count === 0
|
|
||||||
)
|
|
||||||
return null;
|
|
||||||
return purchase.cost / purchase.subscriptions_count;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Функции агрегации
|
|
||||||
const calculateAggregate = (
|
|
||||||
values: (number | null)[],
|
|
||||||
func: AggregateFunction
|
|
||||||
): number | null => {
|
|
||||||
const filtered = values.filter((v) => v !== null) as number[];
|
|
||||||
if (filtered.length === 0) return null;
|
|
||||||
|
|
||||||
switch (func) {
|
|
||||||
case "sum":
|
|
||||||
return filtered.reduce((sum, val) => sum + val, 0);
|
|
||||||
case "avg":
|
|
||||||
return filtered.reduce((sum, val) => sum + val, 0) / filtered.length;
|
|
||||||
case "median":
|
|
||||||
const sorted = [...filtered].sort((a, b) => a - b);
|
|
||||||
const mid = Math.floor(sorted.length / 2);
|
|
||||||
return sorted.length % 2 === 0
|
|
||||||
? (sorted[mid - 1] + sorted[mid]) / 2
|
|
||||||
: sorted[mid];
|
|
||||||
case "max":
|
|
||||||
return Math.max(...filtered);
|
|
||||||
case "min":
|
|
||||||
return Math.min(...filtered);
|
|
||||||
default:
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const getAggregateFunctionLabel = (func: AggregateFunction): string => {
|
|
||||||
switch (func) {
|
|
||||||
case "sum":
|
|
||||||
return "Сумма";
|
|
||||||
case "avg":
|
|
||||||
return "Среднее";
|
|
||||||
case "median":
|
|
||||||
return "Медиана";
|
|
||||||
case "max":
|
|
||||||
return "Наибольшее";
|
|
||||||
case "min":
|
|
||||||
return "Наименьшее";
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Функции экспорта
|
|
||||||
const getExportData = () => {
|
|
||||||
return filteredPurchases.map((purchase) => {
|
|
||||||
const cpm = calculateCPM(purchase);
|
|
||||||
const cpl = calculateCPL(purchase);
|
|
||||||
|
|
||||||
return {
|
|
||||||
Статус: purchase.status === "archived" ? "Архив" : "Активно",
|
|
||||||
"Целевой канал": purchase.target_channel_title,
|
|
||||||
"Внешний канал": purchase.external_channel_title,
|
|
||||||
Креатив: purchase.creative_name,
|
|
||||||
Дата: formatDate(purchase.placement_date),
|
|
||||||
Стоимость: purchase.cost,
|
|
||||||
Подписки: purchase.subscriptions_count,
|
|
||||||
Просмотры: purchase.views_count || 0,
|
|
||||||
CPM: cpm !== null ? Number(cpm.toFixed(2)) : null,
|
|
||||||
CPL: cpl !== null ? Number(cpl.toFixed(2)) : null,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const getExportTotals = () => {
|
|
||||||
return {
|
|
||||||
Статус: "",
|
|
||||||
"Целевой канал": "",
|
|
||||||
"Внешний канал": "",
|
|
||||||
Креатив: "",
|
|
||||||
Дата: "Итого",
|
|
||||||
Стоимость:
|
|
||||||
calculateAggregate(
|
|
||||||
filteredPurchases.map((p) => p.cost),
|
|
||||||
aggregateConfig.cost
|
|
||||||
) || 0,
|
|
||||||
Подписки:
|
|
||||||
calculateAggregate(
|
|
||||||
filteredPurchases.map((p) => p.subscriptions_count),
|
|
||||||
aggregateConfig.subscriptions_count
|
|
||||||
) || 0,
|
|
||||||
Просмотры:
|
|
||||||
calculateAggregate(
|
|
||||||
filteredPurchases.map((p) => p.views_count),
|
|
||||||
aggregateConfig.views_count
|
|
||||||
) || 0,
|
|
||||||
CPM:
|
|
||||||
calculateAggregate(
|
|
||||||
filteredPurchases.map((p) => calculateCPM(p)),
|
|
||||||
aggregateConfig.cpm
|
|
||||||
) || 0,
|
|
||||||
CPL:
|
|
||||||
calculateAggregate(
|
|
||||||
filteredPurchases.map((p) => calculateCPL(p)),
|
|
||||||
aggregateConfig.cpl
|
|
||||||
) || 0,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
const exportToExcel = () => {
|
|
||||||
const data = getExportData();
|
|
||||||
const totals = getExportTotals();
|
|
||||||
const exportData = [...data, totals];
|
|
||||||
|
|
||||||
const ws = XLSX.utils.json_to_sheet(exportData);
|
|
||||||
const wb = XLSX.utils.book_new();
|
|
||||||
XLSX.utils.book_append_sheet(wb, ws, "Закупы");
|
|
||||||
|
|
||||||
// Форматирование ширины столбцов
|
|
||||||
const colWidths = [
|
|
||||||
{ wch: 10 }, // Статус
|
|
||||||
{ wch: 25 }, // Целевой канал
|
|
||||||
{ wch: 25 }, // Внешний канал
|
|
||||||
{ wch: 30 }, // Креатив
|
|
||||||
{ wch: 12 }, // Дата
|
|
||||||
{ wch: 12 }, // Стоимость
|
|
||||||
{ wch: 10 }, // Подписки
|
|
||||||
{ wch: 12 }, // Просмотры
|
|
||||||
{ wch: 10 }, // CPM
|
|
||||||
{ wch: 10 }, // CPL
|
|
||||||
];
|
|
||||||
ws["!cols"] = colWidths;
|
|
||||||
|
|
||||||
XLSX.writeFile(wb, `Закупы_${new Date().toISOString().split("T")[0]}.xlsx`);
|
|
||||||
};
|
|
||||||
|
|
||||||
const exportToCSV = () => {
|
|
||||||
const data = getExportData();
|
|
||||||
const totals = getExportTotals();
|
|
||||||
const exportData = [...data, totals];
|
|
||||||
|
|
||||||
// Заголовки
|
|
||||||
const headers = Object.keys(exportData[0]);
|
|
||||||
const csvContent = [
|
|
||||||
headers.join(","),
|
|
||||||
...exportData.map((row) =>
|
|
||||||
headers
|
|
||||||
.map((header) => {
|
|
||||||
const value = row[header as keyof typeof row];
|
|
||||||
// Обработка значений с запятыми или кавычками
|
|
||||||
if (
|
|
||||||
value === null ||
|
|
||||||
value === undefined ||
|
|
||||||
value === "" ||
|
|
||||||
value === 0
|
|
||||||
) {
|
|
||||||
return value === null ? "" : value;
|
|
||||||
}
|
|
||||||
const stringValue = String(value);
|
|
||||||
if (stringValue.includes(",") || stringValue.includes('"')) {
|
|
||||||
return `"${stringValue.replace(/"/g, '""')}"`;
|
|
||||||
}
|
|
||||||
return stringValue;
|
|
||||||
})
|
|
||||||
.join(",")
|
|
||||||
),
|
|
||||||
].join("\n");
|
|
||||||
|
|
||||||
const blob = new Blob(["\ufeff" + csvContent], {
|
|
||||||
type: "text/csv;charset=utf-8;",
|
|
||||||
});
|
|
||||||
const link = document.createElement("a");
|
|
||||||
link.href = URL.createObjectURL(blob);
|
|
||||||
link.download = `Закупы_${new Date().toISOString().split("T")[0]}.csv`;
|
|
||||||
link.click();
|
|
||||||
URL.revokeObjectURL(link.href);
|
|
||||||
};
|
|
||||||
|
|
||||||
const exportToTXT = () => {
|
|
||||||
const data = getExportData();
|
|
||||||
const totals = getExportTotals();
|
|
||||||
const exportData = [...data, totals];
|
|
||||||
|
|
||||||
const headers = Object.keys(exportData[0]);
|
|
||||||
const columnWidths = headers.map((header) => {
|
|
||||||
const maxLength = Math.max(
|
|
||||||
header.length,
|
|
||||||
...exportData.map((row) => {
|
|
||||||
const value = row[header as keyof typeof row];
|
|
||||||
return String(value || "").length;
|
|
||||||
})
|
|
||||||
);
|
|
||||||
return Math.min(maxLength + 2, 30);
|
|
||||||
});
|
|
||||||
|
|
||||||
const txtContent = [
|
|
||||||
// Заголовки
|
|
||||||
headers.map((header, i) => header.padEnd(columnWidths[i])).join(" | "),
|
|
||||||
// Разделитель
|
|
||||||
columnWidths.map((width) => "-".repeat(width)).join("-+-"),
|
|
||||||
// Данные
|
|
||||||
...exportData.map((row) =>
|
|
||||||
headers
|
|
||||||
.map((header, i) => {
|
|
||||||
const value = row[header as keyof typeof row];
|
|
||||||
return String(value || "").padEnd(columnWidths[i]);
|
|
||||||
})
|
|
||||||
.join(" | ")
|
|
||||||
),
|
|
||||||
].join("\n");
|
|
||||||
|
|
||||||
const blob = new Blob([txtContent], { type: "text/plain;charset=utf-8" });
|
|
||||||
const link = document.createElement("a");
|
|
||||||
link.href = URL.createObjectURL(blob);
|
|
||||||
link.download = `Закупы_${new Date().toISOString().split("T")[0]}.txt`;
|
|
||||||
link.click();
|
|
||||||
URL.revokeObjectURL(link.href);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Обработка сортировки
|
|
||||||
const handleSort = (field: SortField) => {
|
|
||||||
if (sortField === field) {
|
|
||||||
// Переключение: asc -> desc -> null -> asc
|
|
||||||
if (sortDirection === "asc") {
|
|
||||||
setSortDirection("desc");
|
|
||||||
} else if (sortDirection === "desc") {
|
|
||||||
setSortField(null);
|
|
||||||
setSortDirection(null);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
setSortField(field);
|
|
||||||
setSortDirection("asc");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const getSortIcon = (field: SortField) => {
|
|
||||||
if (sortField !== field) {
|
|
||||||
return <ArrowUpDown className="ml-2 h-4 w-4 inline" />;
|
|
||||||
}
|
|
||||||
if (sortDirection === "asc") {
|
|
||||||
return <ArrowUp className="ml-2 h-4 w-4 inline" />;
|
|
||||||
}
|
|
||||||
return <ArrowDown className="ml-2 h-4 w-4 inline" />;
|
|
||||||
};
|
|
||||||
|
|
||||||
const filteredPurchases = purchases
|
|
||||||
.filter((purchase) => {
|
|
||||||
// Поиск
|
|
||||||
const matchesSearch =
|
|
||||||
purchase.external_channel_title
|
|
||||||
.toLowerCase()
|
|
||||||
.includes(searchQuery.toLowerCase()) ||
|
|
||||||
purchase.creative_name
|
|
||||||
.toLowerCase()
|
|
||||||
.includes(searchQuery.toLowerCase());
|
|
||||||
|
|
||||||
// Фильтр по статусу
|
|
||||||
let matchesStatus = true;
|
|
||||||
if (filterStatus === "archived") {
|
|
||||||
matchesStatus = purchase.status === "archived";
|
|
||||||
} else if (filterStatus === "active") {
|
|
||||||
matchesStatus = purchase.status === "active";
|
|
||||||
}
|
|
||||||
|
|
||||||
return matchesSearch && matchesStatus;
|
|
||||||
})
|
|
||||||
.sort((a, b) => {
|
|
||||||
if (!sortField || !sortDirection) return 0;
|
|
||||||
|
|
||||||
let aValue: number | string | null = 0;
|
|
||||||
let bValue: number | string | null = 0;
|
|
||||||
|
|
||||||
switch (sortField) {
|
|
||||||
case "status":
|
|
||||||
aValue = a.status;
|
|
||||||
bValue = b.status;
|
|
||||||
// Сравниваем строки
|
|
||||||
if (typeof aValue === "string" && typeof bValue === "string") {
|
|
||||||
return sortDirection === "asc"
|
|
||||||
? aValue.localeCompare(bValue)
|
|
||||||
: bValue.localeCompare(aValue);
|
|
||||||
}
|
|
||||||
return 0;
|
|
||||||
case "placement_date":
|
|
||||||
aValue = new Date(a.placement_date).getTime();
|
|
||||||
bValue = new Date(b.placement_date).getTime();
|
|
||||||
break;
|
|
||||||
case "cost":
|
|
||||||
aValue = a.cost;
|
|
||||||
bValue = b.cost;
|
|
||||||
break;
|
|
||||||
case "subscriptions_count":
|
|
||||||
aValue = a.subscriptions_count;
|
|
||||||
bValue = b.subscriptions_count;
|
|
||||||
break;
|
|
||||||
case "views_count":
|
|
||||||
aValue = a.views_count || 0;
|
|
||||||
bValue = b.views_count || 0;
|
|
||||||
break;
|
|
||||||
case "cpm":
|
|
||||||
aValue = calculateCPM(a);
|
|
||||||
bValue = calculateCPM(b);
|
|
||||||
break;
|
|
||||||
case "cpl":
|
|
||||||
aValue = calculateCPL(a);
|
|
||||||
bValue = calculateCPL(b);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Обработка null значений для чисел
|
|
||||||
if (typeof aValue === "number" && typeof bValue === "number") {
|
|
||||||
if (aValue === null && bValue === null) return 0;
|
|
||||||
if (aValue === null) return 1;
|
|
||||||
if (bValue === null) return -1;
|
|
||||||
|
|
||||||
if (sortDirection === "asc") {
|
|
||||||
return aValue - bValue;
|
|
||||||
} else {
|
|
||||||
return bValue - aValue;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return 0;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Статистика
|
|
||||||
const stats = {
|
|
||||||
total: purchases.length,
|
|
||||||
active: purchases.filter((p) => p.status === "active").length,
|
|
||||||
archived: purchases.filter((p) => p.status === "archived").length,
|
|
||||||
totalCost: purchases.reduce((sum, p) => sum + (p.cost || 0), 0),
|
|
||||||
totalSubscriptions: purchases.reduce(
|
|
||||||
(sum, p) => sum + p.subscriptions_count,
|
|
||||||
0
|
|
||||||
),
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<DashboardHeader
|
|
||||||
breadcrumbs={[{ label: "Главная", href: "/" }, { label: "Закупы" }]}
|
|
||||||
/>
|
|
||||||
<div className="flex flex-1 flex-col gap-4 p-4 pt-0 mt-4">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<h1 className="text-3xl font-bold tracking-tight">Закупы</h1>
|
|
||||||
<p className="text-muted-foreground mt-1">
|
|
||||||
Рекламные размещения во внешних каналах
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Button asChild>
|
|
||||||
<Link href="/purchases/new">
|
|
||||||
<Plus className="mr-2 h-4 w-4" />
|
|
||||||
Создать закуп
|
|
||||||
</Link>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{error && (
|
|
||||||
<Alert variant="destructive">
|
|
||||||
<AlertCircle className="h-4 w-4" />
|
|
||||||
<AlertDescription>{error}</AlertDescription>
|
|
||||||
</Alert>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Card>
|
|
||||||
<CardContent className="justify-between flex flex-row">
|
|
||||||
<div className="grid gap-4 md:grid-cols-3">
|
|
||||||
<div className="relative">
|
|
||||||
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
|
|
||||||
<Input
|
|
||||||
placeholder="Поиск по названию..."
|
|
||||||
value={searchQuery}
|
|
||||||
onChange={(e) => setSearchQuery(e.target.value)}
|
|
||||||
className="pl-8"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Select value={filterStatus} onValueChange={setFilterStatus}>
|
|
||||||
<SelectTrigger>
|
|
||||||
<SelectValue placeholder="Статус" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="all">Все статусы</SelectItem>
|
|
||||||
<SelectItem value="completed">Завершено</SelectItem>
|
|
||||||
<SelectItem value="scheduled">Запланировано</SelectItem>
|
|
||||||
<SelectItem value="archived">Архив</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Badge variant="secondary">
|
|
||||||
Найдено: {formatNumber(filteredPurchases.length)}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card>
|
|
||||||
<CardHeader className="flex flex-row justify-between">
|
|
||||||
<div className="">
|
|
||||||
<CardTitle>Список закупов</CardTitle>
|
|
||||||
<CardDescription>Все рекламные размещения</CardDescription>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<DropdownMenu>
|
|
||||||
<DropdownMenuTrigger asChild>
|
|
||||||
<Button variant="outline">
|
|
||||||
<Download className="mr-2 h-4 w-4" />
|
|
||||||
Экспорт
|
|
||||||
</Button>
|
|
||||||
</DropdownMenuTrigger>
|
|
||||||
<DropdownMenuContent align="end">
|
|
||||||
<DropdownMenuItem onClick={exportToExcel}>
|
|
||||||
Экспорт в Excel (.xlsx)
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuItem onClick={exportToCSV}>
|
|
||||||
Экспорт в CSV (.csv)
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuItem onClick={exportToTXT}>
|
|
||||||
Экспорт в TXT (.txt)
|
|
||||||
</DropdownMenuItem>
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
{loading ? (
|
|
||||||
<div className="flex items-center justify-center p-12">
|
|
||||||
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
|
||||||
</div>
|
|
||||||
) : filteredPurchases.length === 0 ? (
|
|
||||||
<div className="text-center py-12">
|
|
||||||
<ShoppingCart className="h-12 w-12 text-muted-foreground mx-auto mb-4" />
|
|
||||||
<p className="text-lg font-medium text-muted-foreground">
|
|
||||||
{searchQuery || filterStatus !== "all"
|
|
||||||
? "Закупы не найдены"
|
|
||||||
: "Нет закупов"}
|
|
||||||
</p>
|
|
||||||
<p className="text-sm text-muted-foreground mt-1">
|
|
||||||
{searchQuery || filterStatus !== "all"
|
|
||||||
? "Попробуйте изменить фильтры"
|
|
||||||
: "Создайте первый закуп"}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<Table>
|
|
||||||
<TableHeader>
|
|
||||||
<TableRow>
|
|
||||||
<TableHead
|
|
||||||
className="cursor-pointer hover:bg-muted/50"
|
|
||||||
onClick={() => handleSort("status")}
|
|
||||||
>
|
|
||||||
Статус
|
|
||||||
{getSortIcon("status")}
|
|
||||||
</TableHead>
|
|
||||||
<TableHead>Целевой канал</TableHead>
|
|
||||||
<TableHead>Внешний канал</TableHead>
|
|
||||||
<TableHead>Креатив</TableHead>
|
|
||||||
<TableHead
|
|
||||||
className="cursor-pointer hover:bg-muted/50"
|
|
||||||
onClick={() => handleSort("placement_date")}
|
|
||||||
>
|
|
||||||
Дата
|
|
||||||
{getSortIcon("placement_date")}
|
|
||||||
</TableHead>
|
|
||||||
<TableHead
|
|
||||||
className="text-right cursor-pointer hover:bg-muted/50"
|
|
||||||
onClick={() => handleSort("cost")}
|
|
||||||
>
|
|
||||||
Стоимость
|
|
||||||
{getSortIcon("cost")}
|
|
||||||
</TableHead>
|
|
||||||
<TableHead
|
|
||||||
className="text-right cursor-pointer hover:bg-muted/50"
|
|
||||||
onClick={() => handleSort("subscriptions_count")}
|
|
||||||
>
|
|
||||||
Подписки
|
|
||||||
{getSortIcon("subscriptions_count")}
|
|
||||||
</TableHead>
|
|
||||||
<TableHead
|
|
||||||
className="text-right cursor-pointer hover:bg-muted/50"
|
|
||||||
onClick={() => handleSort("views_count")}
|
|
||||||
>
|
|
||||||
Просмотры
|
|
||||||
{getSortIcon("views_count")}
|
|
||||||
</TableHead>
|
|
||||||
<TableHead
|
|
||||||
className="text-right cursor-pointer hover:bg-muted/50"
|
|
||||||
onClick={() => handleSort("cpm")}
|
|
||||||
>
|
|
||||||
CPM
|
|
||||||
{getSortIcon("cpm")}
|
|
||||||
</TableHead>
|
|
||||||
<TableHead
|
|
||||||
className="text-right cursor-pointer hover:bg-muted/50"
|
|
||||||
onClick={() => handleSort("cpl")}
|
|
||||||
>
|
|
||||||
CPL
|
|
||||||
{getSortIcon("cpl")}
|
|
||||||
</TableHead>
|
|
||||||
<TableHead></TableHead>
|
|
||||||
</TableRow>
|
|
||||||
</TableHeader>
|
|
||||||
<TableBody>
|
|
||||||
{filteredPurchases.map((purchase) => {
|
|
||||||
const cpm = calculateCPM(purchase);
|
|
||||||
const cpl = calculateCPL(purchase);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<TableRow key={purchase.id}>
|
|
||||||
<TableCell>
|
|
||||||
{purchase.status === "archived" ? (
|
|
||||||
<Badge variant="secondary">
|
|
||||||
<Archive className="h-3 w-3 mr-1" />
|
|
||||||
Архив
|
|
||||||
</Badge>
|
|
||||||
) : (
|
|
||||||
<Badge variant="default">
|
|
||||||
<CheckCircle className="h-3 w-3 mr-1" />
|
|
||||||
Активно
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="font-medium">
|
|
||||||
{purchase.target_channel_title}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>{purchase.external_channel_title}</TableCell>
|
|
||||||
<TableCell className="max-w-[200px] truncate">
|
|
||||||
{purchase.creative_name}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
{formatDate(purchase.placement_date)}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="text-right">
|
|
||||||
{formatCurrency(purchase.cost)}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="text-right">
|
|
||||||
{formatNumber(purchase.subscriptions_count)}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="text-right">
|
|
||||||
{purchase.views_count
|
|
||||||
? formatNumber(purchase.views_count)
|
|
||||||
: "—"}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="text-right">
|
|
||||||
{cpm !== null ? formatCurrency(cpm) : "—"}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="text-right">
|
|
||||||
{cpl !== null ? formatCurrency(cpl) : "—"}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="text-right">
|
|
||||||
<Button asChild variant="ghost" size="sm">
|
|
||||||
<Link href={`/purchases/${purchase.id}`}>
|
|
||||||
<Eye className="h-4 w-4" />
|
|
||||||
</Link>
|
|
||||||
</Button>
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</TableBody>
|
|
||||||
<TableFooter>
|
|
||||||
<TableRow>
|
|
||||||
<TableCell colSpan={5} className="font-semibold">
|
|
||||||
Итого
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="text-right font-semibold">
|
|
||||||
<div className="flex items-center justify-end gap-2">
|
|
||||||
<DropdownMenu>
|
|
||||||
<DropdownMenuTrigger asChild>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
className="h-6 w-6 p-0"
|
|
||||||
>
|
|
||||||
<span className="text-xs">ƒ</span>
|
|
||||||
</Button>
|
|
||||||
</DropdownMenuTrigger>
|
|
||||||
<DropdownMenuContent align="end">
|
|
||||||
{(
|
|
||||||
[
|
|
||||||
"sum",
|
|
||||||
"avg",
|
|
||||||
"median",
|
|
||||||
"max",
|
|
||||||
"min",
|
|
||||||
] as AggregateFunction[]
|
|
||||||
).map((func) => (
|
|
||||||
<DropdownMenuItem
|
|
||||||
key={func}
|
|
||||||
onClick={() =>
|
|
||||||
setAggregateConfig({
|
|
||||||
...aggregateConfig,
|
|
||||||
cost: func,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
className={
|
|
||||||
aggregateConfig.cost === func
|
|
||||||
? "bg-accent"
|
|
||||||
: ""
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{getAggregateFunctionLabel(func)}
|
|
||||||
</DropdownMenuItem>
|
|
||||||
))}
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
{formatCurrency(
|
|
||||||
calculateAggregate(
|
|
||||||
filteredPurchases.map((p) => p.cost),
|
|
||||||
aggregateConfig.cost
|
|
||||||
) || 0
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="text-right font-semibold">
|
|
||||||
<div className="flex items-center justify-end gap-2">
|
|
||||||
<DropdownMenu>
|
|
||||||
<DropdownMenuTrigger asChild>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
className="h-6 w-6 p-0"
|
|
||||||
>
|
|
||||||
<span className="text-xs">ƒ</span>
|
|
||||||
</Button>
|
|
||||||
</DropdownMenuTrigger>
|
|
||||||
<DropdownMenuContent align="end">
|
|
||||||
{(
|
|
||||||
[
|
|
||||||
"sum",
|
|
||||||
"avg",
|
|
||||||
"median",
|
|
||||||
"max",
|
|
||||||
"min",
|
|
||||||
] as AggregateFunction[]
|
|
||||||
).map((func) => (
|
|
||||||
<DropdownMenuItem
|
|
||||||
key={func}
|
|
||||||
onClick={() =>
|
|
||||||
setAggregateConfig({
|
|
||||||
...aggregateConfig,
|
|
||||||
subscriptions_count: func,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
className={
|
|
||||||
aggregateConfig.subscriptions_count === func
|
|
||||||
? "bg-accent"
|
|
||||||
: ""
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{getAggregateFunctionLabel(func)}
|
|
||||||
</DropdownMenuItem>
|
|
||||||
))}
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
{formatNumber(
|
|
||||||
calculateAggregate(
|
|
||||||
filteredPurchases.map((p) => p.subscriptions_count),
|
|
||||||
aggregateConfig.subscriptions_count
|
|
||||||
) || 0
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="text-right font-semibold">
|
|
||||||
<div className="flex items-center justify-end gap-2">
|
|
||||||
<DropdownMenu>
|
|
||||||
<DropdownMenuTrigger asChild>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
className="h-6 w-6 p-0"
|
|
||||||
>
|
|
||||||
<span className="text-xs">ƒ</span>
|
|
||||||
</Button>
|
|
||||||
</DropdownMenuTrigger>
|
|
||||||
<DropdownMenuContent align="end">
|
|
||||||
{(
|
|
||||||
[
|
|
||||||
"sum",
|
|
||||||
"avg",
|
|
||||||
"median",
|
|
||||||
"max",
|
|
||||||
"min",
|
|
||||||
] as AggregateFunction[]
|
|
||||||
).map((func) => (
|
|
||||||
<DropdownMenuItem
|
|
||||||
key={func}
|
|
||||||
onClick={() =>
|
|
||||||
setAggregateConfig({
|
|
||||||
...aggregateConfig,
|
|
||||||
views_count: func,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
className={
|
|
||||||
aggregateConfig.views_count === func
|
|
||||||
? "bg-accent"
|
|
||||||
: ""
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{getAggregateFunctionLabel(func)}
|
|
||||||
</DropdownMenuItem>
|
|
||||||
))}
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
{formatNumber(
|
|
||||||
calculateAggregate(
|
|
||||||
filteredPurchases.map((p) => p.views_count),
|
|
||||||
aggregateConfig.views_count
|
|
||||||
) || 0
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="text-right font-semibold">
|
|
||||||
<div className="flex items-center justify-end gap-2">
|
|
||||||
<DropdownMenu>
|
|
||||||
<DropdownMenuTrigger asChild>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
className="h-6 w-6 p-0"
|
|
||||||
>
|
|
||||||
<span className="text-xs">ƒ</span>
|
|
||||||
</Button>
|
|
||||||
</DropdownMenuTrigger>
|
|
||||||
<DropdownMenuContent align="end">
|
|
||||||
{(
|
|
||||||
[
|
|
||||||
"sum",
|
|
||||||
"avg",
|
|
||||||
"median",
|
|
||||||
"max",
|
|
||||||
"min",
|
|
||||||
] as AggregateFunction[]
|
|
||||||
).map((func) => (
|
|
||||||
<DropdownMenuItem
|
|
||||||
key={func}
|
|
||||||
onClick={() =>
|
|
||||||
setAggregateConfig({
|
|
||||||
...aggregateConfig,
|
|
||||||
cpm: func,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
className={
|
|
||||||
aggregateConfig.cpm === func
|
|
||||||
? "bg-accent"
|
|
||||||
: ""
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{getAggregateFunctionLabel(func)}
|
|
||||||
</DropdownMenuItem>
|
|
||||||
))}
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
{formatCurrency(
|
|
||||||
calculateAggregate(
|
|
||||||
filteredPurchases.map((p) => calculateCPM(p)),
|
|
||||||
aggregateConfig.cpm
|
|
||||||
) || 0
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="text-right font-semibold">
|
|
||||||
<div className="flex items-center justify-end gap-2">
|
|
||||||
<DropdownMenu>
|
|
||||||
<DropdownMenuTrigger asChild>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
className="h-6 w-6 p-0"
|
|
||||||
>
|
|
||||||
<span className="text-xs">ƒ</span>
|
|
||||||
</Button>
|
|
||||||
</DropdownMenuTrigger>
|
|
||||||
<DropdownMenuContent align="end">
|
|
||||||
{(
|
|
||||||
[
|
|
||||||
"sum",
|
|
||||||
"avg",
|
|
||||||
"median",
|
|
||||||
"max",
|
|
||||||
"min",
|
|
||||||
] as AggregateFunction[]
|
|
||||||
).map((func) => (
|
|
||||||
<DropdownMenuItem
|
|
||||||
key={func}
|
|
||||||
onClick={() =>
|
|
||||||
setAggregateConfig({
|
|
||||||
...aggregateConfig,
|
|
||||||
cpl: func,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
className={
|
|
||||||
aggregateConfig.cpl === func
|
|
||||||
? "bg-accent"
|
|
||||||
: ""
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{getAggregateFunctionLabel(func)}
|
|
||||||
</DropdownMenuItem>
|
|
||||||
))}
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
{formatCurrency(
|
|
||||||
calculateAggregate(
|
|
||||||
filteredPurchases.map((p) => calculateCPL(p)),
|
|
||||||
aggregateConfig.cpl
|
|
||||||
) || 0
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell></TableCell>
|
|
||||||
</TableRow>
|
|
||||||
</TableFooter>
|
|
||||||
</Table>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
302
app/dashboard/[workspaceId]/analytics/channels/page.tsx
Normal file
302
app/dashboard/[workspaceId]/analytics/channels/page.tsx
Normal file
@@ -0,0 +1,302 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Channels Analytics Page
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useParams } from "next/navigation";
|
||||||
|
import {
|
||||||
|
Loader2,
|
||||||
|
Building2,
|
||||||
|
ArrowUpDown,
|
||||||
|
ArrowUp,
|
||||||
|
ArrowDown,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { DashboardHeader } from "@/components/layout/dashboard-header";
|
||||||
|
import { useWorkspace } from "@/components/providers/workspace-provider";
|
||||||
|
import { demoAwareAnalyticsApi } from "@/lib/demo";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "@/components/ui/card";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from "@/components/ui/table";
|
||||||
|
import type { ChannelAnalyticsItem } from "@/lib/types/api";
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Helpers
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
const formatCurrency = (value: number | null) => {
|
||||||
|
if (value === null) return "—";
|
||||||
|
return new Intl.NumberFormat("ru-RU", {
|
||||||
|
style: "currency",
|
||||||
|
currency: "RUB",
|
||||||
|
minimumFractionDigits: 0,
|
||||||
|
maximumFractionDigits: 0,
|
||||||
|
}).format(value);
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatNumber = (value: number | null) => {
|
||||||
|
if (value === null) return "—";
|
||||||
|
return new Intl.NumberFormat("ru-RU").format(value);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Types
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
type SortField =
|
||||||
|
| "placements"
|
||||||
|
| "cost"
|
||||||
|
| "subscriptions"
|
||||||
|
| "views"
|
||||||
|
| "cps"
|
||||||
|
| "cpm";
|
||||||
|
type SortOrder = "asc" | "desc" | null;
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Component
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export default function ChannelsAnalyticsPage() {
|
||||||
|
const params = useParams();
|
||||||
|
const workspaceId = params?.workspaceId as string;
|
||||||
|
const { getProjectFilterId } = useWorkspace();
|
||||||
|
const projectFilterId = getProjectFilterId();
|
||||||
|
|
||||||
|
const [channels, setChannels] = useState<ChannelAnalyticsItem[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [sortField, setSortField] = useState<SortField | null>("cost");
|
||||||
|
const [sortOrder, setSortOrder] = useState<SortOrder>("desc");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadData();
|
||||||
|
}, [workspaceId, projectFilterId]);
|
||||||
|
|
||||||
|
const loadData = async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
const response = await demoAwareAnalyticsApi.channels(workspaceId, {
|
||||||
|
project_id: projectFilterId,
|
||||||
|
size: 100,
|
||||||
|
});
|
||||||
|
setChannels(response.items);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to load channels analytics:", err);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Sort handler
|
||||||
|
const handleSort = (field: SortField) => {
|
||||||
|
if (sortField === field) {
|
||||||
|
if (sortOrder === "asc") setSortOrder("desc");
|
||||||
|
else if (sortOrder === "desc") {
|
||||||
|
setSortField(null);
|
||||||
|
setSortOrder(null);
|
||||||
|
} else setSortOrder("asc");
|
||||||
|
} else {
|
||||||
|
setSortField(field);
|
||||||
|
setSortOrder("desc");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getSortIcon = (field: SortField) => {
|
||||||
|
if (sortField !== field) return <ArrowUpDown className="h-4 w-4 ml-1" />;
|
||||||
|
if (sortOrder === "asc") return <ArrowUp className="h-4 w-4 ml-1" />;
|
||||||
|
if (sortOrder === "desc") return <ArrowDown className="h-4 w-4 ml-1" />;
|
||||||
|
return <ArrowUpDown className="h-4 w-4 ml-1" />;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Sort data
|
||||||
|
const sortedChannels = [...channels].sort((a, b) => {
|
||||||
|
if (!sortField || !sortOrder) return 0;
|
||||||
|
|
||||||
|
let aVal = 0;
|
||||||
|
let bVal = 0;
|
||||||
|
|
||||||
|
switch (sortField) {
|
||||||
|
case "placements":
|
||||||
|
aVal = a.placements_count;
|
||||||
|
bVal = b.placements_count;
|
||||||
|
break;
|
||||||
|
case "cost":
|
||||||
|
aVal = a.total_cost;
|
||||||
|
bVal = b.total_cost;
|
||||||
|
break;
|
||||||
|
case "subscriptions":
|
||||||
|
aVal = a.total_subscriptions;
|
||||||
|
bVal = b.total_subscriptions;
|
||||||
|
break;
|
||||||
|
case "views":
|
||||||
|
aVal = a.total_views;
|
||||||
|
bVal = b.total_views;
|
||||||
|
break;
|
||||||
|
case "cps":
|
||||||
|
aVal = a.avg_cps ?? 0;
|
||||||
|
bVal = b.avg_cps ?? 0;
|
||||||
|
break;
|
||||||
|
case "cpm":
|
||||||
|
aVal = a.avg_cpm ?? 0;
|
||||||
|
bVal = b.avg_cpm ?? 0;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
return sortOrder === "asc" ? aVal - bVal : bVal - aVal;
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<DashboardHeader
|
||||||
|
breadcrumbs={[
|
||||||
|
{ label: "Главная", href: `/dashboard/${workspaceId}` },
|
||||||
|
{ label: "Аналитика", href: `/dashboard/${workspaceId}/analytics` },
|
||||||
|
{ label: "Каналы" },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex flex-1 flex-col gap-4 p-4 pt-0 mt-4">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold tracking-tight">
|
||||||
|
Аналитика по каналам
|
||||||
|
</h1>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
Эффективность каналов размещения
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex items-center justify-center py-12">
|
||||||
|
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
) : sortedChannels.length === 0 ? (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="flex flex-col items-center justify-center py-12">
|
||||||
|
<Building2 className="h-12 w-12 text-muted-foreground mb-4" />
|
||||||
|
<h3 className="text-lg font-semibold mb-2">Нет данных</h3>
|
||||||
|
<p className="text-muted-foreground text-center">
|
||||||
|
Создайте размещения для отображения статистики по каналам
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
) : (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Каналы размещения</CardTitle>
|
||||||
|
<CardDescription>{sortedChannels.length} каналов</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>Канал</TableHead>
|
||||||
|
<TableHead>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="-ml-3 h-8"
|
||||||
|
onClick={() => handleSort("placements")}
|
||||||
|
>
|
||||||
|
Размещений
|
||||||
|
{getSortIcon("placements")}
|
||||||
|
</Button>
|
||||||
|
</TableHead>
|
||||||
|
<TableHead>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="-ml-3 h-8"
|
||||||
|
onClick={() => handleSort("cost")}
|
||||||
|
>
|
||||||
|
Расходы
|
||||||
|
{getSortIcon("cost")}
|
||||||
|
</Button>
|
||||||
|
</TableHead>
|
||||||
|
<TableHead>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="-ml-3 h-8"
|
||||||
|
onClick={() => handleSort("subscriptions")}
|
||||||
|
>
|
||||||
|
Подписчики
|
||||||
|
{getSortIcon("subscriptions")}
|
||||||
|
</Button>
|
||||||
|
</TableHead>
|
||||||
|
<TableHead>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="-ml-3 h-8"
|
||||||
|
onClick={() => handleSort("views")}
|
||||||
|
>
|
||||||
|
Просмотры
|
||||||
|
{getSortIcon("views")}
|
||||||
|
</Button>
|
||||||
|
</TableHead>
|
||||||
|
<TableHead>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="-ml-3 h-8"
|
||||||
|
onClick={() => handleSort("cps")}
|
||||||
|
>
|
||||||
|
Ср. CPS
|
||||||
|
{getSortIcon("cps")}
|
||||||
|
</Button>
|
||||||
|
</TableHead>
|
||||||
|
<TableHead>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="-ml-3 h-8"
|
||||||
|
onClick={() => handleSort("cpm")}
|
||||||
|
>
|
||||||
|
Ср. CPM
|
||||||
|
{getSortIcon("cpm")}
|
||||||
|
</Button>
|
||||||
|
</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{sortedChannels.map((channel) => (
|
||||||
|
<TableRow key={channel.channel_id}>
|
||||||
|
<TableCell>
|
||||||
|
<div className="font-medium">{channel.channel_title}</div>
|
||||||
|
{channel.channel_username && (
|
||||||
|
<div className="text-xs text-muted-foreground">
|
||||||
|
@{channel.channel_username}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>{channel.placements_count}</TableCell>
|
||||||
|
<TableCell>{formatCurrency(channel.total_cost)}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{formatNumber(channel.total_subscriptions)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>{formatNumber(channel.total_views)}</TableCell>
|
||||||
|
<TableCell>{formatCurrency(channel.avg_cps)}</TableCell>
|
||||||
|
<TableCell>{formatCurrency(channel.avg_cpm)}</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
305
app/dashboard/[workspaceId]/analytics/creatives/page.tsx
Normal file
305
app/dashboard/[workspaceId]/analytics/creatives/page.tsx
Normal file
@@ -0,0 +1,305 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Creatives Analytics Page
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useParams } from "next/navigation";
|
||||||
|
import Link from "next/link";
|
||||||
|
import {
|
||||||
|
Loader2,
|
||||||
|
Folder,
|
||||||
|
ArrowUpDown,
|
||||||
|
ArrowUp,
|
||||||
|
ArrowDown,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { DashboardHeader } from "@/components/layout/dashboard-header";
|
||||||
|
import { useWorkspace } from "@/components/providers/workspace-provider";
|
||||||
|
import { demoAwareAnalyticsApi } from "@/lib/demo";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "@/components/ui/card";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from "@/components/ui/table";
|
||||||
|
import type { CreativeAnalyticsItem } from "@/lib/types/api";
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Helpers
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
const formatCurrency = (value: number | null) => {
|
||||||
|
if (value === null) return "—";
|
||||||
|
return new Intl.NumberFormat("ru-RU", {
|
||||||
|
style: "currency",
|
||||||
|
currency: "RUB",
|
||||||
|
minimumFractionDigits: 0,
|
||||||
|
maximumFractionDigits: 0,
|
||||||
|
}).format(value);
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatNumber = (value: number | null) => {
|
||||||
|
if (value === null) return "—";
|
||||||
|
return new Intl.NumberFormat("ru-RU").format(value);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Types
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
type SortField =
|
||||||
|
| "placements"
|
||||||
|
| "cost"
|
||||||
|
| "subscriptions"
|
||||||
|
| "views"
|
||||||
|
| "cps"
|
||||||
|
| "cpm";
|
||||||
|
type SortOrder = "asc" | "desc" | null;
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Component
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export default function CreativesAnalyticsPage() {
|
||||||
|
const params = useParams();
|
||||||
|
const workspaceId = params?.workspaceId as string;
|
||||||
|
const { getProjectFilterId } = useWorkspace();
|
||||||
|
const projectFilterId = getProjectFilterId();
|
||||||
|
|
||||||
|
const [creatives, setCreatives] = useState<CreativeAnalyticsItem[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [sortField, setSortField] = useState<SortField | null>("cost");
|
||||||
|
const [sortOrder, setSortOrder] = useState<SortOrder>("desc");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadData();
|
||||||
|
}, [workspaceId, projectFilterId]);
|
||||||
|
|
||||||
|
const loadData = async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
const response = await demoAwareAnalyticsApi.creatives(workspaceId, {
|
||||||
|
project_id: projectFilterId,
|
||||||
|
size: 100,
|
||||||
|
});
|
||||||
|
setCreatives(response.items);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to load creatives analytics:", err);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Sort handler
|
||||||
|
const handleSort = (field: SortField) => {
|
||||||
|
if (sortField === field) {
|
||||||
|
if (sortOrder === "asc") setSortOrder("desc");
|
||||||
|
else if (sortOrder === "desc") {
|
||||||
|
setSortField(null);
|
||||||
|
setSortOrder(null);
|
||||||
|
} else setSortOrder("asc");
|
||||||
|
} else {
|
||||||
|
setSortField(field);
|
||||||
|
setSortOrder("desc");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getSortIcon = (field: SortField) => {
|
||||||
|
if (sortField !== field) return <ArrowUpDown className="h-4 w-4 ml-1" />;
|
||||||
|
if (sortOrder === "asc") return <ArrowUp className="h-4 w-4 ml-1" />;
|
||||||
|
if (sortOrder === "desc") return <ArrowDown className="h-4 w-4 ml-1" />;
|
||||||
|
return <ArrowUpDown className="h-4 w-4 ml-1" />;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Sort data
|
||||||
|
const sortedCreatives = [...creatives].sort((a, b) => {
|
||||||
|
if (!sortField || !sortOrder) return 0;
|
||||||
|
|
||||||
|
let aVal = 0;
|
||||||
|
let bVal = 0;
|
||||||
|
|
||||||
|
switch (sortField) {
|
||||||
|
case "placements":
|
||||||
|
aVal = a.placements_count;
|
||||||
|
bVal = b.placements_count;
|
||||||
|
break;
|
||||||
|
case "cost":
|
||||||
|
aVal = a.total_cost;
|
||||||
|
bVal = b.total_cost;
|
||||||
|
break;
|
||||||
|
case "subscriptions":
|
||||||
|
aVal = a.total_subscriptions;
|
||||||
|
bVal = b.total_subscriptions;
|
||||||
|
break;
|
||||||
|
case "views":
|
||||||
|
aVal = a.total_views;
|
||||||
|
bVal = b.total_views;
|
||||||
|
break;
|
||||||
|
case "cps":
|
||||||
|
aVal = a.avg_cpf ?? 0;
|
||||||
|
bVal = b.avg_cpf ?? 0;
|
||||||
|
break;
|
||||||
|
case "cpm":
|
||||||
|
aVal = a.avg_cpm ?? 0;
|
||||||
|
bVal = b.avg_cpm ?? 0;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
return sortOrder === "asc" ? aVal - bVal : bVal - aVal;
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<DashboardHeader
|
||||||
|
breadcrumbs={[
|
||||||
|
{ label: "Главная", href: `/dashboard/${workspaceId}` },
|
||||||
|
{ label: "Аналитика", href: `/dashboard/${workspaceId}/analytics` },
|
||||||
|
{ label: "Креативы" },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex flex-1 flex-col gap-4 p-4 pt-0 mt-4">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold tracking-tight">
|
||||||
|
Аналитика по креативам
|
||||||
|
</h1>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
Сравнение эффективности рекламных материалов
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex items-center justify-center py-12">
|
||||||
|
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
) : sortedCreatives.length === 0 ? (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="flex flex-col items-center justify-center py-12">
|
||||||
|
<Folder className="h-12 w-12 text-muted-foreground mb-4" />
|
||||||
|
<h3 className="text-lg font-semibold mb-2">Нет данных</h3>
|
||||||
|
<p className="text-muted-foreground text-center">
|
||||||
|
Создайте размещения для отображения статистики по креативам
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
) : (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Креативы</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
{sortedCreatives.length} креативов с размещениями
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>Креатив</TableHead>
|
||||||
|
<TableHead>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="-ml-3 h-8"
|
||||||
|
onClick={() => handleSort("placements")}
|
||||||
|
>
|
||||||
|
Размещений
|
||||||
|
{getSortIcon("placements")}
|
||||||
|
</Button>
|
||||||
|
</TableHead>
|
||||||
|
<TableHead>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="-ml-3 h-8"
|
||||||
|
onClick={() => handleSort("cost")}
|
||||||
|
>
|
||||||
|
Расходы
|
||||||
|
{getSortIcon("cost")}
|
||||||
|
</Button>
|
||||||
|
</TableHead>
|
||||||
|
<TableHead>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="-ml-3 h-8"
|
||||||
|
onClick={() => handleSort("subscriptions")}
|
||||||
|
>
|
||||||
|
Подписчики
|
||||||
|
{getSortIcon("subscriptions")}
|
||||||
|
</Button>
|
||||||
|
</TableHead>
|
||||||
|
<TableHead>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="-ml-3 h-8"
|
||||||
|
onClick={() => handleSort("views")}
|
||||||
|
>
|
||||||
|
Просмотры
|
||||||
|
{getSortIcon("views")}
|
||||||
|
</Button>
|
||||||
|
</TableHead>
|
||||||
|
<TableHead>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="-ml-3 h-8"
|
||||||
|
onClick={() => handleSort("cps")}
|
||||||
|
>
|
||||||
|
Ср. CPS
|
||||||
|
{getSortIcon("cps")}
|
||||||
|
</Button>
|
||||||
|
</TableHead>
|
||||||
|
<TableHead>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="-ml-3 h-8"
|
||||||
|
onClick={() => handleSort("cpm")}
|
||||||
|
>
|
||||||
|
Ср. CPM
|
||||||
|
{getSortIcon("cpm")}
|
||||||
|
</Button>
|
||||||
|
</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{sortedCreatives.map((creative) => (
|
||||||
|
<TableRow key={creative.id}>
|
||||||
|
<TableCell>
|
||||||
|
<Link
|
||||||
|
href={`/dashboard/${workspaceId}/creatives/${creative.id}`}
|
||||||
|
className="font-medium hover:underline"
|
||||||
|
>
|
||||||
|
{creative.name}
|
||||||
|
</Link>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>{creative.placements_count}</TableCell>
|
||||||
|
<TableCell>{formatCurrency(creative.total_cost)}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{formatNumber(creative.total_subscriptions)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>{formatNumber(creative.total_views)}</TableCell>
|
||||||
|
<TableCell>{formatCurrency(creative.avg_cpf)}</TableCell>
|
||||||
|
<TableCell>{formatCurrency(creative.avg_cpm)}</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
291
app/dashboard/[workspaceId]/analytics/page.tsx
Normal file
291
app/dashboard/[workspaceId]/analytics/page.tsx
Normal file
@@ -0,0 +1,291 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Analytics Overview Page
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useParams } from "next/navigation";
|
||||||
|
import Link from "next/link";
|
||||||
|
import {
|
||||||
|
Loader2,
|
||||||
|
Users,
|
||||||
|
Eye,
|
||||||
|
DollarSign,
|
||||||
|
ArrowRight,
|
||||||
|
BarChart3,
|
||||||
|
Info,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { DashboardHeader } from "@/components/layout/dashboard-header";
|
||||||
|
import { useWorkspace } from "@/components/providers/workspace-provider";
|
||||||
|
import { demoAwareAnalyticsApi } from "@/lib/demo";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "@/components/ui/card";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||||
|
import type { SpendingAnalytics } from "@/lib/types/api";
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Helpers
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
const formatCurrency = (value: number | null) => {
|
||||||
|
if (value === null) return "—";
|
||||||
|
return new Intl.NumberFormat("ru-RU", {
|
||||||
|
style: "currency",
|
||||||
|
currency: "RUB",
|
||||||
|
minimumFractionDigits: 0,
|
||||||
|
maximumFractionDigits: 0,
|
||||||
|
}).format(value);
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatNumber = (value: number | null) => {
|
||||||
|
if (value === null) return "—";
|
||||||
|
return new Intl.NumberFormat("ru-RU").format(value);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Component
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export default function AnalyticsPage() {
|
||||||
|
const params = useParams();
|
||||||
|
const workspaceId = params?.workspaceId as string;
|
||||||
|
const {
|
||||||
|
selectedProjects,
|
||||||
|
getProjectFilterId,
|
||||||
|
allProjectsSelected,
|
||||||
|
projects
|
||||||
|
} = useWorkspace();
|
||||||
|
|
||||||
|
const [spending, setSpending] = useState<SpendingAnalytics | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
const projectFilterId = getProjectFilterId();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadAnalytics();
|
||||||
|
}, [workspaceId, projectFilterId]);
|
||||||
|
|
||||||
|
const loadAnalytics = async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
const data = await demoAwareAnalyticsApi.spending(workspaceId, {
|
||||||
|
project_id: projectFilterId,
|
||||||
|
grouping: "month",
|
||||||
|
});
|
||||||
|
setSpending(data);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to load analytics:", err);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Calculate averages
|
||||||
|
const avgCps =
|
||||||
|
spending && spending.total_subscriptions > 0
|
||||||
|
? spending.total_cost / spending.total_subscriptions
|
||||||
|
: null;
|
||||||
|
const avgCpm =
|
||||||
|
spending && spending.total_views > 0
|
||||||
|
? (spending.total_cost / spending.total_views) * 1000
|
||||||
|
: null;
|
||||||
|
|
||||||
|
// Selection info text
|
||||||
|
const selectionText = allProjectsSelected
|
||||||
|
? "Все проекты"
|
||||||
|
: selectedProjects.length === 1
|
||||||
|
? selectedProjects[0].title
|
||||||
|
: `${selectedProjects.length} проекта`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<DashboardHeader
|
||||||
|
breadcrumbs={[
|
||||||
|
{ label: "Главная", href: `/dashboard/${workspaceId}` },
|
||||||
|
{ label: "Аналитика" },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex flex-1 flex-col gap-4 p-4 pt-0 mt-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold tracking-tight">Аналитика</h1>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
Обзор эффективности рекламных размещений
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Badge variant="outline" className="text-sm">
|
||||||
|
{selectionText}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{selectedProjects.length > 1 && !allProjectsSelected && (
|
||||||
|
<Alert>
|
||||||
|
<Info className="h-4 w-4" />
|
||||||
|
<AlertDescription>
|
||||||
|
Выбрано {selectedProjects.length} проекта: {selectedProjects.map(p => p.title).join(", ")}.
|
||||||
|
Показана суммарная аналитика по всем выбранным проектам.
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex items-center justify-center py-12">
|
||||||
|
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{/* Summary Cards */}
|
||||||
|
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium">
|
||||||
|
Всего потрачено
|
||||||
|
</CardTitle>
|
||||||
|
<DollarSign className="h-4 w-4 text-muted-foreground" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-2xl font-bold">
|
||||||
|
{formatCurrency(spending?.total_cost ?? 0)}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium">
|
||||||
|
Подписчиков
|
||||||
|
</CardTitle>
|
||||||
|
<Users className="h-4 w-4 text-muted-foreground" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-2xl font-bold">
|
||||||
|
{formatNumber(spending?.total_subscriptions ?? 0)}
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Средний CPS: {avgCps ? formatCurrency(avgCps) : "—"}
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium">Просмотров</CardTitle>
|
||||||
|
<Eye className="h-4 w-4 text-muted-foreground" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-2xl font-bold">
|
||||||
|
{formatNumber(spending?.total_views ?? 0)}
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Средний CPM: {avgCpm ? formatCurrency(avgCpm) : "—"}
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium">
|
||||||
|
Периодов данных
|
||||||
|
</CardTitle>
|
||||||
|
<BarChart3 className="h-4 w-4 text-muted-foreground" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-2xl font-bold">
|
||||||
|
{spending?.chart_data?.length ?? 0}
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground">месяцев</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Quick Links */}
|
||||||
|
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||||
|
<Card className="hover:bg-muted/50 transition-colors">
|
||||||
|
<Link href={`/dashboard/${workspaceId}/analytics/spending`}>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center justify-between">
|
||||||
|
Динамика расходов
|
||||||
|
<ArrowRight className="h-4 w-4" />
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
График расходов по времени с группировкой
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
</Link>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className="hover:bg-muted/50 transition-colors">
|
||||||
|
<Link href={`/dashboard/${workspaceId}/analytics/channels`}>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center justify-between">
|
||||||
|
Аналитика по каналам
|
||||||
|
<ArrowRight className="h-4 w-4" />
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Эффективность каналов размещения
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
</Link>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className="hover:bg-muted/50 transition-colors">
|
||||||
|
<Link href={`/dashboard/${workspaceId}/analytics/creatives`}>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center justify-between">
|
||||||
|
Аналитика по креативам
|
||||||
|
<ArrowRight className="h-4 w-4" />
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Сравнение эффективности креативов
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
</Link>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Recent Activity */}
|
||||||
|
{spending && spending.chart_data && spending.chart_data.length > 0 && (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Последние периоды</CardTitle>
|
||||||
|
<CardDescription>Динамика по периодам</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="space-y-4">
|
||||||
|
{spending.chart_data.slice(0, 6).map((item, index) => (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
className="flex items-center justify-between border-b pb-4 last:border-0 last:pb-0"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<div className="font-medium">{item.period}</div>
|
||||||
|
<div className="text-sm text-muted-foreground">
|
||||||
|
{formatNumber(item.subscriptions)} подписчиков •{" "}
|
||||||
|
{formatNumber(item.views)} просмотров
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-right">
|
||||||
|
<div className="font-medium">
|
||||||
|
{formatCurrency(item.cost)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
501
app/dashboard/[workspaceId]/analytics/spending/page.tsx
Normal file
501
app/dashboard/[workspaceId]/analytics/spending/page.tsx
Normal file
@@ -0,0 +1,501 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Spending Analytics Page - Enhanced with Charts
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
import { useEffect, useState, useMemo } from "react";
|
||||||
|
import { useParams } from "next/navigation";
|
||||||
|
import { Loader2, TrendingUp, BarChart3, LineChart as LineChartIcon, Info } from "lucide-react";
|
||||||
|
import {
|
||||||
|
LineChart,
|
||||||
|
Line,
|
||||||
|
BarChart,
|
||||||
|
Bar,
|
||||||
|
XAxis,
|
||||||
|
YAxis,
|
||||||
|
CartesianGrid,
|
||||||
|
LabelList,
|
||||||
|
} from "recharts";
|
||||||
|
import { DashboardHeader } from "@/components/layout/dashboard-header";
|
||||||
|
import { useWorkspace } from "@/components/providers/workspace-provider";
|
||||||
|
import { demoAwareAnalyticsApi } from "@/lib/demo";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "@/components/ui/card";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from "@/components/ui/table";
|
||||||
|
import {
|
||||||
|
ChartContainer,
|
||||||
|
ChartTooltip,
|
||||||
|
ChartTooltipContent,
|
||||||
|
ChartLegend,
|
||||||
|
ChartLegendContent,
|
||||||
|
type ChartConfig,
|
||||||
|
} from "@/components/ui/chart";
|
||||||
|
import {
|
||||||
|
Tooltip,
|
||||||
|
TooltipContent,
|
||||||
|
TooltipProvider,
|
||||||
|
TooltipTrigger,
|
||||||
|
} from "@/components/ui/tooltip";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import type { SpendingAnalytics, DateGrouping } from "@/lib/types/api";
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Helpers
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
const formatCurrency = (value: number | null) => {
|
||||||
|
if (value === null) return "—";
|
||||||
|
return new Intl.NumberFormat("ru-RU", {
|
||||||
|
style: "currency",
|
||||||
|
currency: "RUB",
|
||||||
|
minimumFractionDigits: 0,
|
||||||
|
maximumFractionDigits: 0,
|
||||||
|
}).format(value);
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatNumber = (value: number | null) => {
|
||||||
|
if (value === null) return "—";
|
||||||
|
return new Intl.NumberFormat("ru-RU").format(value);
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatShortCurrency = (value: number) => {
|
||||||
|
if (value >= 1000000) return `${(value / 1000000).toFixed(1)}M`;
|
||||||
|
if (value >= 1000) return `${(value / 1000).toFixed(0)}K`;
|
||||||
|
return value.toString();
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatDateLabel = (date: string, grouping: DateGrouping) => {
|
||||||
|
const d = new Date(date);
|
||||||
|
const g = grouping.toLowerCase();
|
||||||
|
if (g === "day") {
|
||||||
|
return d.toLocaleDateString("ru-RU", { day: "numeric", month: "short" });
|
||||||
|
} else if (g === "week") {
|
||||||
|
return `Нед. ${d.toLocaleDateString("ru-RU", { day: "numeric", month: "short" })}`;
|
||||||
|
} else {
|
||||||
|
return d.toLocaleDateString("ru-RU", { month: "short", year: "2-digit" });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Chart Types
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
type ChartType = "line" | "bar";
|
||||||
|
type MetricKey = "cost" | "subscriptions" | "views";
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Chart Configurations
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
const chartConfig: ChartConfig = {
|
||||||
|
cost: {
|
||||||
|
label: "Расходы",
|
||||||
|
color: "hsl(var(--chart-1))",
|
||||||
|
},
|
||||||
|
subscriptions: {
|
||||||
|
label: "Подписчики",
|
||||||
|
color: "hsl(var(--chart-2))",
|
||||||
|
},
|
||||||
|
views: {
|
||||||
|
label: "Просмотры",
|
||||||
|
color: "hsl(var(--chart-3))",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Component
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export default function SpendingAnalyticsPage() {
|
||||||
|
const params = useParams();
|
||||||
|
const workspaceId = params?.workspaceId as string;
|
||||||
|
const { selectedProjects, getProjectFilterId, allProjectsSelected } = useWorkspace();
|
||||||
|
const projectFilterId = getProjectFilterId();
|
||||||
|
|
||||||
|
const [spending, setSpending] = useState<SpendingAnalytics | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [grouping, setGrouping] = useState<DateGrouping>("month");
|
||||||
|
|
||||||
|
// Chart settings
|
||||||
|
const [chartType, setChartType] = useState<ChartType>("line");
|
||||||
|
const [showLabels, setShowLabels] = useState(false);
|
||||||
|
const [selectedMetrics, setSelectedMetrics] = useState<MetricKey[]>(["cost", "subscriptions"]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadData();
|
||||||
|
}, [workspaceId, projectFilterId, grouping]);
|
||||||
|
|
||||||
|
const loadData = async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
const data = await demoAwareAnalyticsApi.spending(workspaceId, {
|
||||||
|
project_id: projectFilterId,
|
||||||
|
grouping,
|
||||||
|
});
|
||||||
|
setSpending(data);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to load spending:", err);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Format chart data
|
||||||
|
const chartData = useMemo(() => {
|
||||||
|
if (!spending?.chart_data) return [];
|
||||||
|
return spending.chart_data.map((item) => ({
|
||||||
|
period: item.period,
|
||||||
|
dateLabel: formatDateLabel(item.period, grouping),
|
||||||
|
cost: item.cost,
|
||||||
|
subscriptions: item.subscriptions,
|
||||||
|
views: item.views,
|
||||||
|
}));
|
||||||
|
}, [spending, grouping]);
|
||||||
|
|
||||||
|
// Toggle metric
|
||||||
|
const handleToggleMetric = (metric: MetricKey) => {
|
||||||
|
if (selectedMetrics.includes(metric)) {
|
||||||
|
if (selectedMetrics.length > 1) {
|
||||||
|
setSelectedMetrics(selectedMetrics.filter((m) => m !== metric));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setSelectedMetrics([...selectedMetrics, metric]);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Render chart
|
||||||
|
const renderChart = () => {
|
||||||
|
if (chartData.length === 0) return null;
|
||||||
|
|
||||||
|
const ChartComponent = chartType === "line" ? LineChart : BarChart;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ChartContainer config={chartConfig} className="aspect-auto h-[300px] w-full">
|
||||||
|
<ChartComponent
|
||||||
|
data={chartData}
|
||||||
|
margin={{ left: 12, right: 12, top: showLabels ? 20 : 12, bottom: 12 }}
|
||||||
|
>
|
||||||
|
<CartesianGrid vertical={false} strokeDasharray="3 3" />
|
||||||
|
<XAxis
|
||||||
|
dataKey="dateLabel"
|
||||||
|
tickLine={false}
|
||||||
|
axisLine={false}
|
||||||
|
tickMargin={8}
|
||||||
|
minTickGap={32}
|
||||||
|
/>
|
||||||
|
<YAxis
|
||||||
|
tickLine={false}
|
||||||
|
axisLine={false}
|
||||||
|
tickMargin={8}
|
||||||
|
tickFormatter={formatShortCurrency}
|
||||||
|
/>
|
||||||
|
<ChartTooltip
|
||||||
|
content={
|
||||||
|
<ChartTooltipContent
|
||||||
|
labelFormatter={(_, payload) => {
|
||||||
|
if (payload && payload[0]) {
|
||||||
|
return payload[0].payload.period;
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<ChartLegend content={<ChartLegendContent />} />
|
||||||
|
|
||||||
|
{selectedMetrics.map((metric) =>
|
||||||
|
chartType === "line" ? (
|
||||||
|
<Line
|
||||||
|
key={metric}
|
||||||
|
dataKey={metric}
|
||||||
|
type="monotone"
|
||||||
|
stroke={`var(--color-${metric})`}
|
||||||
|
strokeWidth={2}
|
||||||
|
dot={{ r: 4 }}
|
||||||
|
activeDot={{ r: 6 }}
|
||||||
|
>
|
||||||
|
{showLabels && (
|
||||||
|
<LabelList
|
||||||
|
dataKey={metric}
|
||||||
|
position="top"
|
||||||
|
formatter={(value: number) =>
|
||||||
|
metric === "cost" ? formatShortCurrency(value) : formatNumber(value)
|
||||||
|
}
|
||||||
|
className="fill-foreground text-[10px]"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Line>
|
||||||
|
) : (
|
||||||
|
<Bar
|
||||||
|
key={metric}
|
||||||
|
dataKey={metric}
|
||||||
|
fill={`var(--color-${metric})`}
|
||||||
|
radius={[4, 4, 0, 0]}
|
||||||
|
>
|
||||||
|
{showLabels && (
|
||||||
|
<LabelList
|
||||||
|
dataKey={metric}
|
||||||
|
position="top"
|
||||||
|
formatter={(value: number) =>
|
||||||
|
metric === "cost" ? formatShortCurrency(value) : formatNumber(value)
|
||||||
|
}
|
||||||
|
className="fill-foreground text-[10px]"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Bar>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</ChartComponent>
|
||||||
|
</ChartContainer>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TooltipProvider>
|
||||||
|
<DashboardHeader
|
||||||
|
breadcrumbs={[
|
||||||
|
{ label: "Главная", href: `/dashboard/${workspaceId}` },
|
||||||
|
{ label: "Аналитика", href: `/dashboard/${workspaceId}/analytics` },
|
||||||
|
{ label: "Расходы" },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex flex-1 flex-col gap-4 p-4 pt-0 mt-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold tracking-tight">Динамика расходов</h1>
|
||||||
|
<p className="text-muted-foreground">Анализ расходов по периодам</p>
|
||||||
|
</div>
|
||||||
|
<Select value={grouping} onValueChange={(v) => setGrouping(v as DateGrouping)}>
|
||||||
|
<SelectTrigger className="w-[180px]">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="day">По дням</SelectItem>
|
||||||
|
<SelectItem value="week">По неделям</SelectItem>
|
||||||
|
<SelectItem value="month">По месяцам</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex items-center justify-center py-12">
|
||||||
|
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
) : spending ? (
|
||||||
|
<>
|
||||||
|
{/* Summary Cards */}
|
||||||
|
<div className="grid gap-4 md:grid-cols-3">
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium">Всего потрачено</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-2xl font-bold">{formatCurrency(spending.total_cost)}</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium">Подписчиков</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-2xl font-bold">
|
||||||
|
{formatNumber(spending.total_subscriptions)}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium">Просмотров</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-2xl font-bold">{formatNumber(spending.total_views)}</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Chart with Settings */}
|
||||||
|
{chartData.length > 0 && (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<CardTitle>График динамики</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Визуализация данных по выбранным метрикам
|
||||||
|
</CardDescription>
|
||||||
|
</div>
|
||||||
|
{/* Chart Settings */}
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
{/* Chart Type Toggle */}
|
||||||
|
<div className="flex items-center gap-1 border rounded-lg p-1">
|
||||||
|
<Button
|
||||||
|
variant={chartType === "line" ? "secondary" : "ghost"}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setChartType("line")}
|
||||||
|
aria-label="Линейный график"
|
||||||
|
>
|
||||||
|
<LineChartIcon className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant={chartType === "bar" ? "secondary" : "ghost"}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setChartType("bar")}
|
||||||
|
aria-label="Столбчатая диаграмма"
|
||||||
|
>
|
||||||
|
<BarChart3 className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Labels Toggle */}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Checkbox
|
||||||
|
id="show-labels"
|
||||||
|
checked={showLabels}
|
||||||
|
onCheckedChange={(checked) => setShowLabels(checked === true)}
|
||||||
|
/>
|
||||||
|
<Label htmlFor="show-labels" className="text-sm cursor-pointer">
|
||||||
|
Подписи
|
||||||
|
</Label>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Info className="h-3.5 w-3.5 text-muted-foreground cursor-help" />
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
Показывать значения на точках графика
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Metrics Selection */}
|
||||||
|
<div className="flex items-center gap-4 mt-3 pt-3 border-t">
|
||||||
|
<span className="text-sm text-muted-foreground">Метрики:</span>
|
||||||
|
{(["cost", "subscriptions", "views"] as MetricKey[]).map((metric) => (
|
||||||
|
<div key={metric} className="flex items-center gap-2">
|
||||||
|
<Checkbox
|
||||||
|
id={`metric-${metric}`}
|
||||||
|
checked={selectedMetrics.includes(metric)}
|
||||||
|
onCheckedChange={() => handleToggleMetric(metric)}
|
||||||
|
/>
|
||||||
|
<Label
|
||||||
|
htmlFor={`metric-${metric}`}
|
||||||
|
className="text-sm cursor-pointer flex items-center gap-1.5"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="h-2.5 w-2.5 rounded-full"
|
||||||
|
style={{ backgroundColor: `var(--color-${metric})` }}
|
||||||
|
/>
|
||||||
|
{chartConfig[metric].label}
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="px-2 sm:p-6">{renderChart()}</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Data Table */}
|
||||||
|
{spending.chart_data && spending.chart_data.length > 0 ? (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Данные по периодам</CardTitle>
|
||||||
|
<CardDescription>{spending.chart_data.length} периодов</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>Период</TableHead>
|
||||||
|
<TableHead className="text-right">Расходы</TableHead>
|
||||||
|
<TableHead className="text-right">Подписчики</TableHead>
|
||||||
|
<TableHead className="text-right">Просмотры</TableHead>
|
||||||
|
<TableHead className="text-right">
|
||||||
|
<div className="flex items-center justify-end gap-1">
|
||||||
|
CPS
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Info className="h-3.5 w-3.5 text-muted-foreground cursor-help" />
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>Cost Per Subscription</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
</TableHead>
|
||||||
|
<TableHead className="text-right">
|
||||||
|
<div className="flex items-center justify-end gap-1">
|
||||||
|
CPM
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Info className="h-3.5 w-3.5 text-muted-foreground cursor-help" />
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>Cost Per Mille (1000 views)</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{spending.chart_data.map((item, index) => {
|
||||||
|
const cps = item.subscriptions > 0 ? item.cost / item.subscriptions : null;
|
||||||
|
const cpm = item.views > 0 ? (item.cost / item.views) * 1000 : null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TableRow key={index}>
|
||||||
|
<TableCell className="font-medium">{item.period}</TableCell>
|
||||||
|
<TableCell className="text-right">{formatCurrency(item.cost)}</TableCell>
|
||||||
|
<TableCell className="text-right">
|
||||||
|
{formatNumber(item.subscriptions)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right">{formatNumber(item.views)}</TableCell>
|
||||||
|
<TableCell className="text-right">
|
||||||
|
{cps ? formatCurrency(cps) : "—"}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right">
|
||||||
|
{cpm ? formatCurrency(cpm) : "—"}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</Card>
|
||||||
|
) : (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="flex flex-col items-center justify-center py-12">
|
||||||
|
<TrendingUp className="h-12 w-12 text-muted-foreground mb-4" />
|
||||||
|
<h3 className="text-lg font-semibold mb-2">Нет данных</h3>
|
||||||
|
<p className="text-muted-foreground text-center">
|
||||||
|
Создайте размещения для отображения статистики
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</TooltipProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
384
app/dashboard/[workspaceId]/channels/page.tsx
Normal file
384
app/dashboard/[workspaceId]/channels/page.tsx
Normal file
@@ -0,0 +1,384 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Channels Catalog Page - Enhanced with Sorting
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
import { useEffect, useState, useMemo } from "react";
|
||||||
|
import { useParams } from "next/navigation";
|
||||||
|
import {
|
||||||
|
Loader2,
|
||||||
|
Search,
|
||||||
|
Building2,
|
||||||
|
Users,
|
||||||
|
ExternalLink,
|
||||||
|
Grid,
|
||||||
|
List,
|
||||||
|
ArrowUpDown,
|
||||||
|
ArrowUp,
|
||||||
|
ArrowDown,
|
||||||
|
Info,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { DashboardHeader } from "@/components/layout/dashboard-header";
|
||||||
|
import { channelsApi } from "@/lib/api";
|
||||||
|
import { isDemoWorkspace, demoChannels } from "@/lib/demo";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "@/components/ui/card";
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from "@/components/ui/table";
|
||||||
|
import {
|
||||||
|
Tooltip,
|
||||||
|
TooltipContent,
|
||||||
|
TooltipProvider,
|
||||||
|
TooltipTrigger,
|
||||||
|
} from "@/components/ui/tooltip";
|
||||||
|
import type { Channel } from "@/lib/types/api";
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Helpers
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
const formatNumber = (value: number | null | undefined): string => {
|
||||||
|
if (value === null || value === undefined) return "—";
|
||||||
|
return new Intl.NumberFormat("ru-RU").format(value);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Sort Types
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
type SortField = "title" | "username" | "subscribers";
|
||||||
|
type SortOrder = "asc" | "desc" | null;
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Component
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export default function ChannelsCatalogPage() {
|
||||||
|
const params = useParams();
|
||||||
|
const workspaceId = params?.workspaceId as string;
|
||||||
|
|
||||||
|
const [channels, setChannels] = useState<Channel[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [search, setSearch] = useState("");
|
||||||
|
const [viewMode, setViewMode] = useState<"table" | "grid">("table");
|
||||||
|
const [sortField, setSortField] = useState<SortField | null>(null);
|
||||||
|
const [sortOrder, setSortOrder] = useState<SortOrder>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const loadChannels = async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
// Demo mode - use mock data
|
||||||
|
const isDemo = isDemoWorkspace(workspaceId);
|
||||||
|
const response = isDemo
|
||||||
|
? { items: demoChannels.filter(c => !search || c.username?.includes(search) || c.title.includes(search)), total: demoChannels.length, page: 1, size: 100, pages: 1 }
|
||||||
|
: await channelsApi.list({
|
||||||
|
username: search || undefined,
|
||||||
|
size: 100,
|
||||||
|
});
|
||||||
|
setChannels(response.items);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to load channels:", err);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const debounce = setTimeout(loadChannels, 300);
|
||||||
|
return () => clearTimeout(debounce);
|
||||||
|
}, [search]);
|
||||||
|
|
||||||
|
// Handle sort
|
||||||
|
const handleSort = (field: SortField) => {
|
||||||
|
if (sortField === field) {
|
||||||
|
if (sortOrder === "asc") {
|
||||||
|
setSortOrder("desc");
|
||||||
|
} else if (sortOrder === "desc") {
|
||||||
|
setSortField(null);
|
||||||
|
setSortOrder(null);
|
||||||
|
} else {
|
||||||
|
setSortOrder("asc");
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setSortField(field);
|
||||||
|
setSortOrder("asc");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getSortIcon = (field: SortField) => {
|
||||||
|
if (sortField !== field) return <ArrowUpDown className="h-4 w-4 ml-1" />;
|
||||||
|
if (sortOrder === "asc") return <ArrowUp className="h-4 w-4 ml-1" />;
|
||||||
|
if (sortOrder === "desc") return <ArrowDown className="h-4 w-4 ml-1" />;
|
||||||
|
return <ArrowUpDown className="h-4 w-4 ml-1" />;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Filter and sort channels
|
||||||
|
const filteredChannels = useMemo(() => {
|
||||||
|
let result = [...channels];
|
||||||
|
|
||||||
|
// Sort
|
||||||
|
if (sortField && sortOrder) {
|
||||||
|
result.sort((a, b) => {
|
||||||
|
let aVal: string | number = "";
|
||||||
|
let bVal: string | number = "";
|
||||||
|
|
||||||
|
switch (sortField) {
|
||||||
|
case "title":
|
||||||
|
aVal = a.title.toLowerCase();
|
||||||
|
bVal = b.title.toLowerCase();
|
||||||
|
return sortOrder === "asc"
|
||||||
|
? aVal.localeCompare(bVal)
|
||||||
|
: bVal.localeCompare(aVal);
|
||||||
|
case "username":
|
||||||
|
aVal = (a.username || "").toLowerCase();
|
||||||
|
bVal = (b.username || "").toLowerCase();
|
||||||
|
return sortOrder === "asc"
|
||||||
|
? aVal.localeCompare(bVal)
|
||||||
|
: bVal.localeCompare(aVal);
|
||||||
|
case "subscribers":
|
||||||
|
aVal = a.subscribers_count || 0;
|
||||||
|
bVal = b.subscribers_count || 0;
|
||||||
|
return sortOrder === "asc" ? aVal - bVal : bVal - aVal;
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}, [channels, sortField, sortOrder]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TooltipProvider>
|
||||||
|
<DashboardHeader
|
||||||
|
breadcrumbs={[
|
||||||
|
{ label: "Главная", href: `/dashboard/${workspaceId}` },
|
||||||
|
{ label: "Каталог каналов" },
|
||||||
|
]}
|
||||||
|
showProjectSelector={false}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex flex-1 flex-col gap-4 p-4 pt-0 mt-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold tracking-tight">Каталог каналов</h1>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
{filteredChannels.length} каналов для размещения рекламы
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="relative flex-1 max-w-sm">
|
||||||
|
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
placeholder="Поиск по username..."
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
className="pl-10"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1 border rounded-lg p-1">
|
||||||
|
<Button
|
||||||
|
variant={viewMode === "table" ? "secondary" : "ghost"}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setViewMode("table")}
|
||||||
|
aria-label="Табличный вид"
|
||||||
|
>
|
||||||
|
<List className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant={viewMode === "grid" ? "secondary" : "ghost"}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setViewMode("grid")}
|
||||||
|
aria-label="Плиточный вид"
|
||||||
|
>
|
||||||
|
<Grid className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex items-center justify-center py-12">
|
||||||
|
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
) : channels.length === 0 ? (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="flex flex-col items-center justify-center py-12">
|
||||||
|
<Building2 className="h-12 w-12 text-muted-foreground mb-4" />
|
||||||
|
<h3 className="text-lg font-semibold mb-2">Каналы не найдены</h3>
|
||||||
|
<p className="text-muted-foreground text-center">
|
||||||
|
{search
|
||||||
|
? "Попробуйте изменить поисковый запрос"
|
||||||
|
: "В каталоге пока нет каналов"}
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
) : viewMode === "table" ? (
|
||||||
|
<Card>
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="-ml-3 h-8"
|
||||||
|
onClick={() => handleSort("title")}
|
||||||
|
>
|
||||||
|
Канал
|
||||||
|
{getSortIcon("title")}
|
||||||
|
</Button>
|
||||||
|
</TableHead>
|
||||||
|
<TableHead>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="-ml-3 h-8"
|
||||||
|
onClick={() => handleSort("username")}
|
||||||
|
>
|
||||||
|
Username
|
||||||
|
{getSortIcon("username")}
|
||||||
|
</Button>
|
||||||
|
</TableHead>
|
||||||
|
<TableHead className="text-right">
|
||||||
|
<div className="flex items-center justify-end">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-8"
|
||||||
|
onClick={() => handleSort("subscribers")}
|
||||||
|
>
|
||||||
|
Подписчики
|
||||||
|
{getSortIcon("subscribers")}
|
||||||
|
</Button>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Info className="h-3.5 w-3.5 text-muted-foreground cursor-help ml-1" />
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
Количество подписчиков на момент последнего обновления
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
</TableHead>
|
||||||
|
<TableHead className="w-[120px]"></TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{filteredChannels.map((channel) => (
|
||||||
|
<TableRow key={channel.id}>
|
||||||
|
<TableCell>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="flex h-9 w-9 items-center justify-center rounded-lg bg-muted">
|
||||||
|
<Building2 className="h-4 w-4 text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="font-medium">{channel.title}</div>
|
||||||
|
{channel.description && (
|
||||||
|
<div className="text-xs text-muted-foreground line-clamp-1 max-w-[300px]">
|
||||||
|
{channel.description}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{channel.username ? (
|
||||||
|
<a
|
||||||
|
href={`https://t.me/${channel.username}`}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="text-primary hover:underline flex items-center gap-1"
|
||||||
|
>
|
||||||
|
@{channel.username}
|
||||||
|
<ExternalLink className="h-3 w-3" />
|
||||||
|
</a>
|
||||||
|
) : (
|
||||||
|
<span className="text-muted-foreground">—</span>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right">
|
||||||
|
<div className="flex items-center justify-end gap-1">
|
||||||
|
<Users className="h-4 w-4 text-muted-foreground" />
|
||||||
|
{formatNumber(channel.subscribers_count)}
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Button variant="outline" size="sm" asChild>
|
||||||
|
<a href={`/dashboard/${workspaceId}/placements/new?channel_id=${channel.id}`}>
|
||||||
|
Разместить
|
||||||
|
</a>
|
||||||
|
</Button>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</Card>
|
||||||
|
) : (
|
||||||
|
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||||
|
{filteredChannels.map((channel) => (
|
||||||
|
<Card key={channel.id}>
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-muted">
|
||||||
|
<Building2 className="h-5 w-5 text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<CardTitle className="text-base truncate">{channel.title}</CardTitle>
|
||||||
|
{channel.username && (
|
||||||
|
<CardDescription>
|
||||||
|
<a
|
||||||
|
href={`https://t.me/${channel.username}`}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="hover:underline inline-flex items-center gap-1"
|
||||||
|
>
|
||||||
|
@{channel.username}
|
||||||
|
<ExternalLink className="h-3 w-3" />
|
||||||
|
</a>
|
||||||
|
</CardDescription>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{channel.description && (
|
||||||
|
<p className="text-sm text-muted-foreground line-clamp-2 mb-3">
|
||||||
|
{channel.description}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-1 text-sm text-muted-foreground">
|
||||||
|
<Users className="h-4 w-4" />
|
||||||
|
{formatNumber(channel.subscribers_count)}
|
||||||
|
</div>
|
||||||
|
<Button variant="outline" size="sm" asChild>
|
||||||
|
<a href={`/dashboard/${workspaceId}/placements/new?channel_id=${channel.id}`}>
|
||||||
|
Разместить
|
||||||
|
</a>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</TooltipProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
366
app/dashboard/[workspaceId]/creatives/[creativeId]/page.tsx
Normal file
366
app/dashboard/[workspaceId]/creatives/[creativeId]/page.tsx
Normal file
@@ -0,0 +1,366 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Creative Detail Page
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useParams, useRouter, useSearchParams } from "next/navigation";
|
||||||
|
import Link from "next/link";
|
||||||
|
import {
|
||||||
|
Loader2,
|
||||||
|
ArrowLeft,
|
||||||
|
Pencil,
|
||||||
|
Archive,
|
||||||
|
Trash2,
|
||||||
|
Copy,
|
||||||
|
Check,
|
||||||
|
ExternalLink,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { DashboardHeader } from "@/components/layout/dashboard-header";
|
||||||
|
import { useWorkspace } from "@/components/providers/workspace-provider";
|
||||||
|
import { creativesApi } from "@/lib/api";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "@/components/ui/card";
|
||||||
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
AlertDialogTrigger,
|
||||||
|
} from "@/components/ui/alert-dialog";
|
||||||
|
import type { Creative } from "@/lib/types/api";
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Component
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export default function CreativeDetailPage() {
|
||||||
|
const params = useParams();
|
||||||
|
const router = useRouter();
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
const workspaceId = params?.workspaceId as string;
|
||||||
|
const creativeId = params?.creativeId as string;
|
||||||
|
const { hasPermission } = useWorkspace();
|
||||||
|
|
||||||
|
const [creative, setCreative] = useState<Creative | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [isEditing, setIsEditing] = useState(searchParams.get("edit") === "true");
|
||||||
|
const [isSaving, setIsSaving] = useState(false);
|
||||||
|
const [copied, setCopied] = useState(false);
|
||||||
|
|
||||||
|
// Edit form state
|
||||||
|
const [name, setName] = useState("");
|
||||||
|
const [text, setText] = useState("");
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const canWrite = hasPermission("placements_write");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadCreative();
|
||||||
|
}, [workspaceId, creativeId]);
|
||||||
|
|
||||||
|
const loadCreative = async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
const data = await creativesApi.get(workspaceId, creativeId);
|
||||||
|
setCreative(data);
|
||||||
|
setName(data.name);
|
||||||
|
setText(data.text);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to load creative:", err);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
if (!name.trim() || !text.trim()) {
|
||||||
|
setError("Заполните все поля");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!text.includes("{invite_link}")) {
|
||||||
|
setError("Текст должен содержать {invite_link}");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
setIsSaving(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
const updated = await creativesApi.update(workspaceId, creativeId, {
|
||||||
|
name: name.trim(),
|
||||||
|
text: text.trim(),
|
||||||
|
});
|
||||||
|
|
||||||
|
setCreative(updated);
|
||||||
|
setIsEditing(false);
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err?.detail || "Не удалось сохранить");
|
||||||
|
} finally {
|
||||||
|
setIsSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleArchive = async () => {
|
||||||
|
if (!creative) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Toggle archive status by updating via delete endpoint (soft delete)
|
||||||
|
await creativesApi.delete(workspaceId, creativeId);
|
||||||
|
router.push(`/dashboard/${workspaceId}/creatives`);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to archive:", err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCopy = () => {
|
||||||
|
if (creative) {
|
||||||
|
navigator.clipboard.writeText(creative.text);
|
||||||
|
setCopied(true);
|
||||||
|
setTimeout(() => setCopied(false), 2000);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCancel = () => {
|
||||||
|
if (creative) {
|
||||||
|
setName(creative.name);
|
||||||
|
setText(creative.text);
|
||||||
|
}
|
||||||
|
setIsEditing(false);
|
||||||
|
setError(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<DashboardHeader breadcrumbs={[{ label: "Загрузка..." }]} />
|
||||||
|
<div className="flex items-center justify-center py-12">
|
||||||
|
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!creative) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<DashboardHeader breadcrumbs={[{ label: "Ошибка" }]} />
|
||||||
|
<div className="flex flex-col items-center justify-center py-12">
|
||||||
|
<h2 className="text-xl font-semibold mb-2">Креатив не найден</h2>
|
||||||
|
<Button asChild>
|
||||||
|
<Link href={`/dashboard/${workspaceId}/creatives`}>
|
||||||
|
Вернуться к списку
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<DashboardHeader
|
||||||
|
breadcrumbs={[
|
||||||
|
{ label: "Главная", href: `/dashboard/${workspaceId}` },
|
||||||
|
{ label: "Креативы", href: `/dashboard/${workspaceId}/creatives` },
|
||||||
|
{ label: creative.name },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex flex-1 flex-col gap-4 p-4 pt-0 mt-4 max-w-3xl">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => router.push(`/dashboard/${workspaceId}/creatives`)}
|
||||||
|
>
|
||||||
|
<ArrowLeft className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<h1 className="text-2xl font-bold tracking-tight">
|
||||||
|
{creative.name}
|
||||||
|
</h1>
|
||||||
|
<Badge
|
||||||
|
variant={
|
||||||
|
creative.status === "active" ? "default" : "secondary"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{creative.status === "active" ? "Активен" : "Архив"}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
{creative.placements_count} размещений
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{canWrite && !isEditing && (
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button variant="outline" onClick={() => setIsEditing(true)}>
|
||||||
|
<Pencil className="h-4 w-4 mr-2" />
|
||||||
|
Редактировать
|
||||||
|
</Button>
|
||||||
|
<AlertDialog>
|
||||||
|
<AlertDialogTrigger asChild>
|
||||||
|
<Button variant="outline">
|
||||||
|
<Archive className="h-4 w-4 mr-2" />
|
||||||
|
В архив
|
||||||
|
</Button>
|
||||||
|
</AlertDialogTrigger>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>Архивировать креатив?</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
Креатив будет перемещён в архив. Существующие размещения
|
||||||
|
останутся без изменений.
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>Отмена</AlertDialogCancel>
|
||||||
|
<AlertDialogAction onClick={handleArchive}>
|
||||||
|
Архивировать
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isEditing ? (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Редактирование</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Изменения не повлияют на существующие размещения
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="name">Название</Label>
|
||||||
|
<Input
|
||||||
|
id="name"
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
disabled={isSaving}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="text">Текст креатива</Label>
|
||||||
|
<Textarea
|
||||||
|
id="text"
|
||||||
|
value={text}
|
||||||
|
onChange={(e) => setText(e.target.value)}
|
||||||
|
disabled={isSaving}
|
||||||
|
rows={10}
|
||||||
|
className="font-mono text-sm"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Не забудьте <code className="bg-muted px-1 rounded">{"{invite_link}"}</code>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="rounded-md bg-destructive/10 p-3 text-sm text-destructive">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex gap-2 pt-4">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={handleCancel}
|
||||||
|
disabled={isSaving}
|
||||||
|
>
|
||||||
|
Отмена
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleSave} disabled={isSaving}>
|
||||||
|
{isSaving ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||||
|
Сохранение...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
"Сохранить"
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
) : (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<CardTitle>Текст креатива</CardTitle>
|
||||||
|
<Button variant="outline" size="sm" onClick={handleCopy}>
|
||||||
|
{copied ? (
|
||||||
|
<>
|
||||||
|
<Check className="h-4 w-4 mr-2" />
|
||||||
|
Скопировано
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Copy className="h-4 w-4 mr-2" />
|
||||||
|
Копировать
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<pre className="whitespace-pre-wrap text-sm bg-muted p-4 rounded-lg">
|
||||||
|
{creative.text.replace(
|
||||||
|
"{invite_link}",
|
||||||
|
"https://t.me/+AbCdEfGhIjK"
|
||||||
|
)}
|
||||||
|
</pre>
|
||||||
|
<p className="text-xs text-muted-foreground mt-2">
|
||||||
|
<code className="bg-muted px-1 rounded">{"{invite_link}"}</code>{" "}
|
||||||
|
заменяется уникальной ссылкой при создании размещения
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Quick Actions */}
|
||||||
|
{canWrite && creative.status === "active" && (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Действия</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<Button asChild>
|
||||||
|
<Link
|
||||||
|
href={`/dashboard/${workspaceId}/placements/new?creative_id=${creative.id}`}
|
||||||
|
>
|
||||||
|
<ExternalLink className="h-4 w-4 mr-2" />
|
||||||
|
Создать размещение с этим креативом
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
242
app/dashboard/[workspaceId]/creatives/new/page.tsx
Normal file
242
app/dashboard/[workspaceId]/creatives/new/page.tsx
Normal file
@@ -0,0 +1,242 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Create Creative Page
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useParams, useRouter } from "next/navigation";
|
||||||
|
import { Loader2, ArrowLeft, Info } from "lucide-react";
|
||||||
|
import { DashboardHeader } from "@/components/layout/dashboard-header";
|
||||||
|
import { useWorkspace } from "@/components/providers/workspace-provider";
|
||||||
|
import { creativesApi } from "@/lib/api";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "@/components/ui/card";
|
||||||
|
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Component
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export default function NewCreativePage() {
|
||||||
|
const params = useParams();
|
||||||
|
const router = useRouter();
|
||||||
|
const workspaceId = params?.workspaceId as string;
|
||||||
|
const { currentProject } = useWorkspace();
|
||||||
|
|
||||||
|
const [name, setName] = useState("");
|
||||||
|
const [text, setText] = useState("");
|
||||||
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
if (!name.trim() || !text.trim()) {
|
||||||
|
setError("Заполните все обязательные поля");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!text.includes("{invite_link}")) {
|
||||||
|
setError("Текст должен содержать {invite_link} для вставки ссылки");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!currentProject) {
|
||||||
|
setError("Выберите проект в верхнем меню");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
setIsSubmitting(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
const creative = await creativesApi.create(
|
||||||
|
workspaceId,
|
||||||
|
currentProject.id,
|
||||||
|
{
|
||||||
|
name: name.trim(),
|
||||||
|
text: text.trim(),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
router.push(`/dashboard/${workspaceId}/creatives/${creative.id}`);
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err?.detail || "Не удалось создать креатив");
|
||||||
|
} finally {
|
||||||
|
setIsSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const insertInviteLink = () => {
|
||||||
|
if (!text.includes("{invite_link}")) {
|
||||||
|
setText((prev) => prev + "{invite_link}");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<DashboardHeader
|
||||||
|
breadcrumbs={[
|
||||||
|
{ label: "Главная", href: `/dashboard/${workspaceId}` },
|
||||||
|
{ label: "Креативы", href: `/dashboard/${workspaceId}/creatives` },
|
||||||
|
{ label: "Новый креатив" },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex flex-1 flex-col gap-4 p-4 pt-0 mt-4 max-w-2xl">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => router.back()}
|
||||||
|
>
|
||||||
|
<ArrowLeft className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold tracking-tight">Новый креатив</h1>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
Создайте рекламный материал для размещений
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!currentProject && (
|
||||||
|
<Alert>
|
||||||
|
<Info className="h-4 w-4" />
|
||||||
|
<AlertDescription>
|
||||||
|
Выберите проект в верхнем меню для создания креатива
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit}>
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Информация о креативе</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Креатив — это текст рекламного сообщения с пригласительной ссылкой
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="name">
|
||||||
|
Название <span className="text-destructive">*</span>
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="name"
|
||||||
|
placeholder="Например: Летняя акция 2025"
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
disabled={isSubmitting}
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Внутреннее название для удобства поиска
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Label htmlFor="text">
|
||||||
|
Текст креатива <span className="text-destructive">*</span>
|
||||||
|
</Label>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={insertInviteLink}
|
||||||
|
disabled={text.includes("{invite_link}")}
|
||||||
|
>
|
||||||
|
Вставить {"{invite_link}"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<Textarea
|
||||||
|
id="text"
|
||||||
|
placeholder={`Пример:
|
||||||
|
|
||||||
|
🔥 Подпишись на наш канал!
|
||||||
|
|
||||||
|
Здесь ты найдёшь:
|
||||||
|
• Полезный контент
|
||||||
|
• Эксклюзивные материалы
|
||||||
|
• Актуальные новости
|
||||||
|
|
||||||
|
👉 {invite_link}`}
|
||||||
|
value={text}
|
||||||
|
onChange={(e) => setText(e.target.value)}
|
||||||
|
disabled={isSubmitting}
|
||||||
|
rows={10}
|
||||||
|
className="font-mono text-sm"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Используйте <code className="bg-muted px-1 rounded">{"{invite_link}"}</code>{" "}
|
||||||
|
в том месте, где должна быть пригласительная ссылка
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{text.includes("{invite_link}") && (
|
||||||
|
<Alert>
|
||||||
|
<Info className="h-4 w-4" />
|
||||||
|
<AlertDescription>
|
||||||
|
<span className="font-medium">Предпросмотр:</span>
|
||||||
|
<pre className="mt-2 whitespace-pre-wrap text-sm">
|
||||||
|
{text.replace(
|
||||||
|
"{invite_link}",
|
||||||
|
"https://t.me/+AbCdEfGhIjK"
|
||||||
|
)}
|
||||||
|
</pre>
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="rounded-md bg-destructive/10 p-3 text-sm text-destructive">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex gap-2 pt-4">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => router.back()}
|
||||||
|
disabled={isSubmitting}
|
||||||
|
>
|
||||||
|
Отмена
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
disabled={
|
||||||
|
isSubmitting ||
|
||||||
|
!name.trim() ||
|
||||||
|
!text.trim() ||
|
||||||
|
!currentProject
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{isSubmitting ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||||
|
Создание...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
"Создать креатив"
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
569
app/dashboard/[workspaceId]/creatives/page.tsx
Normal file
569
app/dashboard/[workspaceId]/creatives/page.tsx
Normal file
@@ -0,0 +1,569 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Creatives List Page - Enhanced with Statistics
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
import { useEffect, useState, useMemo } from "react";
|
||||||
|
import { useParams } from "next/navigation";
|
||||||
|
import Link from "next/link";
|
||||||
|
import {
|
||||||
|
Loader2,
|
||||||
|
Search,
|
||||||
|
Plus,
|
||||||
|
Folder,
|
||||||
|
MoreHorizontal,
|
||||||
|
Archive,
|
||||||
|
Pencil,
|
||||||
|
Trash2,
|
||||||
|
Eye,
|
||||||
|
Copy,
|
||||||
|
TrendingUp,
|
||||||
|
Users,
|
||||||
|
ShoppingCart,
|
||||||
|
BarChart3,
|
||||||
|
Info,
|
||||||
|
Grid,
|
||||||
|
List,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { DashboardHeader } from "@/components/layout/dashboard-header";
|
||||||
|
import { useWorkspace } from "@/components/providers/workspace-provider";
|
||||||
|
import { creativesApi } from "@/lib/api";
|
||||||
|
import { demoAwareCreativesApi, demoAwareAnalyticsApi } from "@/lib/demo";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "@/components/ui/card";
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from "@/components/ui/table";
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from "@/components/ui/dropdown-menu";
|
||||||
|
import {
|
||||||
|
Tooltip,
|
||||||
|
TooltipContent,
|
||||||
|
TooltipProvider,
|
||||||
|
TooltipTrigger,
|
||||||
|
} from "@/components/ui/tooltip";
|
||||||
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import type { Creative, CreativeAnalyticsItem } from "@/lib/types/api";
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Types
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
interface CreativeWithStats extends Creative {
|
||||||
|
analytics?: CreativeAnalyticsItem;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Helpers
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
const formatNumber = (value: number | null | undefined): string => {
|
||||||
|
if (value === null || value === undefined) return "—";
|
||||||
|
return new Intl.NumberFormat("ru-RU").format(value);
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatCurrency = (value: number | null | undefined): string => {
|
||||||
|
if (value === null || value === undefined) return "—";
|
||||||
|
return new Intl.NumberFormat("ru-RU", {
|
||||||
|
style: "currency",
|
||||||
|
currency: "RUB",
|
||||||
|
minimumFractionDigits: 0,
|
||||||
|
maximumFractionDigits: 0,
|
||||||
|
}).format(value);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Component
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export default function CreativesPage() {
|
||||||
|
const params = useParams();
|
||||||
|
const workspaceId = params?.workspaceId as string;
|
||||||
|
const { selectedProjects, getProjectFilterId, allProjectsSelected, hasPermission } = useWorkspace();
|
||||||
|
const projectFilterId = getProjectFilterId();
|
||||||
|
|
||||||
|
const [creatives, setCreatives] = useState<Creative[]>([]);
|
||||||
|
const [analyticsData, setAnalyticsData] = useState<CreativeAnalyticsItem[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [loadingAnalytics, setLoadingAnalytics] = useState(false);
|
||||||
|
const [search, setSearch] = useState("");
|
||||||
|
const [includeArchived, setIncludeArchived] = useState(false);
|
||||||
|
const [viewMode, setViewMode] = useState<"table" | "grid">("table");
|
||||||
|
|
||||||
|
const canWrite = hasPermission("placements_write");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadCreatives();
|
||||||
|
}, [workspaceId, projectFilterId, includeArchived]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (creatives.length > 0) {
|
||||||
|
loadAnalytics();
|
||||||
|
}
|
||||||
|
}, [creatives, workspaceId, projectFilterId]);
|
||||||
|
|
||||||
|
const loadCreatives = async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
const response = await demoAwareCreativesApi.list(workspaceId, {
|
||||||
|
project_id: projectFilterId,
|
||||||
|
include_archived: includeArchived,
|
||||||
|
size: 100,
|
||||||
|
});
|
||||||
|
setCreatives(response.items);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to load creatives:", err);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadAnalytics = async () => {
|
||||||
|
try {
|
||||||
|
setLoadingAnalytics(true);
|
||||||
|
const response = await demoAwareAnalyticsApi.creatives(workspaceId, {
|
||||||
|
project_id: projectFilterId,
|
||||||
|
size: 100,
|
||||||
|
});
|
||||||
|
setAnalyticsData(response.items);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to load analytics:", err);
|
||||||
|
} finally {
|
||||||
|
setLoadingAnalytics(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Merge creatives with analytics
|
||||||
|
const creativesWithStats: CreativeWithStats[] = useMemo(() => {
|
||||||
|
return creatives.map((creative) => ({
|
||||||
|
...creative,
|
||||||
|
analytics: analyticsData.find((a) => a.id === creative.id),
|
||||||
|
}));
|
||||||
|
}, [creatives, analyticsData]);
|
||||||
|
|
||||||
|
const handleArchive = async (creative: Creative) => {
|
||||||
|
try {
|
||||||
|
await creativesApi.update(workspaceId, creative.id, {});
|
||||||
|
loadCreatives();
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to archive creative:", err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = async (creativeId: string) => {
|
||||||
|
if (!confirm("Удалить креатив? Это действие необратимо.")) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await creativesApi.delete(workspaceId, creativeId);
|
||||||
|
loadCreatives();
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to delete creative:", err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCopyText = (text: string) => {
|
||||||
|
navigator.clipboard.writeText(text);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Filter by search
|
||||||
|
const filteredCreatives = creativesWithStats.filter(
|
||||||
|
(c) =>
|
||||||
|
c.name.toLowerCase().includes(search.toLowerCase()) ||
|
||||||
|
c.text.toLowerCase().includes(search.toLowerCase())
|
||||||
|
);
|
||||||
|
|
||||||
|
// Totals
|
||||||
|
const totals = useMemo(() => {
|
||||||
|
const data = filteredCreatives.filter((c) => c.analytics);
|
||||||
|
return {
|
||||||
|
placements: data.reduce((sum, c) => sum + (c.analytics?.placements_count || 0), 0),
|
||||||
|
cost: data.reduce((sum, c) => sum + (c.analytics?.total_cost || 0), 0),
|
||||||
|
subscriptions: data.reduce((sum, c) => sum + (c.analytics?.total_subscriptions || 0), 0),
|
||||||
|
views: data.reduce((sum, c) => sum + (c.analytics?.total_views || 0), 0),
|
||||||
|
};
|
||||||
|
}, [filteredCreatives]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TooltipProvider>
|
||||||
|
<DashboardHeader
|
||||||
|
breadcrumbs={[
|
||||||
|
{ label: "Главная", href: `/dashboard/${workspaceId}` },
|
||||||
|
{ label: "Креативы" },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex flex-1 flex-col gap-4 p-4 pt-0 mt-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold tracking-tight">Креативы</h1>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
{filteredCreatives.length} креативов • {formatCurrency(totals.cost)} •{" "}
|
||||||
|
{formatNumber(totals.subscriptions)} подписчиков
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="flex items-center gap-1 border rounded-lg p-1">
|
||||||
|
<Button
|
||||||
|
variant={viewMode === "table" ? "secondary" : "ghost"}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setViewMode("table")}
|
||||||
|
aria-label="Табличный вид"
|
||||||
|
>
|
||||||
|
<List className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant={viewMode === "grid" ? "secondary" : "ghost"}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setViewMode("grid")}
|
||||||
|
aria-label="Плиточный вид"
|
||||||
|
>
|
||||||
|
<Grid className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{canWrite && (
|
||||||
|
<Button asChild>
|
||||||
|
<Link href={`/dashboard/${workspaceId}/creatives/new`}>
|
||||||
|
<Plus className="h-4 w-4 mr-2" />
|
||||||
|
Создать креатив
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="relative flex-1 max-w-sm">
|
||||||
|
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
placeholder="Поиск по названию или тексту..."
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
className="pl-10"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Checkbox
|
||||||
|
id="include-archived"
|
||||||
|
checked={includeArchived}
|
||||||
|
onCheckedChange={(checked) => setIncludeArchived(checked === true)}
|
||||||
|
/>
|
||||||
|
<Label htmlFor="include-archived" className="text-sm cursor-pointer">
|
||||||
|
Показать архивные
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex items-center justify-center py-12">
|
||||||
|
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
) : filteredCreatives.length === 0 ? (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="flex flex-col items-center justify-center py-12">
|
||||||
|
<Folder className="h-12 w-12 text-muted-foreground mb-4" />
|
||||||
|
<h3 className="text-lg font-semibold mb-2">Нет креативов</h3>
|
||||||
|
<p className="text-muted-foreground text-center mb-4">
|
||||||
|
{search
|
||||||
|
? "Ничего не найдено по вашему запросу"
|
||||||
|
: "Создайте первый креатив для начала работы"}
|
||||||
|
</p>
|
||||||
|
{canWrite && !search && (
|
||||||
|
<Button asChild>
|
||||||
|
<Link href={`/dashboard/${workspaceId}/creatives/new`}>
|
||||||
|
<Plus className="h-4 w-4 mr-2" />
|
||||||
|
Создать креатив
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
) : viewMode === "table" ? (
|
||||||
|
<Card>
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>Название</TableHead>
|
||||||
|
<TableHead>Текст</TableHead>
|
||||||
|
<TableHead className="text-center">
|
||||||
|
<div className="flex items-center justify-center gap-1">
|
||||||
|
Размещений
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Info className="h-3.5 w-3.5 text-muted-foreground cursor-help" />
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
Количество размещений с этим креативом
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
</TableHead>
|
||||||
|
<TableHead className="text-right">
|
||||||
|
<div className="flex items-center justify-end gap-1">
|
||||||
|
Потрачено
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Info className="h-3.5 w-3.5 text-muted-foreground cursor-help" />
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
Сумма всех размещений с этим креативом
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
</TableHead>
|
||||||
|
<TableHead className="text-right">
|
||||||
|
<div className="flex items-center justify-end gap-1">
|
||||||
|
Подписчики
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Info className="h-3.5 w-3.5 text-muted-foreground cursor-help" />
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
Привлеченные подписчики
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
</TableHead>
|
||||||
|
<TableHead className="text-right">
|
||||||
|
<div className="flex items-center justify-end gap-1">
|
||||||
|
Сред. CPS
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Info className="h-3.5 w-3.5 text-muted-foreground cursor-help" />
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
Cost Per Subscription — средняя стоимость подписчика
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
</TableHead>
|
||||||
|
<TableHead>Статус</TableHead>
|
||||||
|
<TableHead className="w-[70px]"></TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{filteredCreatives.map((creative) => (
|
||||||
|
<TableRow key={creative.id}>
|
||||||
|
<TableCell>
|
||||||
|
<Link
|
||||||
|
href={`/dashboard/${workspaceId}/creatives/${creative.id}`}
|
||||||
|
className="font-medium hover:underline"
|
||||||
|
>
|
||||||
|
{creative.name}
|
||||||
|
</Link>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div className="max-w-[300px] truncate text-sm text-muted-foreground">
|
||||||
|
{creative.text.replace("{invite_link}", "[ссылка]")}
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-center">
|
||||||
|
<Badge variant="secondary">
|
||||||
|
{creative.analytics?.placements_count ?? creative.placements_count}
|
||||||
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right font-medium">
|
||||||
|
{loadingAnalytics ? (
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin inline" />
|
||||||
|
) : (
|
||||||
|
formatCurrency(creative.analytics?.total_cost)
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right">
|
||||||
|
{loadingAnalytics ? (
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin inline" />
|
||||||
|
) : (
|
||||||
|
formatNumber(creative.analytics?.total_subscriptions)
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right">
|
||||||
|
{loadingAnalytics ? (
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin inline" />
|
||||||
|
) : (
|
||||||
|
formatCurrency(creative.analytics?.avg_cpf)
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge
|
||||||
|
variant={creative.status === "active" ? "default" : "secondary"}
|
||||||
|
>
|
||||||
|
{creative.status === "active" ? "Активен" : "Архив"}
|
||||||
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button variant="ghost" size="icon">
|
||||||
|
<MoreHorizontal className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end">
|
||||||
|
<DropdownMenuItem asChild>
|
||||||
|
<Link
|
||||||
|
href={`/dashboard/${workspaceId}/creatives/${creative.id}`}
|
||||||
|
>
|
||||||
|
<Eye className="h-4 w-4 mr-2" />
|
||||||
|
Просмотр
|
||||||
|
</Link>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem asChild>
|
||||||
|
<Link
|
||||||
|
href={`/dashboard/${workspaceId}/analytics/creatives?creative_id=${creative.id}`}
|
||||||
|
>
|
||||||
|
<BarChart3 className="h-4 w-4 mr-2" />
|
||||||
|
Аналитика
|
||||||
|
</Link>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem onClick={() => handleCopyText(creative.text)}>
|
||||||
|
<Copy className="h-4 w-4 mr-2" />
|
||||||
|
Копировать текст
|
||||||
|
</DropdownMenuItem>
|
||||||
|
{canWrite && (
|
||||||
|
<>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuItem asChild>
|
||||||
|
<Link
|
||||||
|
href={`/dashboard/${workspaceId}/creatives/${creative.id}?edit=true`}
|
||||||
|
>
|
||||||
|
<Pencil className="h-4 w-4 mr-2" />
|
||||||
|
Редактировать
|
||||||
|
</Link>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem onClick={() => handleArchive(creative)}>
|
||||||
|
<Archive className="h-4 w-4 mr-2" />
|
||||||
|
{creative.status === "active" ? "В архив" : "Восстановить"}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
{creative.placements_count === 0 && (
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() => handleDelete(creative.id)}
|
||||||
|
className="text-destructive focus:text-destructive"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4 mr-2" />
|
||||||
|
Удалить
|
||||||
|
</DropdownMenuItem>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</Card>
|
||||||
|
) : (
|
||||||
|
// Grid View
|
||||||
|
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||||
|
{filteredCreatives.map((creative) => (
|
||||||
|
<Card key={creative.id}>
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<CardTitle className="text-base truncate">
|
||||||
|
<Link
|
||||||
|
href={`/dashboard/${workspaceId}/creatives/${creative.id}`}
|
||||||
|
className="hover:underline"
|
||||||
|
>
|
||||||
|
{creative.name}
|
||||||
|
</Link>
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription className="line-clamp-2 mt-1">
|
||||||
|
{creative.text.replace("{invite_link}", "[ссылка]")}
|
||||||
|
</CardDescription>
|
||||||
|
</div>
|
||||||
|
<Badge
|
||||||
|
variant={creative.status === "active" ? "default" : "secondary"}
|
||||||
|
className="ml-2 shrink-0"
|
||||||
|
>
|
||||||
|
{creative.status === "active" ? "Активен" : "Архив"}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
{/* Stats */}
|
||||||
|
{creative.analytics && (
|
||||||
|
<div className="grid grid-cols-2 gap-3 text-sm">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<ShoppingCart className="h-4 w-4 text-muted-foreground" />
|
||||||
|
<div>
|
||||||
|
<div className="font-medium">
|
||||||
|
{formatCurrency(creative.analytics.total_cost)}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-muted-foreground">Потрачено</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Users className="h-4 w-4 text-muted-foreground" />
|
||||||
|
<div>
|
||||||
|
<div className="font-medium">
|
||||||
|
{formatNumber(creative.analytics.total_subscriptions)}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-muted-foreground">Подписчиков</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Eye className="h-4 w-4 text-muted-foreground" />
|
||||||
|
<div>
|
||||||
|
<div className="font-medium">
|
||||||
|
{formatNumber(creative.analytics.total_views)}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-muted-foreground">Просмотров</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<TrendingUp className="h-4 w-4 text-muted-foreground" />
|
||||||
|
<div>
|
||||||
|
<div className="font-medium">
|
||||||
|
{formatCurrency(creative.analytics.avg_cpf)}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-muted-foreground">Сред. CPS</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button variant="outline" size="sm" className="flex-1" asChild>
|
||||||
|
<Link href={`/dashboard/${workspaceId}/creatives/${creative.id}`}>
|
||||||
|
<Eye className="h-4 w-4 mr-1" />
|
||||||
|
Подробнее
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" size="sm" asChild>
|
||||||
|
<Link
|
||||||
|
href={`/dashboard/${workspaceId}/analytics/creatives?creative_id=${creative.id}`}
|
||||||
|
>
|
||||||
|
<BarChart3 className="h-4 w-4" />
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</TooltipProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
29
app/dashboard/[workspaceId]/layout.tsx
Normal file
29
app/dashboard/[workspaceId]/layout.tsx
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
// ============================================================================
|
||||||
|
// Workspace Dashboard Layout
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
import { AppSidebar } from "@/components/layout/app-sidebar";
|
||||||
|
import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar";
|
||||||
|
import { ProtectedRoute } from "@/components/auth/protected-route";
|
||||||
|
import { WorkspaceProvider } from "@/components/providers/workspace-provider";
|
||||||
|
import { DashboardLayoutContent } from "@/components/layout/dashboard-layout-content";
|
||||||
|
|
||||||
|
export default function WorkspaceDashboardLayout({
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<ProtectedRoute>
|
||||||
|
<WorkspaceProvider>
|
||||||
|
<SidebarProvider>
|
||||||
|
<AppSidebar />
|
||||||
|
<SidebarInset>
|
||||||
|
<DashboardLayoutContent>{children}</DashboardLayoutContent>
|
||||||
|
</SidebarInset>
|
||||||
|
</SidebarProvider>
|
||||||
|
</WorkspaceProvider>
|
||||||
|
</ProtectedRoute>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
231
app/dashboard/[workspaceId]/page.tsx
Normal file
231
app/dashboard/[workspaceId]/page.tsx
Normal file
@@ -0,0 +1,231 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Dashboard Home Page
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { Loader2, ShoppingCart, Users, TrendingUp, BarChart3 } from "lucide-react";
|
||||||
|
import { DashboardHeader } from "@/components/layout/dashboard-header";
|
||||||
|
import { useWorkspace } from "@/components/providers/workspace-provider";
|
||||||
|
import { demoAwareAnalyticsApi } from "@/lib/demo";
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import type { SpendingAnalytics } from "@/lib/types/api";
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Helpers
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
const formatNumber = (value: number | null | undefined, showSign = false): string => {
|
||||||
|
if (value === null || value === undefined) return "—";
|
||||||
|
const formatted = new Intl.NumberFormat("ru-RU").format(value);
|
||||||
|
if (showSign && value > 0) return `+${formatted}`;
|
||||||
|
return formatted;
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatCurrency = (value: number | null | undefined): string => {
|
||||||
|
if (value === null || value === undefined) return "—";
|
||||||
|
return new Intl.NumberFormat("ru-RU", {
|
||||||
|
style: "currency",
|
||||||
|
currency: "RUB",
|
||||||
|
minimumFractionDigits: 0,
|
||||||
|
maximumFractionDigits: 0,
|
||||||
|
}).format(value);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Component
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export default function DashboardPage() {
|
||||||
|
const { currentWorkspace, currentProject, isLoading: workspaceLoading, projects } = useWorkspace();
|
||||||
|
const [spending, setSpending] = useState<SpendingAnalytics | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!currentWorkspace) {
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadData = async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
const data = await demoAwareAnalyticsApi.spending(currentWorkspace.id, {
|
||||||
|
project_id: currentProject?.id,
|
||||||
|
});
|
||||||
|
setSpending(data);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to load spending:", err);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
loadData();
|
||||||
|
}, [currentWorkspace?.id, currentProject?.id]);
|
||||||
|
|
||||||
|
if (workspaceLoading) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<DashboardHeader breadcrumbs={[{ label: "Главная" }]} />
|
||||||
|
<div className="flex flex-1 items-center justify-center">
|
||||||
|
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!currentWorkspace) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<DashboardHeader breadcrumbs={[{ label: "Главная" }]} />
|
||||||
|
<div className="flex flex-1 items-center justify-center">
|
||||||
|
<div className="text-center">
|
||||||
|
<h2 className="text-2xl font-semibold mb-2">Нет доступных воркспейсов</h2>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
Создайте новый воркспейс для начала работы
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate totals and averages
|
||||||
|
const totalCost = spending?.total_cost ?? 0;
|
||||||
|
const totalSubscriptions = spending?.total_subscriptions ?? 0;
|
||||||
|
const totalViews = spending?.total_views ?? 0;
|
||||||
|
const avgCps = totalSubscriptions > 0 ? totalCost / totalSubscriptions : null;
|
||||||
|
const avgCpm = totalViews > 0 ? (totalCost / totalViews) * 1000 : null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<DashboardHeader breadcrumbs={[{ label: "Главная" }]} />
|
||||||
|
|
||||||
|
<div className="flex flex-1 flex-col gap-4 p-4 pt-0 mt-4">
|
||||||
|
{/* Stats Cards */}
|
||||||
|
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium">Всего потрачено</CardTitle>
|
||||||
|
<ShoppingCart className="h-4 w-4 text-muted-foreground" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{loading ? (
|
||||||
|
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||||
|
) : (
|
||||||
|
<div className="text-2xl font-bold">{formatCurrency(totalCost)}</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium">Подписчиков</CardTitle>
|
||||||
|
<Users className="h-4 w-4 text-muted-foreground" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{loading ? (
|
||||||
|
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||||
|
) : (
|
||||||
|
<div className="text-2xl font-bold">{formatNumber(totalSubscriptions)}</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium">Средний CPS</CardTitle>
|
||||||
|
<TrendingUp className="h-4 w-4 text-muted-foreground" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{loading ? (
|
||||||
|
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||||
|
) : (
|
||||||
|
<div className="text-2xl font-bold">{formatCurrency(avgCps)}</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium">Средний CPM</CardTitle>
|
||||||
|
<BarChart3 className="h-4 w-4 text-muted-foreground" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{loading ? (
|
||||||
|
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||||
|
) : (
|
||||||
|
<div className="text-2xl font-bold">{formatCurrency(avgCpm)}</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Quick Actions */}
|
||||||
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Быстрый старт</CardTitle>
|
||||||
|
<CardDescription>Начните с основных действий</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-2">
|
||||||
|
<a
|
||||||
|
href={`/dashboard/${currentWorkspace.id}/placements/new`}
|
||||||
|
className="block text-sm text-primary hover:underline"
|
||||||
|
>
|
||||||
|
→ Создать размещение
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
href={`/dashboard/${currentWorkspace.id}/creatives/new`}
|
||||||
|
className="block text-sm text-primary hover:underline"
|
||||||
|
>
|
||||||
|
→ Создать креатив
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
href={`/dashboard/${currentWorkspace.id}/analytics`}
|
||||||
|
className="block text-sm text-primary hover:underline"
|
||||||
|
>
|
||||||
|
→ Посмотреть аналитику
|
||||||
|
</a>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Проекты</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
{projects.length > 0
|
||||||
|
? `${projects.length} активных проектов`
|
||||||
|
: "Нет активных проектов"}
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{projects.length > 0 ? (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{projects.slice(0, 5).map((project) => (
|
||||||
|
<div
|
||||||
|
key={project.id}
|
||||||
|
className="flex items-center justify-between text-sm"
|
||||||
|
>
|
||||||
|
<span className="font-medium">{project.title}</span>
|
||||||
|
{project.username && (
|
||||||
|
<span className="text-muted-foreground">@{project.username}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Добавьте бота в канал через Telegram для создания проекта
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
431
app/dashboard/[workspaceId]/placements/[placementId]/page.tsx
Normal file
431
app/dashboard/[workspaceId]/placements/[placementId]/page.tsx
Normal file
@@ -0,0 +1,431 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Placement Detail Page
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useParams, useRouter } from "next/navigation";
|
||||||
|
import Link from "next/link";
|
||||||
|
import {
|
||||||
|
Loader2,
|
||||||
|
ArrowLeft,
|
||||||
|
ExternalLink,
|
||||||
|
Copy,
|
||||||
|
Check,
|
||||||
|
Archive,
|
||||||
|
Eye,
|
||||||
|
Users,
|
||||||
|
TrendingUp,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { DashboardHeader } from "@/components/layout/dashboard-header";
|
||||||
|
import { useWorkspace } from "@/components/providers/workspace-provider";
|
||||||
|
import { placementsApi } from "@/lib/api";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "@/components/ui/card";
|
||||||
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
AlertDialogTrigger,
|
||||||
|
} from "@/components/ui/alert-dialog";
|
||||||
|
import type { Placement } from "@/lib/types/api";
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Helpers
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
const formatDate = (dateString: string) => {
|
||||||
|
return new Date(dateString).toLocaleDateString("ru-RU", {
|
||||||
|
day: "numeric",
|
||||||
|
month: "long",
|
||||||
|
year: "numeric",
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatDateTime = (dateString: string | null | undefined) => {
|
||||||
|
if (!dateString) return "—";
|
||||||
|
return new Date(dateString).toLocaleString("ru-RU", {
|
||||||
|
day: "numeric",
|
||||||
|
month: "short",
|
||||||
|
year: "numeric",
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatCurrency = (value: number | null | undefined) => {
|
||||||
|
if (value === null || value === undefined) return "—";
|
||||||
|
return new Intl.NumberFormat("ru-RU", {
|
||||||
|
style: "currency",
|
||||||
|
currency: "RUB",
|
||||||
|
minimumFractionDigits: 0,
|
||||||
|
maximumFractionDigits: 0,
|
||||||
|
}).format(value);
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatNumber = (value: number | null | undefined) => {
|
||||||
|
if (value === null || value === undefined) return "—";
|
||||||
|
return new Intl.NumberFormat("ru-RU").format(value);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Component
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export default function PlacementDetailPage() {
|
||||||
|
const params = useParams();
|
||||||
|
const router = useRouter();
|
||||||
|
const workspaceId = params?.workspaceId as string;
|
||||||
|
const placementId = params?.placementId as string;
|
||||||
|
const { hasPermission } = useWorkspace();
|
||||||
|
|
||||||
|
const [placement, setPlacement] = useState<Placement | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [copiedLink, setCopiedLink] = useState(false);
|
||||||
|
|
||||||
|
const canWrite = hasPermission("placements_write");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadPlacement();
|
||||||
|
}, [workspaceId, placementId]);
|
||||||
|
|
||||||
|
const loadPlacement = async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
const data = await placementsApi.get(workspaceId, placementId);
|
||||||
|
setPlacement(data);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to load placement:", err);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleArchive = async () => {
|
||||||
|
try {
|
||||||
|
await placementsApi.delete(workspaceId, placementId);
|
||||||
|
router.push(`/dashboard/${workspaceId}/placements`);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to archive:", err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCopyLink = () => {
|
||||||
|
if (placement?.invite_link) {
|
||||||
|
navigator.clipboard.writeText(placement.invite_link);
|
||||||
|
setCopiedLink(true);
|
||||||
|
setTimeout(() => setCopiedLink(false), 2000);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<DashboardHeader breadcrumbs={[{ label: "Загрузка..." }]} />
|
||||||
|
<div className="flex items-center justify-center py-12">
|
||||||
|
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!placement) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<DashboardHeader breadcrumbs={[{ label: "Ошибка" }]} />
|
||||||
|
<div className="flex flex-col items-center justify-center py-12">
|
||||||
|
<h2 className="text-xl font-semibold mb-2">Размещение не найдено</h2>
|
||||||
|
<Button asChild>
|
||||||
|
<Link href={`/dashboard/${workspaceId}/placements`}>
|
||||||
|
Вернуться к списку
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate metrics
|
||||||
|
const cps =
|
||||||
|
placement.cost && placement.subscriptions_count > 0
|
||||||
|
? placement.cost / placement.subscriptions_count
|
||||||
|
: null;
|
||||||
|
const cpm =
|
||||||
|
placement.cost && placement.views_count
|
||||||
|
? (placement.cost / placement.views_count) * 1000
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<DashboardHeader
|
||||||
|
breadcrumbs={[
|
||||||
|
{ label: "Главная", href: `/dashboard/${workspaceId}` },
|
||||||
|
{ label: "Размещения", href: `/dashboard/${workspaceId}/placements` },
|
||||||
|
{ label: placement.placement_channel_title },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex flex-1 flex-col gap-4 p-4 pt-0 mt-4">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => router.push(`/dashboard/${workspaceId}/placements`)}
|
||||||
|
>
|
||||||
|
<ArrowLeft className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<h1 className="text-2xl font-bold tracking-tight">
|
||||||
|
{placement.placement_channel_title}
|
||||||
|
</h1>
|
||||||
|
<Badge
|
||||||
|
variant={
|
||||||
|
placement.status === "active" ? "default" : "secondary"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{placement.status === "active" ? "Активно" : "Архив"}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
{formatDate(placement.placement_date)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{canWrite && placement.status === "active" && (
|
||||||
|
<AlertDialog>
|
||||||
|
<AlertDialogTrigger asChild>
|
||||||
|
<Button variant="outline">
|
||||||
|
<Archive className="h-4 w-4 mr-2" />
|
||||||
|
В архив
|
||||||
|
</Button>
|
||||||
|
</AlertDialogTrigger>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>Архивировать размещение?</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
Размещение будет перемещено в архив. Статистика сохранится.
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>Отмена</AlertDialogCancel>
|
||||||
|
<AlertDialogAction onClick={handleArchive}>
|
||||||
|
Архивировать
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Stats Cards */}
|
||||||
|
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium">Стоимость</CardTitle>
|
||||||
|
<TrendingUp className="h-4 w-4 text-muted-foreground" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-2xl font-bold">
|
||||||
|
{formatCurrency(placement.cost)}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium">Подписчиков</CardTitle>
|
||||||
|
<Users className="h-4 w-4 text-muted-foreground" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-2xl font-bold">
|
||||||
|
{formatNumber(placement.subscriptions_count)}
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
CPS: {cps ? formatCurrency(cps) : "—"}
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium">Просмотров</CardTitle>
|
||||||
|
<Eye className="h-4 w-4 text-muted-foreground" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-2xl font-bold">
|
||||||
|
{formatNumber(placement.views_count)}
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
CPM: {cpm ? formatCurrency(cpm) : "—"}
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium">
|
||||||
|
Тип ссылки
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-2xl font-bold">
|
||||||
|
{placement.invite_link_type === "approval"
|
||||||
|
? "С заявками"
|
||||||
|
: "Публичная"}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-4 lg:grid-cols-2">
|
||||||
|
{/* Invite Link */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Пригласительная ссылка</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
{placement.invite_link_type === "approval"
|
||||||
|
? "С одобрением — бот отслеживает подписчиков"
|
||||||
|
: "Публичная ссылка"}
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<code className="flex-1 bg-muted px-3 py-2 rounded text-sm truncate">
|
||||||
|
{placement.invite_link}
|
||||||
|
</code>
|
||||||
|
<Button variant="outline" size="icon" onClick={handleCopyLink}>
|
||||||
|
{copiedLink ? (
|
||||||
|
<Check className="h-4 w-4" />
|
||||||
|
) : (
|
||||||
|
<Copy className="h-4 w-4" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" size="icon" asChild>
|
||||||
|
<a
|
||||||
|
href={placement.invite_link}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
>
|
||||||
|
<ExternalLink className="h-4 w-4" />
|
||||||
|
</a>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Creative */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Креатив</CardTitle>
|
||||||
|
<CardDescription>{placement.creative_name}</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<Button variant="outline" asChild>
|
||||||
|
<Link
|
||||||
|
href={`/dashboard/${workspaceId}/creatives/${placement.creative_id}`}
|
||||||
|
>
|
||||||
|
Просмотреть креатив
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Details */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Детали размещения</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<dl className="grid gap-4 sm:grid-cols-2">
|
||||||
|
<div>
|
||||||
|
<dt className="text-sm font-medium text-muted-foreground">
|
||||||
|
Проект
|
||||||
|
</dt>
|
||||||
|
<dd className="text-sm">{placement.project_channel_title}</dd>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<dt className="text-sm font-medium text-muted-foreground">
|
||||||
|
Дата размещения
|
||||||
|
</dt>
|
||||||
|
<dd className="text-sm">{formatDate(placement.placement_date)}</dd>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<dt className="text-sm font-medium text-muted-foreground">
|
||||||
|
Статус просмотров
|
||||||
|
</dt>
|
||||||
|
<dd className="text-sm">
|
||||||
|
<Badge variant="outline">
|
||||||
|
{placement.views_availability === "available"
|
||||||
|
? "Автообновление"
|
||||||
|
: placement.views_availability === "manual"
|
||||||
|
? "Ручной ввод"
|
||||||
|
: placement.views_availability === "unavailable"
|
||||||
|
? "Недоступно"
|
||||||
|
: "Не проверено"}
|
||||||
|
</Badge>
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<dt className="text-sm font-medium text-muted-foreground">
|
||||||
|
Последнее обновление просмотров
|
||||||
|
</dt>
|
||||||
|
<dd className="text-sm">
|
||||||
|
{formatDateTime(placement.last_views_fetch_at)}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{placement.ad_post_url && (
|
||||||
|
<div className="sm:col-span-2">
|
||||||
|
<dt className="text-sm font-medium text-muted-foreground">
|
||||||
|
Ссылка на пост
|
||||||
|
</dt>
|
||||||
|
<dd className="text-sm">
|
||||||
|
<a
|
||||||
|
href={placement.ad_post_url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="text-primary hover:underline inline-flex items-center gap-1"
|
||||||
|
>
|
||||||
|
{placement.ad_post_url}
|
||||||
|
<ExternalLink className="h-3 w-3" />
|
||||||
|
</a>
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{placement.comment && (
|
||||||
|
<div className="sm:col-span-2">
|
||||||
|
<dt className="text-sm font-medium text-muted-foreground">
|
||||||
|
Комментарий
|
||||||
|
</dt>
|
||||||
|
<dd className="text-sm">{placement.comment}</dd>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</dl>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
393
app/dashboard/[workspaceId]/placements/new/page.tsx
Normal file
393
app/dashboard/[workspaceId]/placements/new/page.tsx
Normal file
@@ -0,0 +1,393 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Create Placement Page
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useParams, useRouter, useSearchParams } from "next/navigation";
|
||||||
|
import { Loader2, ArrowLeft, Info, Calendar as CalendarIcon } from "lucide-react";
|
||||||
|
import { format } from "date-fns";
|
||||||
|
import { ru } from "date-fns/locale";
|
||||||
|
import { DashboardHeader } from "@/components/layout/dashboard-header";
|
||||||
|
import { useWorkspace } from "@/components/providers/workspace-provider";
|
||||||
|
import { placementsApi, creativesApi, channelsApi } from "@/lib/api";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "@/components/ui/card";
|
||||||
|
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||||
|
import { Calendar } from "@/components/ui/calendar";
|
||||||
|
import {
|
||||||
|
Popover,
|
||||||
|
PopoverContent,
|
||||||
|
PopoverTrigger,
|
||||||
|
} from "@/components/ui/popover";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import type { Creative, Channel, InviteLinkType } from "@/lib/types/api";
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Component
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export default function NewPlacementPage() {
|
||||||
|
const params = useParams();
|
||||||
|
const router = useRouter();
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
const workspaceId = params?.workspaceId as string;
|
||||||
|
const { currentProject } = useWorkspace();
|
||||||
|
|
||||||
|
// Pre-filled from URL params
|
||||||
|
const prefilledCreativeId = searchParams.get("creative_id");
|
||||||
|
const prefilledChannelId = searchParams.get("channel_id");
|
||||||
|
|
||||||
|
// Form state
|
||||||
|
const [channelId, setChannelId] = useState(prefilledChannelId || "");
|
||||||
|
const [creativeId, setCreativeId] = useState(prefilledCreativeId || "");
|
||||||
|
const [placementDate, setPlacementDate] = useState<Date>(new Date());
|
||||||
|
const [cost, setCost] = useState("");
|
||||||
|
const [comment, setComment] = useState("");
|
||||||
|
const [adPostUrl, setAdPostUrl] = useState("");
|
||||||
|
const [inviteLinkType, setInviteLinkType] = useState<InviteLinkType>("approval");
|
||||||
|
|
||||||
|
// Data
|
||||||
|
const [channels, setChannels] = useState<Channel[]>([]);
|
||||||
|
const [creatives, setCreatives] = useState<Creative[]>([]);
|
||||||
|
const [loadingData, setLoadingData] = useState(true);
|
||||||
|
|
||||||
|
// Submit state
|
||||||
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadData();
|
||||||
|
}, [workspaceId, currentProject?.id]);
|
||||||
|
|
||||||
|
const loadData = async () => {
|
||||||
|
try {
|
||||||
|
setLoadingData(true);
|
||||||
|
|
||||||
|
const [channelsRes, creativesRes] = await Promise.all([
|
||||||
|
channelsApi.list({ size: 100 }),
|
||||||
|
currentProject
|
||||||
|
? creativesApi.list(workspaceId, {
|
||||||
|
project_id: currentProject.id,
|
||||||
|
include_archived: false,
|
||||||
|
size: 100,
|
||||||
|
})
|
||||||
|
: Promise.resolve({ items: [] }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
setChannels(channelsRes.items);
|
||||||
|
setCreatives(creativesRes.items);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to load data:", err);
|
||||||
|
} finally {
|
||||||
|
setLoadingData(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
if (!currentProject) {
|
||||||
|
setError("Выберите проект в верхнем меню");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!channelId || !creativeId) {
|
||||||
|
setError("Выберите канал и креатив");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
setIsSubmitting(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
const placement = await placementsApi.create(workspaceId, {
|
||||||
|
project_id: currentProject.id,
|
||||||
|
placement_channel_id: channelId,
|
||||||
|
creative_id: creativeId,
|
||||||
|
placement_date: placementDate.toISOString(),
|
||||||
|
cost: cost ? parseFloat(cost) : undefined,
|
||||||
|
comment: comment || undefined,
|
||||||
|
ad_post_url: adPostUrl || undefined,
|
||||||
|
invite_link_type: inviteLinkType,
|
||||||
|
});
|
||||||
|
|
||||||
|
router.push(`/dashboard/${workspaceId}/placements/${placement.id}`);
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err?.detail || "Не удалось создать размещение");
|
||||||
|
} finally {
|
||||||
|
setIsSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<DashboardHeader
|
||||||
|
breadcrumbs={[
|
||||||
|
{ label: "Главная", href: `/dashboard/${workspaceId}` },
|
||||||
|
{ label: "Размещения", href: `/dashboard/${workspaceId}/placements` },
|
||||||
|
{ label: "Новое размещение" },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex flex-1 flex-col gap-4 p-4 pt-0 mt-4 max-w-2xl">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<Button variant="ghost" size="icon" onClick={() => router.back()}>
|
||||||
|
<ArrowLeft className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold tracking-tight">Новое размещение</h1>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
Создайте размещение рекламы в Telegram канале
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!currentProject && (
|
||||||
|
<Alert>
|
||||||
|
<Info className="h-4 w-4" />
|
||||||
|
<AlertDescription>
|
||||||
|
Выберите проект в верхнем меню для создания размещения
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{loadingData ? (
|
||||||
|
<div className="flex items-center justify-center py-12">
|
||||||
|
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<form onSubmit={handleSubmit}>
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Параметры размещения</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Укажите канал, креатив и условия размещения
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
{/* Channel */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>
|
||||||
|
Канал для размещения <span className="text-destructive">*</span>
|
||||||
|
</Label>
|
||||||
|
<Select value={channelId} onValueChange={setChannelId}>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Выберите канал" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{channels.map((channel) => (
|
||||||
|
<SelectItem key={channel.id} value={channel.id}>
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span>{channel.title}</span>
|
||||||
|
{channel.username && (
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
@{channel.username}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Creative */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>
|
||||||
|
Креатив <span className="text-destructive">*</span>
|
||||||
|
</Label>
|
||||||
|
<Select value={creativeId} onValueChange={setCreativeId}>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Выберите креатив" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{creatives.length === 0 ? (
|
||||||
|
<div className="p-2 text-sm text-muted-foreground text-center">
|
||||||
|
Нет доступных креативов
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
creatives.map((creative) => (
|
||||||
|
<SelectItem key={creative.id} value={creative.id}>
|
||||||
|
{creative.name}
|
||||||
|
</SelectItem>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
{creatives.length === 0 && currentProject && (
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Сначала{" "}
|
||||||
|
<a
|
||||||
|
href={`/dashboard/${workspaceId}/creatives/new`}
|
||||||
|
className="text-primary hover:underline"
|
||||||
|
>
|
||||||
|
создайте креатив
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Placement Date */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>
|
||||||
|
Дата размещения <span className="text-destructive">*</span>
|
||||||
|
</Label>
|
||||||
|
<Popover>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className={cn(
|
||||||
|
"w-full justify-start text-left font-normal",
|
||||||
|
!placementDate && "text-muted-foreground"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<CalendarIcon className="mr-2 h-4 w-4" />
|
||||||
|
{placementDate
|
||||||
|
? format(placementDate, "PPP", { locale: ru })
|
||||||
|
: "Выберите дату"}
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent className="w-auto p-0" align="start">
|
||||||
|
<Calendar
|
||||||
|
mode="single"
|
||||||
|
selected={placementDate}
|
||||||
|
onSelect={(date) => date && setPlacementDate(date)}
|
||||||
|
locale={ru}
|
||||||
|
/>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Cost */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="cost">Стоимость (₽)</Label>
|
||||||
|
<Input
|
||||||
|
id="cost"
|
||||||
|
type="number"
|
||||||
|
placeholder="0"
|
||||||
|
value={cost}
|
||||||
|
onChange={(e) => setCost(e.target.value)}
|
||||||
|
disabled={isSubmitting}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Invite Link Type */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Тип пригласительной ссылки</Label>
|
||||||
|
<Select
|
||||||
|
value={inviteLinkType}
|
||||||
|
onValueChange={(v) => setInviteLinkType(v as InviteLinkType)}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="approval">
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span>С одобрением (рекомендуется)</span>
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
Бот одобряет заявки и отслеживает подписчиков
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="public">
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span>Публичная ссылка</span>
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
Без отслеживания подписчиков
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Ad Post URL */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="adPostUrl">Ссылка на рекламный пост</Label>
|
||||||
|
<Input
|
||||||
|
id="adPostUrl"
|
||||||
|
placeholder="https://t.me/channel/123"
|
||||||
|
value={adPostUrl}
|
||||||
|
onChange={(e) => setAdPostUrl(e.target.value)}
|
||||||
|
disabled={isSubmitting}
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Ссылка на пост после публикации (можно добавить позже)
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Comment */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="comment">Комментарий</Label>
|
||||||
|
<Textarea
|
||||||
|
id="comment"
|
||||||
|
placeholder="Дополнительная информация..."
|
||||||
|
value={comment}
|
||||||
|
onChange={(e) => setComment(e.target.value)}
|
||||||
|
disabled={isSubmitting}
|
||||||
|
rows={3}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="rounded-md bg-destructive/10 p-3 text-sm text-destructive">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex gap-2 pt-4">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => router.back()}
|
||||||
|
disabled={isSubmitting}
|
||||||
|
>
|
||||||
|
Отмена
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
disabled={
|
||||||
|
isSubmitting ||
|
||||||
|
!channelId ||
|
||||||
|
!creativeId ||
|
||||||
|
!currentProject
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{isSubmitting ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||||
|
Создание...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
"Создать размещение"
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
742
app/dashboard/[workspaceId]/placements/page.tsx
Normal file
742
app/dashboard/[workspaceId]/placements/page.tsx
Normal file
@@ -0,0 +1,742 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Placements List Page - Enhanced with Totals Row and Export
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
import { useEffect, useState, useMemo, useCallback } from "react";
|
||||||
|
import { useParams } from "next/navigation";
|
||||||
|
import Link from "next/link";
|
||||||
|
import {
|
||||||
|
Loader2,
|
||||||
|
Search,
|
||||||
|
Plus,
|
||||||
|
ShoppingCart,
|
||||||
|
MoreHorizontal,
|
||||||
|
Eye,
|
||||||
|
ExternalLink,
|
||||||
|
ArrowUpDown,
|
||||||
|
ArrowUp,
|
||||||
|
ArrowDown,
|
||||||
|
Download,
|
||||||
|
FileSpreadsheet,
|
||||||
|
FileText,
|
||||||
|
Info,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { DashboardHeader } from "@/components/layout/dashboard-header";
|
||||||
|
import { useWorkspace } from "@/components/providers/workspace-provider";
|
||||||
|
import { demoAwarePlacementsApi } from "@/lib/demo";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
TableFooter,
|
||||||
|
} from "@/components/ui/table";
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuLabel,
|
||||||
|
} from "@/components/ui/dropdown-menu";
|
||||||
|
import {
|
||||||
|
Tooltip,
|
||||||
|
TooltipContent,
|
||||||
|
TooltipProvider,
|
||||||
|
TooltipTrigger,
|
||||||
|
} from "@/components/ui/tooltip";
|
||||||
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import type { Placement } from "@/lib/types/api";
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Helpers
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
const formatDate = (dateString: string) => {
|
||||||
|
return new Date(dateString).toLocaleDateString("ru-RU", {
|
||||||
|
day: "numeric",
|
||||||
|
month: "short",
|
||||||
|
year: "numeric",
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatCurrency = (value: number | null) => {
|
||||||
|
if (value === null) return "—";
|
||||||
|
return new Intl.NumberFormat("ru-RU", {
|
||||||
|
style: "currency",
|
||||||
|
currency: "RUB",
|
||||||
|
minimumFractionDigits: 0,
|
||||||
|
maximumFractionDigits: 0,
|
||||||
|
}).format(value);
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatNumber = (value: number | null) => {
|
||||||
|
if (value === null) return "—";
|
||||||
|
return new Intl.NumberFormat("ru-RU").format(value);
|
||||||
|
};
|
||||||
|
|
||||||
|
const calculateCPS = (cost: number | null, subscriptions: number) => {
|
||||||
|
if (!cost || subscriptions === 0) return null;
|
||||||
|
return cost / subscriptions;
|
||||||
|
};
|
||||||
|
|
||||||
|
const calculateCPM = (cost: number | null | undefined, views: number | null | undefined) => {
|
||||||
|
if (!cost || !views || views === 0) return null;
|
||||||
|
return (cost / views) * 1000;
|
||||||
|
};
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Sort Types
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
type SortField = "date" | "cost" | "subscriptions" | "views" | "cps" | "cpm" | "status";
|
||||||
|
type SortOrder = "asc" | "desc" | null;
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Aggregation Functions
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
type AggFunc = "sum" | "avg" | "median" | "max" | "min";
|
||||||
|
|
||||||
|
const aggregationFunctions: Record<AggFunc, { label: string; fn: (values: number[]) => number | null }> = {
|
||||||
|
sum: {
|
||||||
|
label: "Сумма",
|
||||||
|
fn: (values) => (values.length > 0 ? values.reduce((a, b) => a + b, 0) : null),
|
||||||
|
},
|
||||||
|
avg: {
|
||||||
|
label: "Среднее",
|
||||||
|
fn: (values) => (values.length > 0 ? values.reduce((a, b) => a + b, 0) / values.length : null),
|
||||||
|
},
|
||||||
|
median: {
|
||||||
|
label: "Медиана",
|
||||||
|
fn: (values) => {
|
||||||
|
if (values.length === 0) return null;
|
||||||
|
const sorted = [...values].sort((a, b) => a - b);
|
||||||
|
const mid = Math.floor(sorted.length / 2);
|
||||||
|
return sorted.length % 2 !== 0 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
max: {
|
||||||
|
label: "Макс.",
|
||||||
|
fn: (values) => (values.length > 0 ? Math.max(...values) : null),
|
||||||
|
},
|
||||||
|
min: {
|
||||||
|
label: "Мин.",
|
||||||
|
fn: (values) => (values.length > 0 ? Math.min(...values) : null),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Export Functions
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
const exportToCSV = (placements: Placement[], filename: string) => {
|
||||||
|
const headers = [
|
||||||
|
"Канал",
|
||||||
|
"Username",
|
||||||
|
"Креатив",
|
||||||
|
"Дата",
|
||||||
|
"Стоимость",
|
||||||
|
"Подписки",
|
||||||
|
"Просмотры",
|
||||||
|
"CPS",
|
||||||
|
"CPM",
|
||||||
|
"Статус",
|
||||||
|
"Ссылка на пост",
|
||||||
|
"Пригласительная ссылка",
|
||||||
|
];
|
||||||
|
|
||||||
|
const rows = placements.map((p) => [
|
||||||
|
p.placement_channel_title,
|
||||||
|
"",
|
||||||
|
p.creative_name,
|
||||||
|
new Date(p.placement_date).toISOString().split("T")[0],
|
||||||
|
p.cost?.toString() || "",
|
||||||
|
p.subscriptions_count.toString(),
|
||||||
|
p.views_count?.toString() || "",
|
||||||
|
calculateCPS(p.cost, p.subscriptions_count)?.toFixed(2) || "",
|
||||||
|
calculateCPM(p.cost, p.views_count)?.toFixed(2) || "",
|
||||||
|
p.status,
|
||||||
|
p.ad_post_url || "",
|
||||||
|
p.invite_link,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const csv = [headers.join(";"), ...rows.map((r) => r.join(";"))].join("\n");
|
||||||
|
const blob = new Blob(["\uFEFF" + csv], { type: "text/csv;charset=utf-8;" });
|
||||||
|
downloadBlob(blob, `${filename}.csv`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const exportToExcel = (placements: Placement[], filename: string) => {
|
||||||
|
// Simple XLSX-like TSV format (opens in Excel)
|
||||||
|
const headers = [
|
||||||
|
"Канал",
|
||||||
|
"Username",
|
||||||
|
"Креатив",
|
||||||
|
"Дата",
|
||||||
|
"Стоимость",
|
||||||
|
"Подписки",
|
||||||
|
"Просмотры",
|
||||||
|
"CPS",
|
||||||
|
"CPM",
|
||||||
|
"Статус",
|
||||||
|
];
|
||||||
|
|
||||||
|
const rows = placements.map((p) => [
|
||||||
|
p.placement_channel_title,
|
||||||
|
"",
|
||||||
|
p.creative_name,
|
||||||
|
new Date(p.placement_date).toISOString().split("T")[0],
|
||||||
|
p.cost?.toString() || "",
|
||||||
|
p.subscriptions_count.toString(),
|
||||||
|
p.views_count?.toString() || "",
|
||||||
|
calculateCPS(p.cost, p.subscriptions_count)?.toFixed(2) || "",
|
||||||
|
calculateCPM(p.cost, p.views_count)?.toFixed(2) || "",
|
||||||
|
p.status,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const tsv = [headers.join("\t"), ...rows.map((r) => r.join("\t"))].join("\n");
|
||||||
|
const blob = new Blob(["\uFEFF" + tsv], {
|
||||||
|
type: "application/vnd.ms-excel;charset=utf-8;",
|
||||||
|
});
|
||||||
|
downloadBlob(blob, `${filename}.xls`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const exportToTxt = (placements: Placement[], filename: string) => {
|
||||||
|
const lines = placements.map((p) => {
|
||||||
|
const cps = calculateCPS(p.cost, p.subscriptions_count);
|
||||||
|
const cpm = calculateCPM(p.cost, p.views_count);
|
||||||
|
return [
|
||||||
|
`Канал: ${p.placement_channel_title}`,
|
||||||
|
`Креатив: ${p.creative_name}`,
|
||||||
|
`Дата: ${formatDate(p.placement_date)}`,
|
||||||
|
`Стоимость: ${formatCurrency(p.cost)}`,
|
||||||
|
`Подписки: ${p.subscriptions_count}`,
|
||||||
|
`Просмотры: ${p.views_count || "—"}`,
|
||||||
|
`CPS: ${cps ? formatCurrency(cps) : "—"}`,
|
||||||
|
`CPM: ${cpm ? formatCurrency(cpm) : "—"}`,
|
||||||
|
`Ссылка: ${p.invite_link}`,
|
||||||
|
"---",
|
||||||
|
].join("\n");
|
||||||
|
});
|
||||||
|
|
||||||
|
const text = lines.join("\n");
|
||||||
|
const blob = new Blob([text], { type: "text/plain;charset=utf-8;" });
|
||||||
|
downloadBlob(blob, `${filename}.txt`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const downloadBlob = (blob: Blob, filename: string) => {
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const link = document.createElement("a");
|
||||||
|
link.href = url;
|
||||||
|
link.download = filename;
|
||||||
|
document.body.appendChild(link);
|
||||||
|
link.click();
|
||||||
|
document.body.removeChild(link);
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Component
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export default function PlacementsPage() {
|
||||||
|
const params = useParams();
|
||||||
|
const workspaceId = params?.workspaceId as string;
|
||||||
|
const { selectedProjects, getProjectFilterId, allProjectsSelected, hasPermission } = useWorkspace();
|
||||||
|
const projectFilterId = getProjectFilterId();
|
||||||
|
|
||||||
|
const [placements, setPlacements] = useState<Placement[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [search, setSearch] = useState("");
|
||||||
|
const [includeArchived, setIncludeArchived] = useState(false);
|
||||||
|
const [sortField, setSortField] = useState<SortField | null>(null);
|
||||||
|
const [sortOrder, setSortOrder] = useState<SortOrder>(null);
|
||||||
|
|
||||||
|
// Aggregation settings
|
||||||
|
const [aggCost, setAggCost] = useState<AggFunc>("sum");
|
||||||
|
const [aggSubs, setAggSubs] = useState<AggFunc>("sum");
|
||||||
|
const [aggViews, setAggViews] = useState<AggFunc>("sum");
|
||||||
|
const [aggCps, setAggCps] = useState<AggFunc>("avg");
|
||||||
|
const [aggCpm, setAggCpm] = useState<AggFunc>("avg");
|
||||||
|
|
||||||
|
const canWrite = hasPermission("placements_write");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadPlacements();
|
||||||
|
}, [workspaceId, projectFilterId, includeArchived]);
|
||||||
|
|
||||||
|
const loadPlacements = async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
const response = await demoAwarePlacementsApi.list(workspaceId, {
|
||||||
|
project_id: projectFilterId,
|
||||||
|
include_archived: includeArchived,
|
||||||
|
size: 100,
|
||||||
|
});
|
||||||
|
setPlacements(response.items);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to load placements:", err);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle sort
|
||||||
|
const handleSort = (field: SortField) => {
|
||||||
|
if (sortField === field) {
|
||||||
|
if (sortOrder === "asc") {
|
||||||
|
setSortOrder("desc");
|
||||||
|
} else if (sortOrder === "desc") {
|
||||||
|
setSortField(null);
|
||||||
|
setSortOrder(null);
|
||||||
|
} else {
|
||||||
|
setSortOrder("asc");
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setSortField(field);
|
||||||
|
setSortOrder("asc");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getSortIcon = (field: SortField) => {
|
||||||
|
if (sortField !== field) return <ArrowUpDown className="h-4 w-4 ml-1" />;
|
||||||
|
if (sortOrder === "asc") return <ArrowUp className="h-4 w-4 ml-1" />;
|
||||||
|
if (sortOrder === "desc") return <ArrowDown className="h-4 w-4 ml-1" />;
|
||||||
|
return <ArrowUpDown className="h-4 w-4 ml-1" />;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Filter and sort
|
||||||
|
const filteredPlacements = useMemo(() => {
|
||||||
|
return placements
|
||||||
|
.filter(
|
||||||
|
(p) =>
|
||||||
|
p.placement_channel_title.toLowerCase().includes(search.toLowerCase()) ||
|
||||||
|
p.creative_name.toLowerCase().includes(search.toLowerCase())
|
||||||
|
)
|
||||||
|
.sort((a, b) => {
|
||||||
|
if (!sortField || !sortOrder) return 0;
|
||||||
|
|
||||||
|
let aVal: number | string = 0;
|
||||||
|
let bVal: number | string = 0;
|
||||||
|
|
||||||
|
switch (sortField) {
|
||||||
|
case "date":
|
||||||
|
return sortOrder === "asc"
|
||||||
|
? new Date(a.placement_date).getTime() - new Date(b.placement_date).getTime()
|
||||||
|
: new Date(b.placement_date).getTime() - new Date(a.placement_date).getTime();
|
||||||
|
case "cost":
|
||||||
|
aVal = a.cost ?? 0;
|
||||||
|
bVal = b.cost ?? 0;
|
||||||
|
break;
|
||||||
|
case "subscriptions":
|
||||||
|
aVal = a.subscriptions_count;
|
||||||
|
bVal = b.subscriptions_count;
|
||||||
|
break;
|
||||||
|
case "views":
|
||||||
|
aVal = a.views_count ?? 0;
|
||||||
|
bVal = b.views_count ?? 0;
|
||||||
|
break;
|
||||||
|
case "cps":
|
||||||
|
aVal = calculateCPS(a.cost, a.subscriptions_count) ?? 0;
|
||||||
|
bVal = calculateCPS(b.cost, b.subscriptions_count) ?? 0;
|
||||||
|
break;
|
||||||
|
case "cpm":
|
||||||
|
aVal = calculateCPM(a.cost, a.views_count) ?? 0;
|
||||||
|
bVal = calculateCPM(b.cost, b.views_count) ?? 0;
|
||||||
|
break;
|
||||||
|
case "status":
|
||||||
|
aVal = a.status;
|
||||||
|
bVal = b.status;
|
||||||
|
return sortOrder === "asc"
|
||||||
|
? aVal.localeCompare(bVal)
|
||||||
|
: bVal.localeCompare(aVal);
|
||||||
|
}
|
||||||
|
|
||||||
|
return sortOrder === "asc"
|
||||||
|
? (aVal as number) - (bVal as number)
|
||||||
|
: (bVal as number) - (aVal as number);
|
||||||
|
});
|
||||||
|
}, [placements, search, sortField, sortOrder]);
|
||||||
|
|
||||||
|
// Calculate totals with aggregation functions
|
||||||
|
const totals = useMemo(() => {
|
||||||
|
const costs = filteredPlacements.map((p) => p.cost).filter((c): c is number => c !== null);
|
||||||
|
const subs = filteredPlacements.map((p) => p.subscriptions_count);
|
||||||
|
const views = filteredPlacements.map((p) => p.views_count).filter((v): v is number => v !== null);
|
||||||
|
const cpsValues = filteredPlacements
|
||||||
|
.map((p) => calculateCPS(p.cost, p.subscriptions_count))
|
||||||
|
.filter((c): c is number => c !== null);
|
||||||
|
const cpmValues = filteredPlacements
|
||||||
|
.map((p) => calculateCPM(p.cost, p.views_count))
|
||||||
|
.filter((c): c is number => c !== null);
|
||||||
|
|
||||||
|
return {
|
||||||
|
cost: aggregationFunctions[aggCost].fn(costs),
|
||||||
|
subscriptions: aggregationFunctions[aggSubs].fn(subs),
|
||||||
|
views: aggregationFunctions[aggViews].fn(views),
|
||||||
|
cps: aggregationFunctions[aggCps].fn(cpsValues),
|
||||||
|
cpm: aggregationFunctions[aggCpm].fn(cpmValues),
|
||||||
|
};
|
||||||
|
}, [filteredPlacements, aggCost, aggSubs, aggViews, aggCps, aggCpm]);
|
||||||
|
|
||||||
|
// Export handlers
|
||||||
|
const handleExport = useCallback(
|
||||||
|
(format: "csv" | "excel" | "txt") => {
|
||||||
|
const filename = `placements_${new Date().toISOString().split("T")[0]}`;
|
||||||
|
switch (format) {
|
||||||
|
case "csv":
|
||||||
|
exportToCSV(filteredPlacements, filename);
|
||||||
|
break;
|
||||||
|
case "excel":
|
||||||
|
exportToExcel(filteredPlacements, filename);
|
||||||
|
break;
|
||||||
|
case "txt":
|
||||||
|
exportToTxt(filteredPlacements, filename);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[filteredPlacements]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Aggregation selector component
|
||||||
|
const AggSelector = ({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
value: AggFunc;
|
||||||
|
onChange: (v: AggFunc) => void;
|
||||||
|
}) => (
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button variant="ghost" size="icon" className="h-5 w-5 ml-1 opacity-50 hover:opacity-100">
|
||||||
|
<span className="text-[10px] font-mono">f</span>
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end">
|
||||||
|
<DropdownMenuLabel>Функция</DropdownMenuLabel>
|
||||||
|
{(Object.keys(aggregationFunctions) as AggFunc[]).map((key) => (
|
||||||
|
<DropdownMenuItem
|
||||||
|
key={key}
|
||||||
|
onClick={() => onChange(key)}
|
||||||
|
className={value === key ? "bg-accent" : ""}
|
||||||
|
>
|
||||||
|
{aggregationFunctions[key].label}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
))}
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TooltipProvider>
|
||||||
|
<DashboardHeader
|
||||||
|
breadcrumbs={[
|
||||||
|
{ label: "Главная", href: `/dashboard/${workspaceId}` },
|
||||||
|
{ label: "Размещения" },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex flex-1 flex-col gap-4 p-4 pt-0 mt-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold tracking-tight">Размещения</h1>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
{filteredPlacements.length} размещений
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{/* Export Button */}
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button variant="outline">
|
||||||
|
<Download className="h-4 w-4 mr-2" />
|
||||||
|
Экспорт
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end">
|
||||||
|
<DropdownMenuItem onClick={() => handleExport("excel")}>
|
||||||
|
<FileSpreadsheet className="h-4 w-4 mr-2" />
|
||||||
|
Excel (.xls)
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem onClick={() => handleExport("csv")}>
|
||||||
|
<FileText className="h-4 w-4 mr-2" />
|
||||||
|
CSV
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem onClick={() => handleExport("txt")}>
|
||||||
|
<FileText className="h-4 w-4 mr-2" />
|
||||||
|
Текст (.txt)
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
{canWrite && (
|
||||||
|
<Button asChild>
|
||||||
|
<Link href={`/dashboard/${workspaceId}/placements/new`}>
|
||||||
|
<Plus className="h-4 w-4 mr-2" />
|
||||||
|
Создать размещение
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="relative flex-1 max-w-sm">
|
||||||
|
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
placeholder="Поиск по каналу или креативу..."
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
className="pl-10"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Checkbox
|
||||||
|
id="include-archived"
|
||||||
|
checked={includeArchived}
|
||||||
|
onCheckedChange={(checked) => setIncludeArchived(checked === true)}
|
||||||
|
/>
|
||||||
|
<Label htmlFor="include-archived" className="text-sm cursor-pointer">
|
||||||
|
Показать архивные
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex items-center justify-center py-12">
|
||||||
|
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
) : filteredPlacements.length === 0 ? (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="flex flex-col items-center justify-center py-12">
|
||||||
|
<ShoppingCart className="h-12 w-12 text-muted-foreground mb-4" />
|
||||||
|
<h3 className="text-lg font-semibold mb-2">Нет размещений</h3>
|
||||||
|
<p className="text-muted-foreground text-center mb-4">
|
||||||
|
{search ? "Ничего не найдено по вашему запросу" : "Создайте первое размещение"}
|
||||||
|
</p>
|
||||||
|
{canWrite && !search && (
|
||||||
|
<Button asChild>
|
||||||
|
<Link href={`/dashboard/${workspaceId}/placements/new`}>
|
||||||
|
<Plus className="h-4 w-4 mr-2" />
|
||||||
|
Создать размещение
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
) : (
|
||||||
|
<Card>
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>Канал</TableHead>
|
||||||
|
<TableHead>Креатив</TableHead>
|
||||||
|
<TableHead>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="-ml-3 h-8"
|
||||||
|
onClick={() => handleSort("date")}
|
||||||
|
>
|
||||||
|
Дата
|
||||||
|
{getSortIcon("date")}
|
||||||
|
</Button>
|
||||||
|
</TableHead>
|
||||||
|
<TableHead>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="-ml-3 h-8"
|
||||||
|
onClick={() => handleSort("cost")}
|
||||||
|
>
|
||||||
|
Стоимость
|
||||||
|
{getSortIcon("cost")}
|
||||||
|
</Button>
|
||||||
|
</TableHead>
|
||||||
|
<TableHead>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="-ml-3 h-8"
|
||||||
|
onClick={() => handleSort("subscriptions")}
|
||||||
|
>
|
||||||
|
Подписки
|
||||||
|
{getSortIcon("subscriptions")}
|
||||||
|
</Button>
|
||||||
|
</TableHead>
|
||||||
|
<TableHead>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="-ml-3 h-8"
|
||||||
|
onClick={() => handleSort("views")}
|
||||||
|
>
|
||||||
|
Просмотры
|
||||||
|
{getSortIcon("views")}
|
||||||
|
</Button>
|
||||||
|
</TableHead>
|
||||||
|
<TableHead>
|
||||||
|
<div className="flex items-center">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="-ml-3 h-8"
|
||||||
|
onClick={() => handleSort("cps")}
|
||||||
|
>
|
||||||
|
CPS
|
||||||
|
{getSortIcon("cps")}
|
||||||
|
</Button>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Info className="h-3.5 w-3.5 text-muted-foreground cursor-help" />
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>Cost Per Subscription</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
</TableHead>
|
||||||
|
<TableHead>
|
||||||
|
<div className="flex items-center">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="-ml-3 h-8"
|
||||||
|
onClick={() => handleSort("cpm")}
|
||||||
|
>
|
||||||
|
CPM
|
||||||
|
{getSortIcon("cpm")}
|
||||||
|
</Button>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Info className="h-3.5 w-3.5 text-muted-foreground cursor-help" />
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>Cost Per Mille (1000 views)</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
</TableHead>
|
||||||
|
<TableHead>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="-ml-3 h-8"
|
||||||
|
onClick={() => handleSort("status")}
|
||||||
|
>
|
||||||
|
Статус
|
||||||
|
{getSortIcon("status")}
|
||||||
|
</Button>
|
||||||
|
</TableHead>
|
||||||
|
<TableHead className="w-[50px]"></TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{filteredPlacements.map((placement) => {
|
||||||
|
const cps = calculateCPS(placement.cost, placement.subscriptions_count);
|
||||||
|
const cpm = calculateCPM(placement.cost, placement.views_count);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TableRow key={placement.id}>
|
||||||
|
<TableCell>
|
||||||
|
<div className="font-medium">{placement.placement_channel_title}</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div className="text-sm">{placement.creative_name}</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>{formatDate(placement.placement_date)}</TableCell>
|
||||||
|
<TableCell>{formatCurrency(placement.cost)}</TableCell>
|
||||||
|
<TableCell>{formatNumber(placement.subscriptions_count)}</TableCell>
|
||||||
|
<TableCell>{formatNumber(placement.views_count ?? null)}</TableCell>
|
||||||
|
<TableCell>{cps ? formatCurrency(cps) : "—"}</TableCell>
|
||||||
|
<TableCell>{cpm ? formatCurrency(cpm) : "—"}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge
|
||||||
|
variant={placement.status === "active" ? "default" : "secondary"}
|
||||||
|
>
|
||||||
|
{placement.status === "active" ? "Активен" : "Архив"}
|
||||||
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button variant="ghost" size="icon">
|
||||||
|
<MoreHorizontal className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end">
|
||||||
|
<DropdownMenuItem asChild>
|
||||||
|
<Link href={`/dashboard/${workspaceId}/placements/${placement.id}`}>
|
||||||
|
<Eye className="h-4 w-4 mr-2" />
|
||||||
|
Подробнее
|
||||||
|
</Link>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
{placement.ad_post_url && (
|
||||||
|
<DropdownMenuItem asChild>
|
||||||
|
<a
|
||||||
|
href={placement.ad_post_url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
>
|
||||||
|
<ExternalLink className="h-4 w-4 mr-2" />
|
||||||
|
Открыть пост
|
||||||
|
</a>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
)}
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</TableBody>
|
||||||
|
{/* Totals Row */}
|
||||||
|
<TableFooter>
|
||||||
|
<TableRow className="bg-muted/50 font-medium">
|
||||||
|
<TableCell colSpan={3}>
|
||||||
|
<span className="text-muted-foreground">Итого</span>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div className="flex items-center">
|
||||||
|
{formatCurrency(totals.cost)}
|
||||||
|
<AggSelector value={aggCost} onChange={setAggCost} />
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div className="flex items-center">
|
||||||
|
{formatNumber(totals.subscriptions)}
|
||||||
|
<AggSelector value={aggSubs} onChange={setAggSubs} />
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div className="flex items-center">
|
||||||
|
{formatNumber(totals.views)}
|
||||||
|
<AggSelector value={aggViews} onChange={setAggViews} />
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div className="flex items-center">
|
||||||
|
{totals.cps ? formatCurrency(totals.cps) : "—"}
|
||||||
|
<AggSelector value={aggCps} onChange={setAggCps} />
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div className="flex items-center">
|
||||||
|
{totals.cpm ? formatCurrency(totals.cpm) : "—"}
|
||||||
|
<AggSelector value={aggCpm} onChange={setAggCpm} />
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell colSpan={2}></TableCell>
|
||||||
|
</TableRow>
|
||||||
|
</TableFooter>
|
||||||
|
</Table>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</TooltipProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
458
app/dashboard/[workspaceId]/projects/page.tsx
Normal file
458
app/dashboard/[workspaceId]/projects/page.tsx
Normal file
@@ -0,0 +1,458 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Projects Page (Target Channels) - Enhanced with Statistics
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useParams } from "next/navigation";
|
||||||
|
import Link from "next/link";
|
||||||
|
import {
|
||||||
|
Loader2,
|
||||||
|
Tv,
|
||||||
|
ExternalLink,
|
||||||
|
Grid,
|
||||||
|
List,
|
||||||
|
TrendingUp,
|
||||||
|
Users,
|
||||||
|
Eye,
|
||||||
|
ShoppingCart,
|
||||||
|
Info,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { DashboardHeader } from "@/components/layout/dashboard-header";
|
||||||
|
import { useWorkspace } from "@/components/providers/workspace-provider";
|
||||||
|
import { demoAwareAnalyticsApi } from "@/lib/demo";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "@/components/ui/card";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from "@/components/ui/table";
|
||||||
|
import {
|
||||||
|
Tooltip,
|
||||||
|
TooltipContent,
|
||||||
|
TooltipProvider,
|
||||||
|
TooltipTrigger,
|
||||||
|
} from "@/components/ui/tooltip";
|
||||||
|
import { BOT_USERNAME } from "@/lib/utils/constants";
|
||||||
|
import type { Project, SpendingAnalytics } from "@/lib/types/api";
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Types
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
interface ProjectWithStats extends Project {
|
||||||
|
stats?: {
|
||||||
|
total_cost: number;
|
||||||
|
total_subscriptions: number;
|
||||||
|
total_views: number;
|
||||||
|
avg_cps: number | null;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Helpers
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
const formatNumber = (value: number | null | undefined): string => {
|
||||||
|
if (value === null || value === undefined) return "—";
|
||||||
|
return new Intl.NumberFormat("ru-RU").format(value);
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatCurrency = (value: number | null | undefined): string => {
|
||||||
|
if (value === null || value === undefined) return "—";
|
||||||
|
return new Intl.NumberFormat("ru-RU", {
|
||||||
|
style: "currency",
|
||||||
|
currency: "RUB",
|
||||||
|
minimumFractionDigits: 0,
|
||||||
|
maximumFractionDigits: 0,
|
||||||
|
}).format(value);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Component
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export default function ProjectsPage() {
|
||||||
|
const params = useParams();
|
||||||
|
const workspaceId = params?.workspaceId as string;
|
||||||
|
const { projects, isLoadingProjects, currentWorkspace } = useWorkspace();
|
||||||
|
|
||||||
|
const [projectsWithStats, setProjectsWithStats] = useState<ProjectWithStats[]>([]);
|
||||||
|
const [loadingStats, setLoadingStats] = useState(false);
|
||||||
|
const [viewMode, setViewMode] = useState<"grid" | "table">("grid");
|
||||||
|
|
||||||
|
// Load statistics for each project
|
||||||
|
useEffect(() => {
|
||||||
|
if (projects.length === 0) {
|
||||||
|
setProjectsWithStats([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadStats = async () => {
|
||||||
|
setLoadingStats(true);
|
||||||
|
try {
|
||||||
|
const statsPromises = projects.map(async (project) => {
|
||||||
|
try {
|
||||||
|
const spending = await demoAwareAnalyticsApi.spending(workspaceId, {
|
||||||
|
project_id: project.id,
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
...project,
|
||||||
|
stats: {
|
||||||
|
total_cost: spending.total_cost,
|
||||||
|
total_subscriptions: spending.total_subscriptions,
|
||||||
|
total_views: spending.total_views,
|
||||||
|
avg_cps:
|
||||||
|
spending.total_subscriptions > 0
|
||||||
|
? spending.total_cost / spending.total_subscriptions
|
||||||
|
: null,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return { ...project, stats: undefined };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const results = await Promise.all(statsPromises);
|
||||||
|
setProjectsWithStats(results);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to load project stats:", err);
|
||||||
|
setProjectsWithStats(projects);
|
||||||
|
} finally {
|
||||||
|
setLoadingStats(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
loadStats();
|
||||||
|
}, [projects, workspaceId]);
|
||||||
|
|
||||||
|
const isLoading = isLoadingProjects || loadingStats;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TooltipProvider>
|
||||||
|
<DashboardHeader
|
||||||
|
breadcrumbs={[
|
||||||
|
{ label: "Главная", href: `/dashboard/${currentWorkspace?.id}` },
|
||||||
|
{ label: "Проекты" },
|
||||||
|
]}
|
||||||
|
showProjectSelector={false}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex flex-1 flex-col gap-4 p-4 pt-0 mt-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold tracking-tight">Проекты</h1>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
Telegram каналы, которые вы продвигаете
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 border rounded-lg p-1">
|
||||||
|
<Button
|
||||||
|
variant={viewMode === "grid" ? "secondary" : "ghost"}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setViewMode("grid")}
|
||||||
|
aria-label="Плиточный вид"
|
||||||
|
>
|
||||||
|
<Grid className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant={viewMode === "table" ? "secondary" : "ghost"}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setViewMode("table")}
|
||||||
|
aria-label="Табличный вид"
|
||||||
|
>
|
||||||
|
<List className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Alert>
|
||||||
|
<Tv className="h-4 w-4" />
|
||||||
|
<AlertDescription>
|
||||||
|
Чтобы добавить новый проект, добавьте бота{" "}
|
||||||
|
<a
|
||||||
|
href={`https://t.me/${BOT_USERNAME}`}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="font-mono text-primary hover:underline inline-flex items-center gap-1"
|
||||||
|
>
|
||||||
|
@{BOT_USERNAME}
|
||||||
|
<ExternalLink className="h-3 w-3" />
|
||||||
|
</a>{" "}
|
||||||
|
администратором в ваш Telegram канал.
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="flex items-center justify-center py-12">
|
||||||
|
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
) : projectsWithStats.length === 0 ? (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="flex flex-col items-center justify-center py-12">
|
||||||
|
<Tv className="h-12 w-12 text-muted-foreground mb-4" />
|
||||||
|
<h3 className="text-lg font-semibold mb-2">Нет проектов</h3>
|
||||||
|
<p className="text-muted-foreground text-center max-w-md">
|
||||||
|
Добавьте бота администратором в ваш Telegram канал, и он
|
||||||
|
автоматически появится здесь как проект.
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
) : viewMode === "table" ? (
|
||||||
|
// Table View
|
||||||
|
<Card>
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>Канал</TableHead>
|
||||||
|
<TableHead>Статус</TableHead>
|
||||||
|
<TableHead className="text-right">
|
||||||
|
<div className="flex items-center justify-end gap-1">
|
||||||
|
Потрачено
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Info className="h-3.5 w-3.5 text-muted-foreground cursor-help" />
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
Сумма всех размещений по проекту
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
</TableHead>
|
||||||
|
<TableHead className="text-right">
|
||||||
|
<div className="flex items-center justify-end gap-1">
|
||||||
|
Подписчики
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Info className="h-3.5 w-3.5 text-muted-foreground cursor-help" />
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
Привлеченные подписчики по пригласительным ссылкам
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
</TableHead>
|
||||||
|
<TableHead className="text-right">
|
||||||
|
<div className="flex items-center justify-end gap-1">
|
||||||
|
Просмотры
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Info className="h-3.5 w-3.5 text-muted-foreground cursor-help" />
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
Суммарные просмотры рекламных постов
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
</TableHead>
|
||||||
|
<TableHead className="text-right">
|
||||||
|
<div className="flex items-center justify-end gap-1">
|
||||||
|
Сред. CPS
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Info className="h-3.5 w-3.5 text-muted-foreground cursor-help" />
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
Cost Per Subscription — средняя стоимость подписчика
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
</TableHead>
|
||||||
|
<TableHead className="w-[140px]"></TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{projectsWithStats.map((project) => (
|
||||||
|
<TableRow key={project.id}>
|
||||||
|
<TableCell>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="flex h-9 w-9 items-center justify-center rounded-lg bg-primary/10">
|
||||||
|
<Tv className="h-4 w-4 text-primary" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="font-medium">{project.title}</div>
|
||||||
|
{project.username && (
|
||||||
|
<a
|
||||||
|
href={`https://t.me/${project.username}`}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="text-xs text-muted-foreground hover:underline inline-flex items-center gap-0.5"
|
||||||
|
>
|
||||||
|
@{project.username}
|
||||||
|
<ExternalLink className="h-2.5 w-2.5" />
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge
|
||||||
|
variant={project.status === "active" ? "default" : "secondary"}
|
||||||
|
>
|
||||||
|
{project.status === "active"
|
||||||
|
? "Активен"
|
||||||
|
: project.status === "inactive"
|
||||||
|
? "Неактивен"
|
||||||
|
: "Архив"}
|
||||||
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right font-medium">
|
||||||
|
{formatCurrency(project.stats?.total_cost)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right">
|
||||||
|
{formatNumber(project.stats?.total_subscriptions)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right">
|
||||||
|
{formatNumber(project.stats?.total_views)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right">
|
||||||
|
{formatCurrency(project.stats?.avg_cps)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Button variant="outline" size="sm" asChild>
|
||||||
|
<Link
|
||||||
|
href={`/dashboard/${currentWorkspace?.id}/projects/${project.id}/purchase-plan`}
|
||||||
|
>
|
||||||
|
План закупа
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</Card>
|
||||||
|
) : (
|
||||||
|
// Grid View
|
||||||
|
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||||
|
{projectsWithStats.map((project) => (
|
||||||
|
<Card key={project.id}>
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-primary/10">
|
||||||
|
<Tv className="h-5 w-5 text-primary" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<CardTitle className="text-base">{project.title}</CardTitle>
|
||||||
|
{project.username && (
|
||||||
|
<CardDescription>
|
||||||
|
<a
|
||||||
|
href={`https://t.me/${project.username}`}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="hover:underline inline-flex items-center gap-0.5"
|
||||||
|
>
|
||||||
|
@{project.username}
|
||||||
|
<ExternalLink className="h-2.5 w-2.5" />
|
||||||
|
</a>
|
||||||
|
</CardDescription>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Badge
|
||||||
|
variant={project.status === "active" ? "default" : "secondary"}
|
||||||
|
>
|
||||||
|
{project.status === "active"
|
||||||
|
? "Активен"
|
||||||
|
: project.status === "inactive"
|
||||||
|
? "Неактивен"
|
||||||
|
: "Архив"}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
{/* Stats Grid */}
|
||||||
|
{project.stats && (
|
||||||
|
<div className="grid grid-cols-2 gap-3 text-sm">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<ShoppingCart className="h-4 w-4 text-muted-foreground" />
|
||||||
|
<div>
|
||||||
|
<div className="font-medium">
|
||||||
|
{formatCurrency(project.stats.total_cost)}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-muted-foreground">
|
||||||
|
Потрачено
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Users className="h-4 w-4 text-muted-foreground" />
|
||||||
|
<div>
|
||||||
|
<div className="font-medium">
|
||||||
|
{formatNumber(project.stats.total_subscriptions)}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-muted-foreground">
|
||||||
|
Подписчиков
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Eye className="h-4 w-4 text-muted-foreground" />
|
||||||
|
<div>
|
||||||
|
<div className="font-medium">
|
||||||
|
{formatNumber(project.stats.total_views)}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-muted-foreground">
|
||||||
|
Просмотров
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<TrendingUp className="h-4 w-4 text-muted-foreground" />
|
||||||
|
<div>
|
||||||
|
<div className="font-medium">
|
||||||
|
{formatCurrency(project.stats.avg_cps)}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-muted-foreground">
|
||||||
|
Сред. CPS
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{project.username && (
|
||||||
|
<Button variant="outline" size="sm" asChild>
|
||||||
|
<a
|
||||||
|
href={`https://t.me/${project.username}`}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
>
|
||||||
|
<ExternalLink className="h-4 w-4 mr-1" />
|
||||||
|
Канал
|
||||||
|
</a>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button variant="outline" size="sm" asChild>
|
||||||
|
<Link
|
||||||
|
href={`/dashboard/${currentWorkspace?.id}/projects/${project.id}/purchase-plan`}
|
||||||
|
>
|
||||||
|
План закупа
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</TooltipProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
742
app/dashboard/[workspaceId]/purchase-plans/[projectId]/page.tsx
Normal file
742
app/dashboard/[workspaceId]/purchase-plans/[projectId]/page.tsx
Normal file
@@ -0,0 +1,742 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Purchase Plan Detail Page
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
import { useEffect, useState, useMemo } from "react";
|
||||||
|
import { useParams, useRouter } from "next/navigation";
|
||||||
|
import Link from "next/link";
|
||||||
|
import {
|
||||||
|
Loader2,
|
||||||
|
Plus,
|
||||||
|
ArrowLeft,
|
||||||
|
MoreHorizontal,
|
||||||
|
Pencil,
|
||||||
|
Trash2,
|
||||||
|
ExternalLink,
|
||||||
|
Clock,
|
||||||
|
CheckCircle,
|
||||||
|
XCircle,
|
||||||
|
ArrowUpDown,
|
||||||
|
ShoppingCart,
|
||||||
|
RefreshCw,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { DashboardHeader } from "@/components/layout/dashboard-header";
|
||||||
|
import { useWorkspace } from "@/components/providers/workspace-provider";
|
||||||
|
import { purchasePlanApi, channelsApi } from "@/lib/api";
|
||||||
|
import { demoAwarePurchasePlanApi, isDemoWorkspace, demoChannels } from "@/lib/demo";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "@/components/ui/card";
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
TableFooter,
|
||||||
|
} from "@/components/ui/table";
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from "@/components/ui/dropdown-menu";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
} from "@/components/ui/alert-dialog";
|
||||||
|
import type { Project, PurchasePlanChannel, Channel } from "@/lib/types/api";
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Helpers
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
const formatCurrency = (value: number | null) => {
|
||||||
|
if (value === null) return "—";
|
||||||
|
return new Intl.NumberFormat("ru-RU", {
|
||||||
|
style: "currency",
|
||||||
|
currency: "RUB",
|
||||||
|
minimumFractionDigits: 0,
|
||||||
|
maximumFractionDigits: 0,
|
||||||
|
}).format(value);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStatusBadge = (status: string) => {
|
||||||
|
switch (status) {
|
||||||
|
case "planned":
|
||||||
|
return (
|
||||||
|
<Badge variant="outline" className="gap-1">
|
||||||
|
<Clock className="h-3 w-3" />
|
||||||
|
Запланировано
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
case "completed":
|
||||||
|
return (
|
||||||
|
<Badge variant="default" className="gap-1">
|
||||||
|
<CheckCircle className="h-3 w-3" />
|
||||||
|
Завершено
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
case "cancelled":
|
||||||
|
return (
|
||||||
|
<Badge variant="secondary" className="gap-1">
|
||||||
|
<XCircle className="h-3 w-3" />
|
||||||
|
Отменено
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
default:
|
||||||
|
return <Badge variant="outline">{status}</Badge>;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
type SortField = "channel" | "status" | "cost";
|
||||||
|
type SortOrder = "asc" | "desc" | null;
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Component
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export default function PurchasePlanDetailPage() {
|
||||||
|
const params = useParams();
|
||||||
|
const router = useRouter();
|
||||||
|
const workspaceId = params?.workspaceId as string;
|
||||||
|
const projectId = params?.projectId as string;
|
||||||
|
const { projects, hasPermission } = useWorkspace();
|
||||||
|
|
||||||
|
const [planChannels, setPlanChannels] = useState<PurchasePlanChannel[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [project, setProject] = useState<Project | null>(null);
|
||||||
|
|
||||||
|
// Add channel dialog
|
||||||
|
const [showAddDialog, setShowAddDialog] = useState(false);
|
||||||
|
const [availableChannels, setAvailableChannels] = useState<Channel[]>([]);
|
||||||
|
const [loadingChannels, setLoadingChannels] = useState(false);
|
||||||
|
const [selectedChannelId, setSelectedChannelId] = useState("");
|
||||||
|
const [plannedCost, setPlannedCost] = useState("");
|
||||||
|
const [comment, setComment] = useState("");
|
||||||
|
const [isAdding, setIsAdding] = useState(false);
|
||||||
|
|
||||||
|
// Edit dialog
|
||||||
|
const [editingChannel, setEditingChannel] = useState<PurchasePlanChannel | null>(null);
|
||||||
|
const [editPlannedCost, setEditPlannedCost] = useState("");
|
||||||
|
const [editComment, setEditComment] = useState("");
|
||||||
|
const [isUpdating, setIsUpdating] = useState(false);
|
||||||
|
|
||||||
|
// Delete confirmation
|
||||||
|
const [deletingChannel, setDeletingChannel] = useState<PurchasePlanChannel | null>(null);
|
||||||
|
|
||||||
|
// Sorting
|
||||||
|
const [sortField, setSortField] = useState<SortField | null>(null);
|
||||||
|
const [sortOrder, setSortOrder] = useState<SortOrder>(null);
|
||||||
|
|
||||||
|
const canWrite = hasPermission("placements_write");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const proj = projects.find((p) => p.id === projectId);
|
||||||
|
if (proj) {
|
||||||
|
setProject(proj);
|
||||||
|
}
|
||||||
|
}, [projects, projectId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadPlanChannels();
|
||||||
|
}, [workspaceId, projectId]);
|
||||||
|
|
||||||
|
const loadPlanChannels = async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
const response = await demoAwarePurchasePlanApi.list(workspaceId, projectId, { size: 100 });
|
||||||
|
setPlanChannels(response.items);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to load purchase plan:", err);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadAvailableChannels = async () => {
|
||||||
|
try {
|
||||||
|
setLoadingChannels(true);
|
||||||
|
// In demo mode use mock channels
|
||||||
|
const isDemo = isDemoWorkspace(workspaceId);
|
||||||
|
const response = isDemo
|
||||||
|
? { items: demoChannels, total: demoChannels.length, page: 1, size: 100, pages: 1 }
|
||||||
|
: await channelsApi.list({ size: 100 });
|
||||||
|
// Filter out channels already in plan
|
||||||
|
const existingIds = new Set(planChannels.map((pc) => pc.channel.id));
|
||||||
|
setAvailableChannels(response.items.filter((c) => !existingIds.has(c.id)));
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to load channels:", err);
|
||||||
|
} finally {
|
||||||
|
setLoadingChannels(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleOpenAddDialog = () => {
|
||||||
|
loadAvailableChannels();
|
||||||
|
setSelectedChannelId("");
|
||||||
|
setPlannedCost("");
|
||||||
|
setComment("");
|
||||||
|
setShowAddDialog(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAddChannel = async () => {
|
||||||
|
if (!selectedChannelId) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
setIsAdding(true);
|
||||||
|
await purchasePlanApi.create(workspaceId, projectId, {
|
||||||
|
channel_id: selectedChannelId,
|
||||||
|
planned_cost: plannedCost ? parseFloat(plannedCost) : undefined,
|
||||||
|
comment: comment || undefined,
|
||||||
|
});
|
||||||
|
setShowAddDialog(false);
|
||||||
|
loadPlanChannels();
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to add channel:", err);
|
||||||
|
} finally {
|
||||||
|
setIsAdding(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleOpenEditDialog = (channel: PurchasePlanChannel) => {
|
||||||
|
setEditingChannel(channel);
|
||||||
|
setEditPlannedCost(channel.planned_cost?.toString() || "");
|
||||||
|
setEditComment(channel.comment || "");
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleUpdateChannel = async () => {
|
||||||
|
if (!editingChannel) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
setIsUpdating(true);
|
||||||
|
await purchasePlanApi.update(workspaceId, projectId, editingChannel.id, {
|
||||||
|
planned_cost: editPlannedCost ? parseFloat(editPlannedCost) : undefined,
|
||||||
|
comment: editComment || undefined,
|
||||||
|
});
|
||||||
|
setEditingChannel(null);
|
||||||
|
loadPlanChannels();
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to update channel:", err);
|
||||||
|
} finally {
|
||||||
|
setIsUpdating(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteChannel = async () => {
|
||||||
|
if (!deletingChannel) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await purchasePlanApi.delete(workspaceId, projectId, deletingChannel.id);
|
||||||
|
setDeletingChannel(null);
|
||||||
|
loadPlanChannels();
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to delete channel:", err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Sorting
|
||||||
|
const handleSort = (field: SortField) => {
|
||||||
|
if (sortField === field) {
|
||||||
|
if (sortOrder === "asc") setSortOrder("desc");
|
||||||
|
else if (sortOrder === "desc") {
|
||||||
|
setSortField(null);
|
||||||
|
setSortOrder(null);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setSortField(field);
|
||||||
|
setSortOrder("asc");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const sortedChannels = useMemo(() => {
|
||||||
|
if (!sortField || !sortOrder) return planChannels;
|
||||||
|
|
||||||
|
return [...planChannels].sort((a, b) => {
|
||||||
|
let aVal: string | number = 0;
|
||||||
|
let bVal: string | number = 0;
|
||||||
|
|
||||||
|
switch (sortField) {
|
||||||
|
case "channel":
|
||||||
|
aVal = a.channel.title.toLowerCase();
|
||||||
|
bVal = b.channel.title.toLowerCase();
|
||||||
|
break;
|
||||||
|
case "status":
|
||||||
|
aVal = a.status;
|
||||||
|
bVal = b.status;
|
||||||
|
break;
|
||||||
|
case "cost":
|
||||||
|
aVal = a.planned_cost ?? 0;
|
||||||
|
bVal = b.planned_cost ?? 0;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof aVal === "string") {
|
||||||
|
return sortOrder === "asc"
|
||||||
|
? aVal.localeCompare(bVal as string)
|
||||||
|
: (bVal as string).localeCompare(aVal);
|
||||||
|
}
|
||||||
|
return sortOrder === "asc" ? aVal - (bVal as number) : (bVal as number) - aVal;
|
||||||
|
});
|
||||||
|
}, [planChannels, sortField, sortOrder]);
|
||||||
|
|
||||||
|
// Stats
|
||||||
|
const stats = useMemo(() => {
|
||||||
|
const planned = planChannels.filter((c) => c.status === "planned");
|
||||||
|
return {
|
||||||
|
totalChannels: planChannels.length,
|
||||||
|
plannedCount: planned.length,
|
||||||
|
completedCount: planChannels.filter((c) => c.status === "completed").length,
|
||||||
|
totalPlannedCost: planned.reduce((sum, c) => sum + (c.planned_cost || 0), 0),
|
||||||
|
};
|
||||||
|
}, [planChannels]);
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<DashboardHeader breadcrumbs={[{ label: "Загрузка..." }]} showProjectSelector={false} />
|
||||||
|
<div className="flex items-center justify-center py-12">
|
||||||
|
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<DashboardHeader
|
||||||
|
breadcrumbs={[
|
||||||
|
{ label: "Главная", href: `/dashboard/${workspaceId}` },
|
||||||
|
{ label: "Планы закупок", href: `/dashboard/${workspaceId}/purchase-plans` },
|
||||||
|
{ label: project?.title || "План закупок" },
|
||||||
|
]}
|
||||||
|
showProjectSelector={false}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex flex-1 flex-col gap-4 p-4 pt-0 mt-4">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => router.push(`/dashboard/${workspaceId}/purchase-plans`)}
|
||||||
|
>
|
||||||
|
<ArrowLeft className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold tracking-tight">
|
||||||
|
План закупок: {project?.title}
|
||||||
|
</h1>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
{stats.totalChannels} каналов • Планируемый бюджет:{" "}
|
||||||
|
{formatCurrency(stats.totalPlannedCost)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{canWrite && (
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button variant="outline" onClick={loadPlanChannels}>
|
||||||
|
<RefreshCw className="h-4 w-4 mr-2" />
|
||||||
|
Обновить
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleOpenAddDialog}>
|
||||||
|
<Plus className="h-4 w-4 mr-2" />
|
||||||
|
Добавить канал
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Stats Cards */}
|
||||||
|
<div className="grid gap-4 md:grid-cols-4">
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium text-muted-foreground">
|
||||||
|
Всего каналов
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-2xl font-bold">{stats.totalChannels}</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium text-muted-foreground">
|
||||||
|
Запланировано
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-2xl font-bold">{stats.plannedCount}</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium text-muted-foreground">
|
||||||
|
Завершено
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-2xl font-bold">{stats.completedCount}</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium text-muted-foreground">
|
||||||
|
Планируемый бюджет
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-2xl font-bold">
|
||||||
|
{formatCurrency(stats.totalPlannedCost)}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Table */}
|
||||||
|
{planChannels.length === 0 ? (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="flex flex-col items-center justify-center py-12">
|
||||||
|
<ShoppingCart className="h-12 w-12 text-muted-foreground mb-4" />
|
||||||
|
<h3 className="text-lg font-semibold mb-2">План пуст</h3>
|
||||||
|
<p className="text-muted-foreground text-center mb-4">
|
||||||
|
Добавьте каналы для размещения рекламы
|
||||||
|
</p>
|
||||||
|
{canWrite && (
|
||||||
|
<Button onClick={handleOpenAddDialog}>
|
||||||
|
<Plus className="h-4 w-4 mr-2" />
|
||||||
|
Добавить канал
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
) : (
|
||||||
|
<Card>
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="-ml-3 h-8"
|
||||||
|
onClick={() => handleSort("channel")}
|
||||||
|
>
|
||||||
|
Канал
|
||||||
|
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</TableHead>
|
||||||
|
<TableHead>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="-ml-3 h-8"
|
||||||
|
onClick={() => handleSort("status")}
|
||||||
|
>
|
||||||
|
Статус
|
||||||
|
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</TableHead>
|
||||||
|
<TableHead className="text-right">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="-ml-3 h-8"
|
||||||
|
onClick={() => handleSort("cost")}
|
||||||
|
>
|
||||||
|
План. стоимость
|
||||||
|
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</TableHead>
|
||||||
|
<TableHead>Комментарий</TableHead>
|
||||||
|
<TableHead className="w-[100px]"></TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{sortedChannels.map((planChannel) => (
|
||||||
|
<TableRow key={planChannel.id}>
|
||||||
|
<TableCell>
|
||||||
|
<div>
|
||||||
|
<div className="font-medium">{planChannel.channel.title}</div>
|
||||||
|
{planChannel.channel.username && (
|
||||||
|
<a
|
||||||
|
href={`https://t.me/${planChannel.channel.username}`}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="text-xs text-muted-foreground hover:text-primary flex items-center gap-1"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
@{planChannel.channel.username}
|
||||||
|
<ExternalLink className="h-3 w-3" />
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>{getStatusBadge(planChannel.status)}</TableCell>
|
||||||
|
<TableCell className="text-right">
|
||||||
|
{formatCurrency(planChannel.planned_cost)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="max-w-[200px] truncate">
|
||||||
|
{planChannel.comment || "—"}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button variant="ghost" size="icon">
|
||||||
|
<MoreHorizontal className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end">
|
||||||
|
<DropdownMenuItem asChild>
|
||||||
|
<Link
|
||||||
|
href={`/dashboard/${workspaceId}/placements/new?channel_id=${planChannel.channel.id}`}
|
||||||
|
>
|
||||||
|
<ShoppingCart className="h-4 w-4 mr-2" />
|
||||||
|
Создать размещение
|
||||||
|
</Link>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
{canWrite && (
|
||||||
|
<>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() => handleOpenEditDialog(planChannel)}
|
||||||
|
>
|
||||||
|
<Pencil className="h-4 w-4 mr-2" />
|
||||||
|
Редактировать
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() => setDeletingChannel(planChannel)}
|
||||||
|
className="text-destructive"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4 mr-2" />
|
||||||
|
Удалить
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
<TableFooter>
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={2} className="font-bold">
|
||||||
|
Итого запланировано:
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right font-bold">
|
||||||
|
{formatCurrency(stats.totalPlannedCost)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell colSpan={2}></TableCell>
|
||||||
|
</TableRow>
|
||||||
|
</TableFooter>
|
||||||
|
</Table>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Add Channel Dialog */}
|
||||||
|
<Dialog open={showAddDialog} onOpenChange={setShowAddDialog}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Добавить канал в план</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Выберите канал из каталога для добавления в план закупок
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="space-y-4 py-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Канал</Label>
|
||||||
|
{loadingChannels ? (
|
||||||
|
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
Загрузка каналов...
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Select value={selectedChannelId} onValueChange={setSelectedChannelId}>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Выберите канал" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{availableChannels.length === 0 ? (
|
||||||
|
<div className="p-2 text-sm text-muted-foreground text-center">
|
||||||
|
Все каналы уже добавлены
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
availableChannels.map((channel) => (
|
||||||
|
<SelectItem key={channel.id} value={channel.id}>
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span>{channel.title}</span>
|
||||||
|
{channel.username && (
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
@{channel.username}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</SelectItem>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="planned-cost">Планируемая стоимость (₽)</Label>
|
||||||
|
<Input
|
||||||
|
id="planned-cost"
|
||||||
|
type="number"
|
||||||
|
placeholder="0"
|
||||||
|
value={plannedCost}
|
||||||
|
onChange={(e) => setPlannedCost(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="comment">Комментарий</Label>
|
||||||
|
<Textarea
|
||||||
|
id="comment"
|
||||||
|
placeholder="Дополнительная информация..."
|
||||||
|
value={comment}
|
||||||
|
onChange={(e) => setComment(e.target.value)}
|
||||||
|
rows={2}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => setShowAddDialog(false)}>
|
||||||
|
Отмена
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={handleAddChannel}
|
||||||
|
disabled={!selectedChannelId || isAdding}
|
||||||
|
>
|
||||||
|
{isAdding ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||||
|
Добавление...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
"Добавить"
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
{/* Edit Dialog */}
|
||||||
|
<Dialog open={!!editingChannel} onOpenChange={(open) => !open && setEditingChannel(null)}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Редактировать канал</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
{editingChannel?.channel.title}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="space-y-4 py-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="edit-planned-cost">Планируемая стоимость (₽)</Label>
|
||||||
|
<Input
|
||||||
|
id="edit-planned-cost"
|
||||||
|
type="number"
|
||||||
|
placeholder="0"
|
||||||
|
value={editPlannedCost}
|
||||||
|
onChange={(e) => setEditPlannedCost(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="edit-comment">Комментарий</Label>
|
||||||
|
<Textarea
|
||||||
|
id="edit-comment"
|
||||||
|
placeholder="Дополнительная информация..."
|
||||||
|
value={editComment}
|
||||||
|
onChange={(e) => setEditComment(e.target.value)}
|
||||||
|
rows={2}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => setEditingChannel(null)}>
|
||||||
|
Отмена
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleUpdateChannel} disabled={isUpdating}>
|
||||||
|
{isUpdating ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||||
|
Сохранение...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
"Сохранить"
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
{/* Delete Confirmation */}
|
||||||
|
<AlertDialog
|
||||||
|
open={!!deletingChannel}
|
||||||
|
onOpenChange={(open) => !open && setDeletingChannel(null)}
|
||||||
|
>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>Удалить канал из плана?</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
Канал "{deletingChannel?.channel.title}" будет удалён из плана закупок.
|
||||||
|
Это действие нельзя отменить.
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>Отмена</AlertDialogCancel>
|
||||||
|
<AlertDialogAction onClick={handleDeleteChannel}>
|
||||||
|
Удалить
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
234
app/dashboard/[workspaceId]/purchase-plans/page.tsx
Normal file
234
app/dashboard/[workspaceId]/purchase-plans/page.tsx
Normal file
@@ -0,0 +1,234 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Purchase Plans Overview Page
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useParams, useRouter } from "next/navigation";
|
||||||
|
import Link from "next/link";
|
||||||
|
import {
|
||||||
|
Loader2,
|
||||||
|
ClipboardList,
|
||||||
|
ChevronRight,
|
||||||
|
Tv,
|
||||||
|
Calendar,
|
||||||
|
DollarSign,
|
||||||
|
CheckCircle,
|
||||||
|
Clock,
|
||||||
|
XCircle,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { DashboardHeader } from "@/components/layout/dashboard-header";
|
||||||
|
import { useWorkspace } from "@/components/providers/workspace-provider";
|
||||||
|
import { demoAwarePurchasePlanApi } from "@/lib/demo";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "@/components/ui/card";
|
||||||
|
import type { Project, PurchasePlanChannel } from "@/lib/types/api";
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Helpers
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
const formatCurrency = (value: number | null) => {
|
||||||
|
if (value === null) return "—";
|
||||||
|
return new Intl.NumberFormat("ru-RU", {
|
||||||
|
style: "currency",
|
||||||
|
currency: "RUB",
|
||||||
|
minimumFractionDigits: 0,
|
||||||
|
maximumFractionDigits: 0,
|
||||||
|
}).format(value);
|
||||||
|
};
|
||||||
|
|
||||||
|
interface ProjectPlanStats {
|
||||||
|
totalChannels: number;
|
||||||
|
plannedChannels: number;
|
||||||
|
completedChannels: number;
|
||||||
|
cancelledChannels: number;
|
||||||
|
totalPlannedCost: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Component
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export default function PurchasePlansPage() {
|
||||||
|
const params = useParams();
|
||||||
|
const router = useRouter();
|
||||||
|
const workspaceId = params?.workspaceId as string;
|
||||||
|
const { projects, isLoadingProjects } = useWorkspace();
|
||||||
|
|
||||||
|
const [projectStats, setProjectStats] = useState<Record<string, ProjectPlanStats>>({});
|
||||||
|
const [loadingStats, setLoadingStats] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (projects.length > 0) {
|
||||||
|
loadAllStats();
|
||||||
|
} else {
|
||||||
|
setLoadingStats(false);
|
||||||
|
}
|
||||||
|
}, [projects, workspaceId]);
|
||||||
|
|
||||||
|
const loadAllStats = async () => {
|
||||||
|
setLoadingStats(true);
|
||||||
|
const stats: Record<string, ProjectPlanStats> = {};
|
||||||
|
|
||||||
|
for (const project of projects) {
|
||||||
|
try {
|
||||||
|
const response = await demoAwarePurchasePlanApi.list(workspaceId, project.id, { size: 100 });
|
||||||
|
const channels = response.items;
|
||||||
|
|
||||||
|
stats[project.id] = {
|
||||||
|
totalChannels: channels.length,
|
||||||
|
plannedChannels: channels.filter((c) => c.status === "planned").length,
|
||||||
|
completedChannels: channels.filter((c) => c.status === "completed").length,
|
||||||
|
cancelledChannels: channels.filter((c) => c.status === "cancelled").length,
|
||||||
|
totalPlannedCost: channels
|
||||||
|
.filter((c) => c.status === "planned")
|
||||||
|
.reduce((sum, c) => sum + (c.planned_cost || 0), 0),
|
||||||
|
};
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`Failed to load plan for project ${project.id}:`, err);
|
||||||
|
stats[project.id] = {
|
||||||
|
totalChannels: 0,
|
||||||
|
plannedChannels: 0,
|
||||||
|
completedChannels: 0,
|
||||||
|
cancelledChannels: 0,
|
||||||
|
totalPlannedCost: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setProjectStats(stats);
|
||||||
|
setLoadingStats(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const isLoading = isLoadingProjects || loadingStats;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<DashboardHeader
|
||||||
|
breadcrumbs={[
|
||||||
|
{ label: "Главная", href: `/dashboard/${workspaceId}` },
|
||||||
|
{ label: "Планы закупок" },
|
||||||
|
]}
|
||||||
|
showProjectSelector={false}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex flex-1 flex-col gap-4 p-4 pt-0 mt-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold tracking-tight">Планы закупок</h1>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
Управление планами закупок по проектам
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="flex items-center justify-center py-12">
|
||||||
|
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
) : projects.length === 0 ? (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="flex flex-col items-center justify-center py-12">
|
||||||
|
<ClipboardList className="h-12 w-12 text-muted-foreground mb-4" />
|
||||||
|
<h3 className="text-lg font-semibold mb-2">Нет проектов</h3>
|
||||||
|
<p className="text-muted-foreground text-center mb-4">
|
||||||
|
Добавьте проект через Telegram бота для создания плана закупок
|
||||||
|
</p>
|
||||||
|
<Button asChild>
|
||||||
|
<Link href={`/dashboard/${workspaceId}/projects`}>
|
||||||
|
Перейти к проектам
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
) : (
|
||||||
|
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||||
|
{projects.map((project) => {
|
||||||
|
const stats = projectStats[project.id];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card
|
||||||
|
key={project.id}
|
||||||
|
className="cursor-pointer hover:bg-muted/50 transition-colors"
|
||||||
|
onClick={() =>
|
||||||
|
router.push(`/dashboard/${workspaceId}/purchase-plans/${project.id}`)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-primary/10">
|
||||||
|
<Tv className="h-5 w-5 text-primary" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<CardTitle className="text-lg">{project.title}</CardTitle>
|
||||||
|
{project.username && (
|
||||||
|
<CardDescription>@{project.username}</CardDescription>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<ChevronRight className="h-5 w-5 text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{stats ? (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-center justify-between text-sm">
|
||||||
|
<span className="text-muted-foreground">Всего каналов:</span>
|
||||||
|
<span className="font-medium">{stats.totalChannels}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-2 flex-wrap">
|
||||||
|
{stats.plannedChannels > 0 && (
|
||||||
|
<Badge variant="outline" className="gap-1">
|
||||||
|
<Clock className="h-3 w-3" />
|
||||||
|
{stats.plannedChannels} план.
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
{stats.completedChannels > 0 && (
|
||||||
|
<Badge variant="default" className="gap-1">
|
||||||
|
<CheckCircle className="h-3 w-3" />
|
||||||
|
{stats.completedChannels} заверш.
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
{stats.cancelledChannels > 0 && (
|
||||||
|
<Badge variant="secondary" className="gap-1">
|
||||||
|
<XCircle className="h-3 w-3" />
|
||||||
|
{stats.cancelledChannels} отмен.
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{stats.totalPlannedCost > 0 && (
|
||||||
|
<div className="flex items-center gap-2 text-sm pt-2 border-t">
|
||||||
|
<DollarSign className="h-4 w-4 text-muted-foreground" />
|
||||||
|
<span className="text-muted-foreground">План. бюджет:</span>
|
||||||
|
<span className="font-medium ml-auto">
|
||||||
|
{formatCurrency(stats.totalPlannedCost)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="text-sm text-muted-foreground">Загрузка...</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
267
app/dashboard/[workspaceId]/settings/invites/page.tsx
Normal file
267
app/dashboard/[workspaceId]/settings/invites/page.tsx
Normal file
@@ -0,0 +1,267 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Workspace Invites Page
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useParams } from "next/navigation";
|
||||||
|
import { Loader2, UserPlus, Mail, Check, X, Clock } from "lucide-react";
|
||||||
|
import { DashboardHeader } from "@/components/layout/dashboard-header";
|
||||||
|
import { invitesApi } from "@/lib/api";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "@/components/ui/card";
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from "@/components/ui/table";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import type { WorkspaceInvite } from "@/lib/types/api";
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Component
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export default function InvitesPage() {
|
||||||
|
const params = useParams();
|
||||||
|
const workspaceId = params?.workspaceId as string;
|
||||||
|
|
||||||
|
const [invites, setInvites] = useState<WorkspaceInvite[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [showDialog, setShowDialog] = useState(false);
|
||||||
|
const [username, setUsername] = useState("");
|
||||||
|
const [isCreating, setIsCreating] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadInvites();
|
||||||
|
}, [workspaceId]);
|
||||||
|
|
||||||
|
const loadInvites = async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
const response = await invitesApi.list(workspaceId, { size: 100 });
|
||||||
|
setInvites(response.items);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to load invites:", err);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCreateInvite = async () => {
|
||||||
|
if (!username.trim()) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
setIsCreating(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
// Remove @ if present
|
||||||
|
const cleanUsername = username.trim().replace(/^@/, "");
|
||||||
|
|
||||||
|
const invite = await invitesApi.create(workspaceId, {
|
||||||
|
username: cleanUsername,
|
||||||
|
});
|
||||||
|
|
||||||
|
setInvites((prev) => [invite, ...prev]);
|
||||||
|
setShowDialog(false);
|
||||||
|
setUsername("");
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err?.error?.message || "Не удалось отправить приглашение");
|
||||||
|
} finally {
|
||||||
|
setIsCreating(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStatusIcon = (status: WorkspaceInvite["status"]) => {
|
||||||
|
switch (status) {
|
||||||
|
case "pending":
|
||||||
|
return <Clock className="h-4 w-4 text-yellow-500" />;
|
||||||
|
case "accepted":
|
||||||
|
return <Check className="h-4 w-4 text-green-500" />;
|
||||||
|
case "rejected":
|
||||||
|
return <X className="h-4 w-4 text-red-500" />;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStatusText = (status: WorkspaceInvite["status"]) => {
|
||||||
|
switch (status) {
|
||||||
|
case "pending":
|
||||||
|
return "Ожидает";
|
||||||
|
case "accepted":
|
||||||
|
return "Принято";
|
||||||
|
case "rejected":
|
||||||
|
return "Отклонено";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatDate = (dateString: string) => {
|
||||||
|
return new Date(dateString).toLocaleDateString("ru-RU", {
|
||||||
|
day: "numeric",
|
||||||
|
month: "short",
|
||||||
|
year: "numeric",
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<DashboardHeader
|
||||||
|
breadcrumbs={[
|
||||||
|
{ label: "Главная", href: `/dashboard/${workspaceId}` },
|
||||||
|
{ label: "Настройки", href: `/dashboard/${workspaceId}/settings` },
|
||||||
|
{ label: "Приглашения" },
|
||||||
|
]}
|
||||||
|
showProjectSelector={false}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex flex-1 flex-col gap-4 p-4 pt-0 mt-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold tracking-tight">Приглашения</h1>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
Приглашения в воркспейс через Telegram
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Dialog open={showDialog} onOpenChange={setShowDialog}>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
<Button>
|
||||||
|
<UserPlus className="h-4 w-4 mr-2" />
|
||||||
|
Пригласить
|
||||||
|
</Button>
|
||||||
|
</DialogTrigger>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Пригласить участника</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Введите username Telegram пользователя для отправки
|
||||||
|
приглашения через бота
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="space-y-4 py-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="username">Telegram username</Label>
|
||||||
|
<Input
|
||||||
|
id="username"
|
||||||
|
placeholder="@username"
|
||||||
|
value={username}
|
||||||
|
onChange={(e) => setUsername(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter") {
|
||||||
|
handleCreateInvite();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{error && (
|
||||||
|
<div className="rounded-md bg-destructive/10 p-3 text-sm text-destructive">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => setShowDialog(false)}
|
||||||
|
>
|
||||||
|
Отмена
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={handleCreateInvite}
|
||||||
|
disabled={!username.trim() || isCreating}
|
||||||
|
>
|
||||||
|
{isCreating ? (
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin mr-2" />
|
||||||
|
) : (
|
||||||
|
<Mail className="h-4 w-4 mr-2" />
|
||||||
|
)}
|
||||||
|
Отправить
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex items-center justify-center py-12">
|
||||||
|
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
) : invites.length === 0 ? (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="flex flex-col items-center justify-center py-12">
|
||||||
|
<Mail className="h-12 w-12 text-muted-foreground mb-4" />
|
||||||
|
<h3 className="text-lg font-semibold mb-2">Нет приглашений</h3>
|
||||||
|
<p className="text-muted-foreground text-center">
|
||||||
|
Пригласите участников в воркспейс через Telegram
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
) : (
|
||||||
|
<Card>
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>Username</TableHead>
|
||||||
|
<TableHead>Статус</TableHead>
|
||||||
|
<TableHead>Дата</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{invites.map((invite) => (
|
||||||
|
<TableRow key={invite.id}>
|
||||||
|
<TableCell>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<UserPlus className="h-4 w-4 text-muted-foreground" />
|
||||||
|
<span className="font-medium">@{invite.username}</span>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge
|
||||||
|
variant={
|
||||||
|
invite.status === "pending"
|
||||||
|
? "outline"
|
||||||
|
: invite.status === "accepted"
|
||||||
|
? "default"
|
||||||
|
: "destructive"
|
||||||
|
}
|
||||||
|
className="gap-1"
|
||||||
|
>
|
||||||
|
{getStatusIcon(invite.status)}
|
||||||
|
{getStatusText(invite.status)}
|
||||||
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-muted-foreground">
|
||||||
|
{formatDate(invite.created_at)}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
281
app/dashboard/[workspaceId]/settings/members/page.tsx
Normal file
281
app/dashboard/[workspaceId]/settings/members/page.tsx
Normal file
@@ -0,0 +1,281 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Workspace Members Page
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useParams } from "next/navigation";
|
||||||
|
import { Loader2, Users, Shield, ChevronDown } from "lucide-react";
|
||||||
|
import { DashboardHeader } from "@/components/layout/dashboard-header";
|
||||||
|
import { membersApi } from "@/lib/api";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
} from "@/components/ui/card";
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from "@/components/ui/table";
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuCheckboxItem,
|
||||||
|
DropdownMenuLabel,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from "@/components/ui/dropdown-menu";
|
||||||
|
import type { WorkspaceMember, PermissionKey } from "@/lib/types/api";
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Constants
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
const PERMISSION_LABELS: Record<PermissionKey, string> = {
|
||||||
|
admin_full: "Полный доступ",
|
||||||
|
projects_read: "Просмотр проектов",
|
||||||
|
projects_write: "Редактирование проектов",
|
||||||
|
placements_read: "Просмотр размещений",
|
||||||
|
placements_write: "Редактирование размещений",
|
||||||
|
analytics_read: "Просмотр аналитики",
|
||||||
|
};
|
||||||
|
|
||||||
|
const ALL_PERMISSIONS: PermissionKey[] = [
|
||||||
|
"admin_full",
|
||||||
|
"projects_read",
|
||||||
|
"projects_write",
|
||||||
|
"placements_read",
|
||||||
|
"placements_write",
|
||||||
|
"analytics_read",
|
||||||
|
];
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Component
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export default function MembersPage() {
|
||||||
|
const params = useParams();
|
||||||
|
const workspaceId = params?.workspaceId as string;
|
||||||
|
|
||||||
|
const [members, setMembers] = useState<WorkspaceMember[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [updating, setUpdating] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const loadMembers = async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
const response = await membersApi.list(workspaceId, { size: 100 });
|
||||||
|
setMembers(response.items);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to load members:", err);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
loadMembers();
|
||||||
|
}, [workspaceId]);
|
||||||
|
|
||||||
|
const handlePermissionChange = async (
|
||||||
|
memberId: string,
|
||||||
|
userId: string,
|
||||||
|
permission: PermissionKey,
|
||||||
|
enabled: boolean
|
||||||
|
) => {
|
||||||
|
try {
|
||||||
|
setUpdating(memberId);
|
||||||
|
|
||||||
|
const member = members.find((m) => m.id === memberId);
|
||||||
|
if (!member) return;
|
||||||
|
|
||||||
|
let newPermissions = [...member.permissions];
|
||||||
|
|
||||||
|
if (enabled) {
|
||||||
|
// Add permission
|
||||||
|
if (!newPermissions.some((p) => p.key === permission)) {
|
||||||
|
newPermissions.push({ key: permission });
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Remove permission
|
||||||
|
newPermissions = newPermissions.filter((p) => p.key !== permission);
|
||||||
|
}
|
||||||
|
|
||||||
|
await membersApi.updatePermissions(workspaceId, userId, {
|
||||||
|
permissions: newPermissions,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update local state
|
||||||
|
setMembers((prev) =>
|
||||||
|
prev.map((m) =>
|
||||||
|
m.id === memberId ? { ...m, permissions: newPermissions } : m
|
||||||
|
)
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to update permissions:", err);
|
||||||
|
} finally {
|
||||||
|
setUpdating(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const hasPermission = (member: WorkspaceMember, key: PermissionKey) => {
|
||||||
|
return member.permissions.some((p) => p.key === key);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<DashboardHeader
|
||||||
|
breadcrumbs={[
|
||||||
|
{ label: "Главная", href: `/dashboard/${workspaceId}` },
|
||||||
|
{ label: "Настройки", href: `/dashboard/${workspaceId}/settings` },
|
||||||
|
{ label: "Участники" },
|
||||||
|
]}
|
||||||
|
showProjectSelector={false}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex flex-1 flex-col gap-4 p-4 pt-0 mt-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold tracking-tight">Участники</h1>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
Управление участниками и правами доступа
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button asChild>
|
||||||
|
<a href={`/dashboard/${workspaceId}/settings/invites`}>
|
||||||
|
Пригласить участника
|
||||||
|
</a>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex items-center justify-center py-12">
|
||||||
|
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
) : members.length === 0 ? (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="flex flex-col items-center justify-center py-12">
|
||||||
|
<Users className="h-12 w-12 text-muted-foreground mb-4" />
|
||||||
|
<h3 className="text-lg font-semibold mb-2">Нет участников</h3>
|
||||||
|
<p className="text-muted-foreground text-center">
|
||||||
|
Пригласите участников в воркспейс
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
) : (
|
||||||
|
<Card>
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>Пользователь</TableHead>
|
||||||
|
<TableHead>Статус</TableHead>
|
||||||
|
<TableHead>Права доступа</TableHead>
|
||||||
|
<TableHead className="w-[150px]"></TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{members.map((member) => (
|
||||||
|
<TableRow key={member.id}>
|
||||||
|
<TableCell>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="flex h-9 w-9 items-center justify-center rounded-full bg-primary/10">
|
||||||
|
<Users className="h-4 w-4 text-primary" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="font-medium">
|
||||||
|
{member.user.username || `User ${member.user.telegram_id}`}
|
||||||
|
</div>
|
||||||
|
{member.user.username && (
|
||||||
|
<div className="text-xs text-muted-foreground">
|
||||||
|
@{member.user.username}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge
|
||||||
|
variant={
|
||||||
|
member.status === "active" ? "default" : "secondary"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{member.status === "active" ? "Активен" : "Приглашён"}
|
||||||
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{hasPermission(member, "admin_full") ? (
|
||||||
|
<Badge variant="destructive" className="gap-1">
|
||||||
|
<Shield className="h-3 w-3" />
|
||||||
|
Админ
|
||||||
|
</Badge>
|
||||||
|
) : (
|
||||||
|
member.permissions.map((p) => (
|
||||||
|
<Badge key={p.key} variant="outline">
|
||||||
|
{PERMISSION_LABELS[p.key]}
|
||||||
|
</Badge>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
{member.permissions.length === 0 && (
|
||||||
|
<span className="text-sm text-muted-foreground">
|
||||||
|
Нет прав
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
disabled={updating === member.id}
|
||||||
|
>
|
||||||
|
{updating === member.id ? (
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
Права
|
||||||
|
<ChevronDown className="h-4 w-4 ml-1" />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end" className="w-56">
|
||||||
|
<DropdownMenuLabel>Права доступа</DropdownMenuLabel>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
{ALL_PERMISSIONS.map((perm) => (
|
||||||
|
<DropdownMenuCheckboxItem
|
||||||
|
key={perm}
|
||||||
|
checked={hasPermission(member, perm)}
|
||||||
|
onCheckedChange={(checked) =>
|
||||||
|
handlePermissionChange(
|
||||||
|
member.id,
|
||||||
|
member.user.id,
|
||||||
|
perm,
|
||||||
|
checked
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{PERMISSION_LABELS[perm]}
|
||||||
|
</DropdownMenuCheckboxItem>
|
||||||
|
))}
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
204
app/dashboard/[workspaceId]/settings/page.tsx
Normal file
204
app/dashboard/[workspaceId]/settings/page.tsx
Normal file
@@ -0,0 +1,204 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Workspace Settings Page
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useParams, useRouter } from "next/navigation";
|
||||||
|
import { Loader2, Settings, Pencil, Trash2 } from "lucide-react";
|
||||||
|
import { DashboardHeader } from "@/components/layout/dashboard-header";
|
||||||
|
import { useWorkspace } from "@/components/providers/workspace-provider";
|
||||||
|
import { workspacesApi } from "@/lib/api";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "@/components/ui/card";
|
||||||
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
AlertDialogTrigger,
|
||||||
|
} from "@/components/ui/alert-dialog";
|
||||||
|
|
||||||
|
export default function SettingsPage() {
|
||||||
|
const params = useParams();
|
||||||
|
const router = useRouter();
|
||||||
|
const workspaceId = params?.workspaceId as string;
|
||||||
|
const { currentWorkspace, refreshWorkspaces } = useWorkspace();
|
||||||
|
|
||||||
|
const [name, setName] = useState(currentWorkspace?.name || "");
|
||||||
|
const [isUpdating, setIsUpdating] = useState(false);
|
||||||
|
const [isDeleting, setIsDeleting] = useState(false);
|
||||||
|
|
||||||
|
const handleUpdate = async () => {
|
||||||
|
if (!name.trim() || name === currentWorkspace?.name) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
setIsUpdating(true);
|
||||||
|
await workspacesApi.update(workspaceId, { name: name.trim() });
|
||||||
|
await refreshWorkspaces();
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to update workspace:", err);
|
||||||
|
} finally {
|
||||||
|
setIsUpdating(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = async () => {
|
||||||
|
try {
|
||||||
|
setIsDeleting(true);
|
||||||
|
await workspacesApi.delete(workspaceId);
|
||||||
|
router.replace("/");
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to delete workspace:", err);
|
||||||
|
} finally {
|
||||||
|
setIsDeleting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!currentWorkspace) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<DashboardHeader breadcrumbs={[{ label: "Настройки" }]} />
|
||||||
|
<div className="flex items-center justify-center py-12">
|
||||||
|
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<DashboardHeader
|
||||||
|
breadcrumbs={[
|
||||||
|
{ label: "Главная", href: `/dashboard/${workspaceId}` },
|
||||||
|
{ label: "Настройки" },
|
||||||
|
]}
|
||||||
|
showProjectSelector={false}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex flex-1 flex-col gap-4 p-4 pt-0 mt-4 max-w-2xl">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold tracking-tight">Настройки воркспейса</h1>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
Управление настройками воркспейса
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* General Settings */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Основные настройки</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Название и основные параметры воркспейса
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="workspace-name">Название</Label>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Input
|
||||||
|
id="workspace-name"
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
placeholder="Название воркспейса"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
onClick={handleUpdate}
|
||||||
|
disabled={
|
||||||
|
!name.trim() ||
|
||||||
|
name === currentWorkspace.name ||
|
||||||
|
isUpdating
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{isUpdating ? (
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Pencil className="h-4 w-4" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Team Management Links */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Команда</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Управление участниками и приглашениями
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-2">
|
||||||
|
<Button variant="outline" className="w-full justify-start" asChild>
|
||||||
|
<a href={`/dashboard/${workspaceId}/settings/members`}>
|
||||||
|
Участники воркспейса
|
||||||
|
</a>
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" className="w-full justify-start" asChild>
|
||||||
|
<a href={`/dashboard/${workspaceId}/settings/invites`}>
|
||||||
|
Приглашения
|
||||||
|
</a>
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Danger Zone */}
|
||||||
|
<Card className="border-destructive">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-destructive">Опасная зона</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Необратимые действия с воркспейсом
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<AlertDialog>
|
||||||
|
<AlertDialogTrigger asChild>
|
||||||
|
<Button variant="destructive">
|
||||||
|
<Trash2 className="h-4 w-4 mr-2" />
|
||||||
|
Удалить воркспейс
|
||||||
|
</Button>
|
||||||
|
</AlertDialogTrigger>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>Удалить воркспейс?</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
Это действие необратимо. Все данные воркспейса будут
|
||||||
|
удалены, включая проекты, креативы, размещения и аналитику.
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>Отмена</AlertDialogCancel>
|
||||||
|
<AlertDialogAction
|
||||||
|
onClick={handleDelete}
|
||||||
|
disabled={isDeleting}
|
||||||
|
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||||
|
>
|
||||||
|
{isDeleting ? (
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin mr-2" />
|
||||||
|
) : null}
|
||||||
|
Удалить
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
115
app/dashboard/onboarding/page.tsx
Normal file
115
app/dashboard/onboarding/page.tsx
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Onboarding Page - First Workspace Creation
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { Building2, Loader2, Megaphone } from "lucide-react";
|
||||||
|
import { workspacesApi } from "@/lib/api";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardFooter,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "@/components/ui/card";
|
||||||
|
import { STORAGE_KEYS } from "@/lib/utils/constants";
|
||||||
|
|
||||||
|
export default function OnboardingPage() {
|
||||||
|
const router = useRouter();
|
||||||
|
const [name, setName] = useState("");
|
||||||
|
const [isCreating, setIsCreating] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const handleCreate = async () => {
|
||||||
|
if (!name.trim()) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
setIsCreating(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
const workspace = await workspacesApi.create({ name: name.trim() });
|
||||||
|
|
||||||
|
// Save as selected workspace
|
||||||
|
localStorage.setItem(STORAGE_KEYS.SELECTED_WORKSPACE, workspace.id);
|
||||||
|
|
||||||
|
// Redirect to dashboard
|
||||||
|
router.replace(`/dashboard/${workspace.id}`);
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err?.error?.message || "Не удалось создать воркспейс");
|
||||||
|
} finally {
|
||||||
|
setIsCreating(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-screen items-center justify-center bg-gradient-to-br from-background to-muted p-4">
|
||||||
|
<Card className="w-full max-w-md">
|
||||||
|
<CardHeader className="text-center">
|
||||||
|
<div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-primary/10">
|
||||||
|
<Megaphone className="h-8 w-8 text-primary" />
|
||||||
|
</div>
|
||||||
|
<CardTitle className="text-2xl">Добро пожаловать в TGEX!</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Создайте свой первый воркспейс для начала работы
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="workspace-name">Название воркспейса</Label>
|
||||||
|
<Input
|
||||||
|
id="workspace-name"
|
||||||
|
placeholder="Например: Моя команда"
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter") {
|
||||||
|
handleCreate();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
disabled={isCreating}
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Воркспейс — это изолированное пространство для вашей команды. Вы
|
||||||
|
сможете пригласить участников и настроить права доступа.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="rounded-md bg-destructive/10 p-3 text-sm text-destructive">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
|
||||||
|
<CardFooter>
|
||||||
|
<Button
|
||||||
|
className="w-full"
|
||||||
|
onClick={handleCreate}
|
||||||
|
disabled={!name.trim() || isCreating}
|
||||||
|
>
|
||||||
|
{isCreating ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
|
Создание...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Building2 className="mr-2 h-4 w-4" />
|
||||||
|
Создать воркспейс
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</CardFooter>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
59
app/page.tsx
Normal file
59
app/page.tsx
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Root Page - Redirect to Dashboard
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { Loader2 } from "lucide-react";
|
||||||
|
import { authApi, workspacesApi } from "@/lib/api";
|
||||||
|
import { STORAGE_KEYS } from "@/lib/utils/constants";
|
||||||
|
|
||||||
|
export default function RootPage() {
|
||||||
|
const router = useRouter();
|
||||||
|
const [isChecking, setIsChecking] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const checkAuthAndRedirect = async () => {
|
||||||
|
// Check if user is authenticated
|
||||||
|
const token = localStorage.getItem(STORAGE_KEYS.ACCESS_TOKEN);
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
router.replace("/login");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Verify token is valid
|
||||||
|
await authApi.me();
|
||||||
|
|
||||||
|
// Get workspaces
|
||||||
|
const { items: workspaces } = await workspacesApi.list({ size: 1 });
|
||||||
|
|
||||||
|
if (workspaces.length > 0) {
|
||||||
|
// Check for saved workspace
|
||||||
|
const savedId = localStorage.getItem(STORAGE_KEYS.SELECTED_WORKSPACE);
|
||||||
|
const workspaceId = savedId || workspaces[0].id;
|
||||||
|
router.replace(`/dashboard/${workspaceId}`);
|
||||||
|
} else {
|
||||||
|
// No workspaces - show onboarding or create first workspace
|
||||||
|
router.replace("/dashboard/onboarding");
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
// Token invalid - redirect to login
|
||||||
|
localStorage.removeItem(STORAGE_KEYS.ACCESS_TOKEN);
|
||||||
|
router.replace("/login");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
checkAuthAndRedirect();
|
||||||
|
}, [router]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-screen items-center justify-center">
|
||||||
|
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@@ -7,7 +7,6 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import { AuthProvider } from "@/components/auth/auth-provider";
|
import { AuthProvider } from "@/components/auth/auth-provider";
|
||||||
import { ThemeProvider } from "@/components/theme-provider";
|
import { ThemeProvider } from "@/components/theme-provider";
|
||||||
import { TargetChannelProvider } from "@/components/providers/target-channel-provider";
|
|
||||||
|
|
||||||
interface ProvidersProps {
|
interface ProvidersProps {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
@@ -21,9 +20,7 @@ export const Providers: React.FC<ProvidersProps> = ({ children }) => {
|
|||||||
enableSystem
|
enableSystem
|
||||||
disableTransitionOnChange
|
disableTransitionOnChange
|
||||||
>
|
>
|
||||||
<AuthProvider>
|
<AuthProvider>{children}</AuthProvider>
|
||||||
<TargetChannelProvider>{children}</TargetChannelProvider>
|
|
||||||
</AuthProvider>
|
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,181 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import React from "react";
|
|
||||||
import { CartesianGrid, Line, LineChart, XAxis, Bar, BarChart } from "recharts";
|
|
||||||
import { CardContent } from "@/components/ui/card";
|
|
||||||
import {
|
|
||||||
ChartConfig,
|
|
||||||
ChartContainer,
|
|
||||||
ChartTooltip,
|
|
||||||
ChartTooltipContent,
|
|
||||||
ChartLegend,
|
|
||||||
ChartLegendContent,
|
|
||||||
} from "@/components/ui/chart";
|
|
||||||
import { formatCurrency, formatNumber } from "@/lib/utils/format";
|
|
||||||
import type { SpendingDataPoint, AnalyticsMetric } from "@/lib/types/api";
|
|
||||||
|
|
||||||
interface AnalyticsChartProps {
|
|
||||||
data: SpendingDataPoint[];
|
|
||||||
metrics: AnalyticsMetric[];
|
|
||||||
chartType: "line" | "bar";
|
|
||||||
showDataLabels?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
const METRIC_LABELS: Record<AnalyticsMetric, string> = {
|
|
||||||
cost: "Расходы",
|
|
||||||
subscriptions: "Подписки",
|
|
||||||
views: "Просмотры",
|
|
||||||
cpf: "CPF",
|
|
||||||
cpm: "CPM",
|
|
||||||
};
|
|
||||||
|
|
||||||
export const AnalyticsChart: React.FC<AnalyticsChartProps> = ({
|
|
||||||
data,
|
|
||||||
metrics,
|
|
||||||
chartType,
|
|
||||||
showDataLabels = false,
|
|
||||||
}) => {
|
|
||||||
// Создаем конфигурацию для chart
|
|
||||||
const chartConfig: ChartConfig = metrics.reduce((acc, metric, index) => {
|
|
||||||
acc[metric] = {
|
|
||||||
label: METRIC_LABELS[metric],
|
|
||||||
color: `var(--chart-${index + 1})`,
|
|
||||||
};
|
|
||||||
return acc;
|
|
||||||
}, {} as ChartConfig);
|
|
||||||
|
|
||||||
const formatValue = (value: number | null, metric: AnalyticsMetric) => {
|
|
||||||
if (value === null) return "—";
|
|
||||||
if (metric === "cost" || metric === "cpf" || metric === "cpm") {
|
|
||||||
return formatCurrency(value);
|
|
||||||
}
|
|
||||||
return formatNumber(value);
|
|
||||||
};
|
|
||||||
|
|
||||||
if (chartType === "bar") {
|
|
||||||
return (
|
|
||||||
<CardContent className="px-2 sm:p-6">
|
|
||||||
<ChartContainer
|
|
||||||
config={chartConfig}
|
|
||||||
className="aspect-auto h-[350px] w-full"
|
|
||||||
>
|
|
||||||
<BarChart
|
|
||||||
accessibilityLayer
|
|
||||||
data={data}
|
|
||||||
margin={{
|
|
||||||
left: 12,
|
|
||||||
right: 12,
|
|
||||||
top: showDataLabels ? 20 : 5,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<CartesianGrid vertical={false} />
|
|
||||||
<XAxis
|
|
||||||
dataKey="period"
|
|
||||||
tickLine={false}
|
|
||||||
axisLine={false}
|
|
||||||
tickMargin={8}
|
|
||||||
minTickGap={32}
|
|
||||||
/>
|
|
||||||
<ChartTooltip
|
|
||||||
content={
|
|
||||||
<ChartTooltipContent
|
|
||||||
labelFormatter={(value) => `Период: ${value}`}
|
|
||||||
formatter={(value, name) => [
|
|
||||||
formatValue(
|
|
||||||
value as number,
|
|
||||||
name as AnalyticsMetric
|
|
||||||
),
|
|
||||||
chartConfig[name as AnalyticsMetric]?.label || name,
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<ChartLegend content={<ChartLegendContent />} />
|
|
||||||
{metrics.map((metric) => (
|
|
||||||
<Bar
|
|
||||||
key={metric}
|
|
||||||
dataKey={metric}
|
|
||||||
fill={chartConfig[metric]?.color}
|
|
||||||
radius={4}
|
|
||||||
label={
|
|
||||||
showDataLabels
|
|
||||||
? {
|
|
||||||
position: "top",
|
|
||||||
formatter: (value: number) =>
|
|
||||||
formatValue(value, metric),
|
|
||||||
fontSize: 10,
|
|
||||||
}
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</BarChart>
|
|
||||||
</ChartContainer>
|
|
||||||
</CardContent>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<CardContent className="px-2 sm:p-6">
|
|
||||||
<ChartContainer
|
|
||||||
config={chartConfig}
|
|
||||||
className="aspect-auto h-[350px] w-full"
|
|
||||||
>
|
|
||||||
<LineChart
|
|
||||||
accessibilityLayer
|
|
||||||
data={data}
|
|
||||||
margin={{
|
|
||||||
left: 12,
|
|
||||||
right: 12,
|
|
||||||
top: showDataLabels ? 20 : 5,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<CartesianGrid vertical={false} />
|
|
||||||
<XAxis
|
|
||||||
dataKey="period"
|
|
||||||
tickLine={false}
|
|
||||||
axisLine={false}
|
|
||||||
tickMargin={8}
|
|
||||||
minTickGap={32}
|
|
||||||
/>
|
|
||||||
<ChartTooltip
|
|
||||||
content={
|
|
||||||
<ChartTooltipContent
|
|
||||||
labelFormatter={(value) => `Период: ${value}`}
|
|
||||||
formatter={(value, name) => [
|
|
||||||
formatValue(
|
|
||||||
value as number,
|
|
||||||
name as AnalyticsMetric
|
|
||||||
),
|
|
||||||
chartConfig[name as AnalyticsMetric]?.label || name,
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<ChartLegend content={<ChartLegendContent />} />
|
|
||||||
{metrics.map((metric) => (
|
|
||||||
<Line
|
|
||||||
key={metric}
|
|
||||||
dataKey={metric}
|
|
||||||
type="monotone"
|
|
||||||
stroke={chartConfig[metric]?.color}
|
|
||||||
strokeWidth={2}
|
|
||||||
dot={showDataLabels}
|
|
||||||
label={
|
|
||||||
showDataLabels
|
|
||||||
? {
|
|
||||||
position: "top",
|
|
||||||
formatter: (value: number) =>
|
|
||||||
formatValue(value, metric),
|
|
||||||
fontSize: 10,
|
|
||||||
}
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</LineChart>
|
|
||||||
</ChartContainer>
|
|
||||||
</CardContent>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
@@ -1,438 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import React, { useState } from "react";
|
|
||||||
import {
|
|
||||||
Card,
|
|
||||||
CardContent,
|
|
||||||
CardDescription,
|
|
||||||
CardHeader,
|
|
||||||
CardTitle,
|
|
||||||
} from "@/components/ui/card";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { Label } from "@/components/ui/label";
|
|
||||||
import {
|
|
||||||
Select,
|
|
||||||
SelectContent,
|
|
||||||
SelectItem,
|
|
||||||
SelectTrigger,
|
|
||||||
SelectValue,
|
|
||||||
} from "@/components/ui/select";
|
|
||||||
import { Calendar } from "@/components/ui/calendar";
|
|
||||||
import {
|
|
||||||
Popover,
|
|
||||||
PopoverContent,
|
|
||||||
PopoverTrigger,
|
|
||||||
} from "@/components/ui/popover";
|
|
||||||
import { Checkbox } from "@/components/ui/checkbox";
|
|
||||||
import { Switch } from "@/components/ui/switch";
|
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
import { format } from "date-fns";
|
|
||||||
import { ru } from "date-fns/locale";
|
|
||||||
import {
|
|
||||||
CalendarIcon,
|
|
||||||
X,
|
|
||||||
TrendingUp,
|
|
||||||
BarChart3,
|
|
||||||
LineChart,
|
|
||||||
GripVertical,
|
|
||||||
Maximize2,
|
|
||||||
Columns2,
|
|
||||||
} from "lucide-react";
|
|
||||||
import type {
|
|
||||||
TargetChannel,
|
|
||||||
AnalyticsMetric,
|
|
||||||
DateGrouping,
|
|
||||||
} from "@/lib/types/api";
|
|
||||||
import { AnalyticsChart } from "./analytics-chart";
|
|
||||||
import type { SpendingAnalytics } from "@/lib/types/api";
|
|
||||||
|
|
||||||
interface ChartConfigPanelProps {
|
|
||||||
channels: TargetChannel[];
|
|
||||||
data: SpendingAnalytics | null;
|
|
||||||
loading: boolean;
|
|
||||||
width: "full" | "half";
|
|
||||||
onBuild: (config: {
|
|
||||||
targetChannelIds: string[];
|
|
||||||
metrics: AnalyticsMetric[];
|
|
||||||
dateFrom: Date | null;
|
|
||||||
dateTo: Date | null;
|
|
||||||
grouping: DateGrouping;
|
|
||||||
}) => void;
|
|
||||||
onRemove?: () => void;
|
|
||||||
onWidthChange?: (width: "full" | "half") => void;
|
|
||||||
showRemoveButton?: boolean;
|
|
||||||
chartNumber?: number;
|
|
||||||
dragHandleProps?: any;
|
|
||||||
}
|
|
||||||
|
|
||||||
const METRICS: { value: AnalyticsMetric; label: string }[] = [
|
|
||||||
{ value: "cost", label: "Расходы" },
|
|
||||||
{ value: "subscriptions", label: "Подписки" },
|
|
||||||
{ value: "views", label: "Просмотры" },
|
|
||||||
{ value: "cpf", label: "CPF (Cost Per Follow)" },
|
|
||||||
{ value: "cpm", label: "CPM (Cost Per Mille)" },
|
|
||||||
];
|
|
||||||
|
|
||||||
const GROUPINGS: { value: DateGrouping; label: string }[] = [
|
|
||||||
{ value: "day", label: "По дням" },
|
|
||||||
{ value: "week", label: "По неделям" },
|
|
||||||
{ value: "month", label: "По месяцам" },
|
|
||||||
{ value: "quarter", label: "По кварталам" },
|
|
||||||
{ value: "year", label: "По годам" },
|
|
||||||
];
|
|
||||||
|
|
||||||
export const ChartConfigPanel: React.FC<ChartConfigPanelProps> = ({
|
|
||||||
channels,
|
|
||||||
data,
|
|
||||||
loading,
|
|
||||||
width,
|
|
||||||
onBuild,
|
|
||||||
onRemove,
|
|
||||||
onWidthChange,
|
|
||||||
showRemoveButton = false,
|
|
||||||
chartNumber = 1,
|
|
||||||
dragHandleProps,
|
|
||||||
}) => {
|
|
||||||
const [selectedChannelIds, setSelectedChannelIds] = useState<string[]>([]);
|
|
||||||
const [selectedMetrics, setSelectedMetrics] = useState<AnalyticsMetric[]>([]);
|
|
||||||
const [dateFrom, setDateFrom] = useState<Date | undefined>();
|
|
||||||
const [dateTo, setDateTo] = useState<Date | undefined>();
|
|
||||||
const [grouping, setGrouping] = useState<DateGrouping>("day");
|
|
||||||
const [chartType, setChartType] = useState<"line" | "bar">("line");
|
|
||||||
const [showDataLabels, setShowDataLabels] = useState(false);
|
|
||||||
|
|
||||||
// Отслеживание изменений настроек
|
|
||||||
const [lastBuiltConfig, setLastBuiltConfig] = useState<string | null>(null);
|
|
||||||
const [isBuilt, setIsBuilt] = useState(false);
|
|
||||||
|
|
||||||
// Текущая конфигурация как строка для сравнения
|
|
||||||
const currentConfigString = JSON.stringify({
|
|
||||||
selectedChannelIds,
|
|
||||||
selectedMetrics,
|
|
||||||
dateFrom: dateFrom?.toISOString(),
|
|
||||||
dateTo: dateTo?.toISOString(),
|
|
||||||
grouping,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Проверяем, изменились ли настройки
|
|
||||||
const hasChanges = isBuilt && lastBuiltConfig !== currentConfigString;
|
|
||||||
|
|
||||||
const toggleChannel = (channelId: string) => {
|
|
||||||
setSelectedChannelIds((prev) =>
|
|
||||||
prev.includes(channelId)
|
|
||||||
? prev.filter((id) => id !== channelId)
|
|
||||||
: [...prev, channelId]
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const toggleMetric = (metric: AnalyticsMetric) => {
|
|
||||||
setSelectedMetrics((prev) =>
|
|
||||||
prev.includes(metric)
|
|
||||||
? prev.filter((m) => m !== metric)
|
|
||||||
: [...prev, metric]
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleBuild = () => {
|
|
||||||
if (selectedChannelIds.length === 0 || selectedMetrics.length === 0) {
|
|
||||||
alert("Выберите хотя бы один канал и одну метрику");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
onBuild({
|
|
||||||
targetChannelIds: selectedChannelIds,
|
|
||||||
metrics: selectedMetrics,
|
|
||||||
dateFrom: dateFrom || null,
|
|
||||||
dateTo: dateTo || null,
|
|
||||||
grouping,
|
|
||||||
});
|
|
||||||
|
|
||||||
setLastBuiltConfig(currentConfigString);
|
|
||||||
setIsBuilt(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
const getButtonText = () => {
|
|
||||||
if (loading) return "Загрузка...";
|
|
||||||
if (!isBuilt) return "Построить график";
|
|
||||||
if (hasChanges) return "Обновить график";
|
|
||||||
return "График построен";
|
|
||||||
};
|
|
||||||
|
|
||||||
const isButtonDisabled = loading || (isBuilt && !hasChanges);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<div className="flex items-center justify-between gap-2">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
{/* Drag handle */}
|
|
||||||
<div
|
|
||||||
{...dragHandleProps}
|
|
||||||
className="cursor-grab active:cursor-grabbing touch-none"
|
|
||||||
>
|
|
||||||
<GripVertical className="h-5 w-5 text-muted-foreground hover:text-foreground" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<CardTitle>График #{chartNumber}</CardTitle>
|
|
||||||
<CardDescription className="mt-1">
|
|
||||||
Настройте параметры и постройте график
|
|
||||||
</CardDescription>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-1">
|
|
||||||
{/* Width toggle */}
|
|
||||||
{onWidthChange && (
|
|
||||||
<div className="flex gap-1 border rounded-md mr-2">
|
|
||||||
<Button
|
|
||||||
variant={width === "full" ? "default" : "ghost"}
|
|
||||||
size="sm"
|
|
||||||
onClick={() => onWidthChange("full")}
|
|
||||||
className="rounded-r-none"
|
|
||||||
title="Полная ширина"
|
|
||||||
>
|
|
||||||
<Maximize2 className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant={width === "half" ? "default" : "ghost"}
|
|
||||||
size="sm"
|
|
||||||
onClick={() => onWidthChange("half")}
|
|
||||||
className="rounded-l-none"
|
|
||||||
title="Половина ширины"
|
|
||||||
>
|
|
||||||
<Columns2 className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Remove button */}
|
|
||||||
{showRemoveButton && onRemove && (
|
|
||||||
<Button variant="ghost" size="sm" onClick={onRemove}>
|
|
||||||
<X className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-4">
|
|
||||||
{/* Настройки */}
|
|
||||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
|
||||||
{/* Выбор каналов */}
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label>Целевые каналы</Label>
|
|
||||||
<Popover>
|
|
||||||
<PopoverTrigger asChild>
|
|
||||||
<Button variant="outline" className="w-full justify-start">
|
|
||||||
{selectedChannelIds.length === 0
|
|
||||||
? "Выберите каналы"
|
|
||||||
: `Выбрано: ${selectedChannelIds.length}`}
|
|
||||||
</Button>
|
|
||||||
</PopoverTrigger>
|
|
||||||
<PopoverContent className="w-64 p-3" align="start">
|
|
||||||
<div className="space-y-2">
|
|
||||||
{channels.map((channel) => (
|
|
||||||
<div
|
|
||||||
key={channel.id}
|
|
||||||
className="flex items-center space-x-2"
|
|
||||||
>
|
|
||||||
<Checkbox
|
|
||||||
id={`channel-${channel.id}`}
|
|
||||||
checked={selectedChannelIds.includes(channel.id)}
|
|
||||||
onCheckedChange={() => toggleChannel(channel.id)}
|
|
||||||
/>
|
|
||||||
<label
|
|
||||||
htmlFor={`channel-${channel.id}`}
|
|
||||||
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70 cursor-pointer"
|
|
||||||
>
|
|
||||||
{channel.title}
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</PopoverContent>
|
|
||||||
</Popover>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Выбор метрик */}
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label>Метрики</Label>
|
|
||||||
<Popover>
|
|
||||||
<PopoverTrigger asChild>
|
|
||||||
<Button variant="outline" className="w-full justify-start">
|
|
||||||
{selectedMetrics.length === 0
|
|
||||||
? "Выберите метрики"
|
|
||||||
: `Выбрано: ${selectedMetrics.length}`}
|
|
||||||
</Button>
|
|
||||||
</PopoverTrigger>
|
|
||||||
<PopoverContent className="w-64 p-3" align="start">
|
|
||||||
<div className="space-y-2">
|
|
||||||
{METRICS.map((metric) => (
|
|
||||||
<div
|
|
||||||
key={metric.value}
|
|
||||||
className="flex items-center space-x-2"
|
|
||||||
>
|
|
||||||
<Checkbox
|
|
||||||
id={`metric-${metric.value}`}
|
|
||||||
checked={selectedMetrics.includes(metric.value)}
|
|
||||||
onCheckedChange={() => toggleMetric(metric.value)}
|
|
||||||
/>
|
|
||||||
<label
|
|
||||||
htmlFor={`metric-${metric.value}`}
|
|
||||||
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70 cursor-pointer"
|
|
||||||
>
|
|
||||||
{metric.label}
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</PopoverContent>
|
|
||||||
</Popover>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Период от */}
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label>Дата от</Label>
|
|
||||||
<Popover>
|
|
||||||
<PopoverTrigger asChild>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
className={cn(
|
|
||||||
"w-full justify-start text-left font-normal",
|
|
||||||
!dateFrom && "text-muted-foreground"
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<CalendarIcon className="mr-2 h-4 w-4" />
|
|
||||||
{dateFrom ? (
|
|
||||||
format(dateFrom, "d MMM yyyy", { locale: ru })
|
|
||||||
) : (
|
|
||||||
<span>Выберите</span>
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
</PopoverTrigger>
|
|
||||||
<PopoverContent className="w-auto p-0" align="start">
|
|
||||||
<Calendar
|
|
||||||
mode="single"
|
|
||||||
selected={dateFrom}
|
|
||||||
onSelect={setDateFrom}
|
|
||||||
initialFocus
|
|
||||||
/>
|
|
||||||
</PopoverContent>
|
|
||||||
</Popover>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Период до */}
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label>Дата до</Label>
|
|
||||||
<Popover>
|
|
||||||
<PopoverTrigger asChild>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
className={cn(
|
|
||||||
"w-full justify-start text-left font-normal",
|
|
||||||
!dateTo && "text-muted-foreground"
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<CalendarIcon className="mr-2 h-4 w-4" />
|
|
||||||
{dateTo ? (
|
|
||||||
format(dateTo, "d MMM yyyy", { locale: ru })
|
|
||||||
) : (
|
|
||||||
<span>Выберите</span>
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
</PopoverTrigger>
|
|
||||||
<PopoverContent className="w-auto p-0" align="start">
|
|
||||||
<Calendar
|
|
||||||
mode="single"
|
|
||||||
selected={dateTo}
|
|
||||||
onSelect={setDateTo}
|
|
||||||
initialFocus
|
|
||||||
/>
|
|
||||||
</PopoverContent>
|
|
||||||
</Popover>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-4">
|
|
||||||
{/* Группировка */}
|
|
||||||
<div className="flex-1 space-y-2">
|
|
||||||
<Label>Группировка</Label>
|
|
||||||
<Select
|
|
||||||
value={grouping}
|
|
||||||
onValueChange={(v) => setGrouping(v as DateGrouping)}
|
|
||||||
>
|
|
||||||
<SelectTrigger>
|
|
||||||
<SelectValue />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{GROUPINGS.map((g) => (
|
|
||||||
<SelectItem key={g.value} value={g.value}>
|
|
||||||
{g.label}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Тип графика */}
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label>Тип</Label>
|
|
||||||
<div className="flex gap-1 border rounded-md">
|
|
||||||
<Button
|
|
||||||
variant={chartType === "line" ? "default" : "ghost"}
|
|
||||||
size="sm"
|
|
||||||
onClick={() => setChartType("line")}
|
|
||||||
className="rounded-r-none"
|
|
||||||
>
|
|
||||||
<LineChart className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant={chartType === "bar" ? "default" : "ghost"}
|
|
||||||
size="sm"
|
|
||||||
onClick={() => setChartType("bar")}
|
|
||||||
className="rounded-l-none"
|
|
||||||
>
|
|
||||||
<BarChart3 className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Подписи значений */}
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label>Подписи</Label>
|
|
||||||
<div className="flex items-center space-x-2 h-9">
|
|
||||||
<Switch
|
|
||||||
id="show-data-labels"
|
|
||||||
checked={showDataLabels}
|
|
||||||
onCheckedChange={setShowDataLabels}
|
|
||||||
/>
|
|
||||||
<Label htmlFor="show-data-labels" className="cursor-pointer">
|
|
||||||
{showDataLabels ? "Вкл" : "Выкл"}
|
|
||||||
</Label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Кнопка построения */}
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label className="invisible">Построить</Label>
|
|
||||||
<Button onClick={handleBuild} disabled={isButtonDisabled}>
|
|
||||||
<TrendingUp
|
|
||||||
className={cn("mr-2 h-4 w-4", loading && "animate-pulse")}
|
|
||||||
/>
|
|
||||||
{getButtonText()}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* График */}
|
|
||||||
{data && (
|
|
||||||
<AnalyticsChart
|
|
||||||
data={data.chart_data}
|
|
||||||
metrics={selectedMetrics}
|
|
||||||
chartType={chartType}
|
|
||||||
showDataLabels={showDataLabels}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -5,8 +5,9 @@
|
|||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
import React, { useEffect } from "react";
|
import React, { useEffect } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter, useParams } from "next/navigation";
|
||||||
import { useAuth } from "./auth-provider";
|
import { useAuth } from "./auth-provider";
|
||||||
|
import { isDemoWorkspace } from "@/lib/demo";
|
||||||
|
|
||||||
interface ProtectedRouteProps {
|
interface ProtectedRouteProps {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
@@ -15,12 +16,25 @@ interface ProtectedRouteProps {
|
|||||||
export const ProtectedRoute: React.FC<ProtectedRouteProps> = ({ children }) => {
|
export const ProtectedRoute: React.FC<ProtectedRouteProps> = ({ children }) => {
|
||||||
const { user, loading } = useAuth();
|
const { user, loading } = useAuth();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const params = useParams();
|
||||||
|
|
||||||
|
// Check if this is demo mode
|
||||||
|
const workspaceId = params?.workspaceId as string | undefined;
|
||||||
|
const isDemo = isDemoWorkspace(workspaceId);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
// Skip auth check for demo mode
|
||||||
|
if (isDemo) return;
|
||||||
|
|
||||||
if (!loading && !user) {
|
if (!loading && !user) {
|
||||||
router.push("/login");
|
router.push("/login");
|
||||||
}
|
}
|
||||||
}, [user, loading, router]);
|
}, [user, loading, router, isDemo]);
|
||||||
|
|
||||||
|
// Demo mode - skip auth entirely
|
||||||
|
if (isDemo) {
|
||||||
|
return <>{children}</>;
|
||||||
|
}
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
|
|||||||
59
components/demo-banner.tsx
Normal file
59
components/demo-banner.tsx
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Demo Banner Component
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
|
import { Sparkles, ArrowRight, X } from "lucide-react";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { BOT_USERNAME } from "@/lib/utils/constants";
|
||||||
|
|
||||||
|
interface DemoBannerProps {
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DemoBanner: React.FC<DemoBannerProps> = ({ className }) => {
|
||||||
|
const [isVisible, setIsVisible] = useState(true);
|
||||||
|
|
||||||
|
if (!isVisible) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`
|
||||||
|
relative w-full bg-gradient-to-r from-violet-600 via-fuchsia-500 to-pink-500
|
||||||
|
text-white py-2.5 px-4 text-center text-sm font-medium
|
||||||
|
shadow-lg shadow-fuchsia-500/20
|
||||||
|
${className || ""}
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-center gap-2 flex-wrap">
|
||||||
|
<Sparkles className="h-4 w-4 animate-pulse" />
|
||||||
|
<span>Вы просматриваете демо-версию платформы</span>
|
||||||
|
<span className="hidden sm:inline text-white/80">•</span>
|
||||||
|
<a
|
||||||
|
href={`https://t.me/${BOT_USERNAME}?start=register`}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="inline-flex items-center gap-1 font-semibold underline underline-offset-2 hover:text-white/90 transition-colors"
|
||||||
|
>
|
||||||
|
Создать свой кабинет
|
||||||
|
<ArrowRight className="h-3.5 w-3.5" />
|
||||||
|
</a>
|
||||||
|
<span className="text-white/60 hidden md:inline">
|
||||||
|
и опробовать всю мощь платформы!
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => setIsVisible(false)}
|
||||||
|
className="absolute right-2 top-1/2 -translate-y-1/2 p-1 rounded hover:bg-white/20 transition-colors"
|
||||||
|
aria-label="Закрыть баннер"
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
@@ -5,15 +5,25 @@
|
|||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
|
import { useParams } from "next/navigation";
|
||||||
import {
|
import {
|
||||||
BarChart3,
|
BarChart3,
|
||||||
|
Building2,
|
||||||
|
Check,
|
||||||
|
ChevronsUpDown,
|
||||||
|
ClipboardList,
|
||||||
|
ExternalLink,
|
||||||
Folder,
|
Folder,
|
||||||
|
HelpCircle,
|
||||||
LayoutDashboard,
|
LayoutDashboard,
|
||||||
Megaphone,
|
Megaphone,
|
||||||
|
MessageCircle,
|
||||||
|
Plus,
|
||||||
|
Settings,
|
||||||
ShoppingCart,
|
ShoppingCart,
|
||||||
TrendingUp,
|
Tv,
|
||||||
Settings2,
|
BookOpen,
|
||||||
ExternalLink,
|
type LucideIcon,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
|
||||||
import { NavMain } from "@/components/nav-main";
|
import { NavMain } from "@/components/nav-main";
|
||||||
@@ -23,126 +33,419 @@ import {
|
|||||||
SidebarContent,
|
SidebarContent,
|
||||||
SidebarFooter,
|
SidebarFooter,
|
||||||
SidebarHeader,
|
SidebarHeader,
|
||||||
|
SidebarMenu,
|
||||||
|
SidebarMenuButton,
|
||||||
|
SidebarMenuItem,
|
||||||
SidebarRail,
|
SidebarRail,
|
||||||
|
SidebarSeparator,
|
||||||
|
SidebarGroup,
|
||||||
|
SidebarGroupContent,
|
||||||
} from "@/components/ui/sidebar";
|
} from "@/components/ui/sidebar";
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuLabel,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from "@/components/ui/dropdown-menu";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
import { useAuth } from "@/components/auth/auth-provider";
|
import { useAuth } from "@/components/auth/auth-provider";
|
||||||
|
import { useWorkspace } from "@/components/providers/workspace-provider";
|
||||||
|
import { buildRoute, BOT_USERNAME } from "@/lib/utils/constants";
|
||||||
|
|
||||||
const data = {
|
// ============================================================================
|
||||||
navMain: [
|
// Types
|
||||||
{
|
// ============================================================================
|
||||||
title: "Главная",
|
|
||||||
url: "/",
|
interface NavItem {
|
||||||
icon: LayoutDashboard,
|
title: string;
|
||||||
isActive: true,
|
url: string;
|
||||||
},
|
icon?: LucideIcon;
|
||||||
{
|
isActive?: boolean;
|
||||||
title: "Внешние каналы",
|
requiredPermission?: "projects_read" | "placements_read" | "analytics_read" | "admin_full";
|
||||||
url: "/external-channels",
|
items?: {
|
||||||
icon: ExternalLink,
|
title: string;
|
||||||
items: [
|
url: string;
|
||||||
{
|
}[];
|
||||||
title: "Каталог",
|
}
|
||||||
url: "/external-channels",
|
|
||||||
},
|
interface FooterLink {
|
||||||
{
|
title: string;
|
||||||
title: "Импорт",
|
url: string;
|
||||||
url: "/external-channels/import",
|
icon: LucideIcon;
|
||||||
},
|
external?: boolean;
|
||||||
],
|
}
|
||||||
},
|
|
||||||
{
|
// ============================================================================
|
||||||
title: "Креативы",
|
// Footer Links Configuration
|
||||||
url: "/creatives",
|
// ============================================================================
|
||||||
icon: Folder,
|
|
||||||
items: [
|
const footerLinks: FooterLink[] = [
|
||||||
{
|
{
|
||||||
title: "Все креативы",
|
title: "Поддержка",
|
||||||
url: "/creatives",
|
url: `https://t.me/${BOT_USERNAME}`,
|
||||||
},
|
icon: MessageCircle,
|
||||||
{
|
external: true,
|
||||||
title: "Создать",
|
},
|
||||||
url: "/creatives/new",
|
{
|
||||||
},
|
title: "Информационный канал",
|
||||||
],
|
url: "https://t.me/tgex_news",
|
||||||
},
|
icon: Megaphone,
|
||||||
{
|
external: true,
|
||||||
title: "Закупы",
|
},
|
||||||
url: "/purchases",
|
{
|
||||||
icon: ShoppingCart,
|
title: "База знаний",
|
||||||
items: [
|
url: "https://docs.tgex.io",
|
||||||
{
|
icon: BookOpen,
|
||||||
title: "Все закупы",
|
external: true,
|
||||||
url: "/purchases",
|
},
|
||||||
},
|
];
|
||||||
{
|
|
||||||
title: "Создать",
|
// ============================================================================
|
||||||
url: "/purchases/new",
|
// Component
|
||||||
},
|
// ============================================================================
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "Аналитика",
|
|
||||||
url: "/analytics",
|
|
||||||
icon: BarChart3,
|
|
||||||
items: [
|
|
||||||
{
|
|
||||||
title: "Обзор",
|
|
||||||
url: "/analytics",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "Затраты",
|
|
||||||
url: "/analytics/costs",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "Целевые каналы",
|
|
||||||
url: "/analytics/target-channels",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "Креативы",
|
|
||||||
url: "/analytics/creatives",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
||||||
export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
|
export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
|
||||||
|
const params = useParams();
|
||||||
|
const workspaceId = params?.workspaceId as string;
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
|
const {
|
||||||
|
workspaces,
|
||||||
|
currentWorkspace,
|
||||||
|
setCurrentWorkspace,
|
||||||
|
hasPermission,
|
||||||
|
isAdmin,
|
||||||
|
createWorkspace,
|
||||||
|
} = useWorkspace();
|
||||||
|
|
||||||
|
const [showCreateDialog, setShowCreateDialog] = React.useState(false);
|
||||||
|
const [newWorkspaceName, setNewWorkspaceName] = React.useState("");
|
||||||
|
const [isCreating, setIsCreating] = React.useState(false);
|
||||||
|
|
||||||
|
// Build navigation items based on permissions
|
||||||
|
const navItems: NavItem[] = React.useMemo(() => {
|
||||||
|
if (!workspaceId) return [];
|
||||||
|
|
||||||
|
const items: NavItem[] = [
|
||||||
|
{
|
||||||
|
title: "Главная",
|
||||||
|
url: buildRoute.dashboard(workspaceId),
|
||||||
|
icon: LayoutDashboard,
|
||||||
|
isActive: true,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// Projects - always visible (contains instructions how to add projects via bot)
|
||||||
|
items.push({
|
||||||
|
title: "Проекты",
|
||||||
|
url: buildRoute.projects(workspaceId),
|
||||||
|
icon: Tv,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Channels catalog (available to all)
|
||||||
|
items.push({
|
||||||
|
title: "Каталог каналов",
|
||||||
|
url: buildRoute.channels(workspaceId),
|
||||||
|
icon: Building2,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Purchase Plans (requires placements_read or admin)
|
||||||
|
if (hasPermission("placements_read")) {
|
||||||
|
items.push({
|
||||||
|
title: "Планы закупок",
|
||||||
|
url: buildRoute.purchasePlans(workspaceId),
|
||||||
|
icon: ClipboardList,
|
||||||
|
requiredPermission: "placements_read",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Creatives (requires placements_read or admin)
|
||||||
|
if (hasPermission("placements_read")) {
|
||||||
|
items.push({
|
||||||
|
title: "Креативы",
|
||||||
|
url: buildRoute.creatives(workspaceId),
|
||||||
|
icon: Folder,
|
||||||
|
requiredPermission: "placements_read",
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
title: "Все креативы",
|
||||||
|
url: buildRoute.creatives(workspaceId),
|
||||||
|
},
|
||||||
|
...(hasPermission("placements_write")
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
title: "Создать",
|
||||||
|
url: buildRoute.creativeNew(workspaceId),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: []),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Placements (requires placements_read or admin)
|
||||||
|
if (hasPermission("placements_read")) {
|
||||||
|
items.push({
|
||||||
|
title: "Размещения",
|
||||||
|
url: buildRoute.placements(workspaceId),
|
||||||
|
icon: ShoppingCart,
|
||||||
|
requiredPermission: "placements_read",
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
title: "Все размещения",
|
||||||
|
url: buildRoute.placements(workspaceId),
|
||||||
|
},
|
||||||
|
...(hasPermission("placements_write")
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
title: "Создать",
|
||||||
|
url: buildRoute.placementNew(workspaceId),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: []),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Analytics (requires analytics_read or admin)
|
||||||
|
if (hasPermission("analytics_read")) {
|
||||||
|
items.push({
|
||||||
|
title: "Аналитика",
|
||||||
|
url: buildRoute.analytics(workspaceId),
|
||||||
|
icon: BarChart3,
|
||||||
|
requiredPermission: "analytics_read",
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
title: "Обзор",
|
||||||
|
url: buildRoute.analytics(workspaceId),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Расходы",
|
||||||
|
url: buildRoute.analyticsSpending(workspaceId),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Каналы",
|
||||||
|
url: buildRoute.analyticsChannels(workspaceId),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Креативы",
|
||||||
|
url: buildRoute.analyticsCreatives(workspaceId),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Settings (admin only)
|
||||||
|
if (isAdmin) {
|
||||||
|
items.push({
|
||||||
|
title: "Настройки",
|
||||||
|
url: buildRoute.settings(workspaceId),
|
||||||
|
icon: Settings,
|
||||||
|
requiredPermission: "admin_full",
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
title: "Участники",
|
||||||
|
url: buildRoute.members(workspaceId),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Приглашения",
|
||||||
|
url: buildRoute.invites(workspaceId),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return items;
|
||||||
|
}, [workspaceId, hasPermission, isAdmin]);
|
||||||
|
|
||||||
|
const handleCreateWorkspace = async () => {
|
||||||
|
if (!newWorkspaceName.trim()) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
setIsCreating(true);
|
||||||
|
const workspace = await createWorkspace(newWorkspaceName.trim());
|
||||||
|
setShowCreateDialog(false);
|
||||||
|
setNewWorkspaceName("");
|
||||||
|
setCurrentWorkspace(workspace);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to create workspace:", err);
|
||||||
|
} finally {
|
||||||
|
setIsCreating(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Sidebar collapsible="icon" {...props}>
|
<>
|
||||||
<SidebarHeader>
|
<Sidebar collapsible="icon" {...props}>
|
||||||
<div className="flex items-center gap-2 px-2 py-2">
|
<SidebarHeader>
|
||||||
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-primary text-primary-foreground">
|
{/* Workspace Switcher */}
|
||||||
<Megaphone className="h-4 w-4" />
|
<SidebarMenu>
|
||||||
|
<SidebarMenuItem>
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<SidebarMenuButton
|
||||||
|
size="lg"
|
||||||
|
className="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground"
|
||||||
|
>
|
||||||
|
<div className="flex aspect-square size-8 items-center justify-center rounded-lg bg-primary text-primary-foreground">
|
||||||
|
<Megaphone className="size-4" />
|
||||||
|
</div>
|
||||||
|
<div className="grid flex-1 text-left text-sm leading-tight">
|
||||||
|
<span className="truncate font-semibold">
|
||||||
|
{currentWorkspace?.name || "TGEX"}
|
||||||
|
</span>
|
||||||
|
<span className="truncate text-xs text-muted-foreground">
|
||||||
|
{workspaces.length} воркспейс{workspaces.length === 1 ? "" : workspaces.length < 5 ? "а" : "ов"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<ChevronsUpDown className="ml-auto" />
|
||||||
|
</SidebarMenuButton>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent
|
||||||
|
className="w-[--radix-dropdown-menu-trigger-width] min-w-56 rounded-lg"
|
||||||
|
align="start"
|
||||||
|
side="bottom"
|
||||||
|
sideOffset={4}
|
||||||
|
>
|
||||||
|
<DropdownMenuLabel className="text-xs text-muted-foreground">
|
||||||
|
Воркспейсы
|
||||||
|
</DropdownMenuLabel>
|
||||||
|
{workspaces.map((workspace) => (
|
||||||
|
<DropdownMenuItem
|
||||||
|
key={workspace.id}
|
||||||
|
onClick={() => setCurrentWorkspace(workspace)}
|
||||||
|
className="gap-2 p-2"
|
||||||
|
>
|
||||||
|
<div className="flex size-6 items-center justify-center rounded-sm border">
|
||||||
|
<Building2 className="size-4 shrink-0" />
|
||||||
|
</div>
|
||||||
|
<span className="flex-1 truncate">{workspace.name}</span>
|
||||||
|
{currentWorkspace?.id === workspace.id && (
|
||||||
|
<Check className="size-4 ml-auto" />
|
||||||
|
)}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
))}
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() => setShowCreateDialog(true)}
|
||||||
|
className="gap-2 p-2"
|
||||||
|
>
|
||||||
|
<div className="flex size-6 items-center justify-center rounded-md border bg-background">
|
||||||
|
<Plus className="size-4" />
|
||||||
|
</div>
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
Создать воркспейс
|
||||||
|
</span>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</SidebarMenuItem>
|
||||||
|
</SidebarMenu>
|
||||||
|
</SidebarHeader>
|
||||||
|
|
||||||
|
<SidebarContent>
|
||||||
|
<NavMain items={navItems} />
|
||||||
|
</SidebarContent>
|
||||||
|
|
||||||
|
<SidebarFooter>
|
||||||
|
{/* Footer Links */}
|
||||||
|
<SidebarGroup>
|
||||||
|
<SidebarGroupContent>
|
||||||
|
<SidebarMenu>
|
||||||
|
{footerLinks.map((link) => (
|
||||||
|
<SidebarMenuItem key={link.title}>
|
||||||
|
<SidebarMenuButton asChild size="sm">
|
||||||
|
<a
|
||||||
|
href={link.url}
|
||||||
|
target={link.external ? "_blank" : undefined}
|
||||||
|
rel={link.external ? "noopener noreferrer" : undefined}
|
||||||
|
className="flex items-center gap-2 text-muted-foreground hover:text-foreground"
|
||||||
|
>
|
||||||
|
<link.icon className="size-4" />
|
||||||
|
<span>{link.title}</span>
|
||||||
|
{link.external && (
|
||||||
|
<ExternalLink className="size-3 ml-auto opacity-50" />
|
||||||
|
)}
|
||||||
|
</a>
|
||||||
|
</SidebarMenuButton>
|
||||||
|
</SidebarMenuItem>
|
||||||
|
))}
|
||||||
|
</SidebarMenu>
|
||||||
|
</SidebarGroupContent>
|
||||||
|
</SidebarGroup>
|
||||||
|
|
||||||
|
<SidebarSeparator />
|
||||||
|
|
||||||
|
{/* User */}
|
||||||
|
{user && (
|
||||||
|
<NavUser
|
||||||
|
user={{
|
||||||
|
name: user.username || `User ${user.telegram_id}`,
|
||||||
|
email: user.username
|
||||||
|
? `@${user.username}`
|
||||||
|
: `ID: ${user.telegram_id}`,
|
||||||
|
avatar: user.username
|
||||||
|
? `https://ui-avatars.com/api/?name=${user.username}`
|
||||||
|
: `https://ui-avatars.com/api/?name=User`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</SidebarFooter>
|
||||||
|
<SidebarRail />
|
||||||
|
</Sidebar>
|
||||||
|
|
||||||
|
{/* Create Workspace Dialog */}
|
||||||
|
<Dialog open={showCreateDialog} onOpenChange={setShowCreateDialog}>
|
||||||
|
<DialogContent className="sm:max-w-md">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Создать воркспейс</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Воркспейс — это изолированное пространство для вашей команды
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="grid gap-4 py-4">
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label htmlFor="workspace-name">Название</Label>
|
||||||
|
<Input
|
||||||
|
id="workspace-name"
|
||||||
|
placeholder="Например: Моя команда"
|
||||||
|
value={newWorkspaceName}
|
||||||
|
onChange={(e) => setNewWorkspaceName(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter") {
|
||||||
|
handleCreateWorkspace();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid flex-1 text-left text-sm leading-tight">
|
<DialogFooter>
|
||||||
<span className="truncate font-semibold">TGEX</span>
|
<Button
|
||||||
<span className="truncate text-xs text-muted-foreground">
|
variant="outline"
|
||||||
Управление закупками
|
onClick={() => setShowCreateDialog(false)}
|
||||||
</span>
|
>
|
||||||
</div>
|
Отмена
|
||||||
</div>
|
</Button>
|
||||||
</SidebarHeader>
|
<Button
|
||||||
<SidebarContent>
|
onClick={handleCreateWorkspace}
|
||||||
<NavMain items={data.navMain} />
|
disabled={!newWorkspaceName.trim() || isCreating}
|
||||||
</SidebarContent>
|
>
|
||||||
<SidebarFooter>
|
{isCreating ? "Создание..." : "Создать"}
|
||||||
{user && (
|
</Button>
|
||||||
<NavUser
|
</DialogFooter>
|
||||||
user={{
|
</DialogContent>
|
||||||
name: user.username || `User ${user.telegram_id}`,
|
</Dialog>
|
||||||
email: user.username
|
</>
|
||||||
? `@${user.username}`
|
|
||||||
: `ID: ${user.telegram_id}`,
|
|
||||||
avatar: user.username
|
|
||||||
? `https://ui-avatars.com/api/?name=${user.username}`
|
|
||||||
: `https://ui-avatars.com/api/?name=User`,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</SidebarFooter>
|
|
||||||
<SidebarRail />
|
|
||||||
</Sidebar>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,20 +15,22 @@ import {
|
|||||||
BreadcrumbSeparator,
|
BreadcrumbSeparator,
|
||||||
} from "@/components/ui/breadcrumb";
|
} from "@/components/ui/breadcrumb";
|
||||||
import { ThemeToggle } from "@/components/theme-toggle";
|
import { ThemeToggle } from "@/components/theme-toggle";
|
||||||
import { TargetChannelSelector } from "@/components/target-channel-selector";
|
import { ProjectSelector } from "@/components/project-selector";
|
||||||
import React from "react";
|
import React from "react";
|
||||||
|
|
||||||
interface BreadcrumbItem {
|
interface BreadcrumbItemType {
|
||||||
label: string;
|
label: string;
|
||||||
href?: string;
|
href?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface DashboardHeaderProps {
|
interface DashboardHeaderProps {
|
||||||
breadcrumbs?: BreadcrumbItem[];
|
breadcrumbs?: BreadcrumbItemType[];
|
||||||
|
showProjectSelector?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const DashboardHeader: React.FC<DashboardHeaderProps> = ({
|
export const DashboardHeader: React.FC<DashboardHeaderProps> = ({
|
||||||
breadcrumbs = [],
|
breadcrumbs = [],
|
||||||
|
showProjectSelector = true,
|
||||||
}) => {
|
}) => {
|
||||||
return (
|
return (
|
||||||
<header className="flex h-16 shrink-0 items-center gap-2 border-b px-4">
|
<header className="flex h-16 shrink-0 items-center gap-2 border-b px-4">
|
||||||
@@ -42,9 +44,7 @@ export const DashboardHeader: React.FC<DashboardHeaderProps> = ({
|
|||||||
{index > 0 && <BreadcrumbSeparator />}
|
{index > 0 && <BreadcrumbSeparator />}
|
||||||
<BreadcrumbItem>
|
<BreadcrumbItem>
|
||||||
{item.href ? (
|
{item.href ? (
|
||||||
<BreadcrumbLink href={item.href}>
|
<BreadcrumbLink href={item.href}>{item.label}</BreadcrumbLink>
|
||||||
{item.label}
|
|
||||||
</BreadcrumbLink>
|
|
||||||
) : (
|
) : (
|
||||||
<BreadcrumbPage>{item.label}</BreadcrumbPage>
|
<BreadcrumbPage>{item.label}</BreadcrumbPage>
|
||||||
)}
|
)}
|
||||||
@@ -55,7 +55,7 @@ export const DashboardHeader: React.FC<DashboardHeaderProps> = ({
|
|||||||
</Breadcrumb>
|
</Breadcrumb>
|
||||||
)}
|
)}
|
||||||
<div className="ml-auto flex items-center gap-2">
|
<div className="ml-auto flex items-center gap-2">
|
||||||
<TargetChannelSelector />
|
{showProjectSelector && <ProjectSelector />}
|
||||||
<ThemeToggle />
|
<ThemeToggle />
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|||||||
26
components/layout/dashboard-layout-content.tsx
Normal file
26
components/layout/dashboard-layout-content.tsx
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Dashboard Layout Content
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
import { useWorkspace } from "@/components/providers/workspace-provider";
|
||||||
|
import { DemoBanner } from "@/components/demo-banner";
|
||||||
|
|
||||||
|
interface DashboardLayoutContentProps {
|
||||||
|
children: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DashboardLayoutContent: React.FC<DashboardLayoutContentProps> = ({
|
||||||
|
children,
|
||||||
|
}) => {
|
||||||
|
const { isDemoMode } = useWorkspace();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col h-full">
|
||||||
|
{isDemoMode && <DemoBanner />}
|
||||||
|
<div className="flex-1 overflow-auto">{children}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
147
components/project-selector.tsx
Normal file
147
components/project-selector.tsx
Normal file
@@ -0,0 +1,147 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Project Selector (Multi-select)
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
import { Check, ChevronsUpDown, Tv, X } from "lucide-react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
} from "@/components/ui/dropdown-menu";
|
||||||
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { useWorkspace } from "@/components/providers/workspace-provider";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
export const ProjectSelector: React.FC = () => {
|
||||||
|
const {
|
||||||
|
projects,
|
||||||
|
selectedProjects,
|
||||||
|
toggleProject,
|
||||||
|
selectAllProjects,
|
||||||
|
clearProjectSelection,
|
||||||
|
isLoadingProjects,
|
||||||
|
} = useWorkspace();
|
||||||
|
|
||||||
|
if (isLoadingProjects) {
|
||||||
|
return (
|
||||||
|
<div className="w-[240px] h-11 rounded-lg border border-input bg-background animate-pulse" />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (projects.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="text-sm text-muted-foreground px-3 py-2">
|
||||||
|
Нет проектов
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const allSelected = selectedProjects.length === projects.length;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="w-[280px] h-auto min-h-[44px] py-2 px-3 rounded-lg justify-between"
|
||||||
|
>
|
||||||
|
<div className="flex flex-col items-start text-left overflow-hidden">
|
||||||
|
{selectedProjects.length === 0 ? (
|
||||||
|
<span className="text-muted-foreground">Выберите проекты</span>
|
||||||
|
) : selectedProjects.length === 1 ? (
|
||||||
|
<>
|
||||||
|
<span className="font-semibold text-base truncate max-w-[220px]">
|
||||||
|
{selectedProjects[0].title}
|
||||||
|
</span>
|
||||||
|
{selectedProjects[0].username && (
|
||||||
|
<span className="text-xs text-muted-foreground -mt-0.5">
|
||||||
|
@{selectedProjects[0].username}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : allSelected ? (
|
||||||
|
<span className="font-semibold text-base">Все проекты</span>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<span className="font-semibold text-base">
|
||||||
|
{selectedProjects.length} проекта
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-muted-foreground -mt-0.5 truncate max-w-[220px]">
|
||||||
|
{selectedProjects.map((p) => p.title).join(", ")}
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<ChevronsUpDown className="h-4 w-4 shrink-0 opacity-50" />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
|
||||||
|
<DropdownMenuContent className="w-[280px] p-2" align="start">
|
||||||
|
{/* Quick actions */}
|
||||||
|
<div className="flex gap-2 mb-2">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="flex-1 text-xs"
|
||||||
|
onClick={selectAllProjects}
|
||||||
|
disabled={allSelected}
|
||||||
|
>
|
||||||
|
Выбрать все
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="flex-1 text-xs"
|
||||||
|
onClick={clearProjectSelection}
|
||||||
|
disabled={selectedProjects.length <= 1}
|
||||||
|
>
|
||||||
|
Сбросить
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
|
||||||
|
{/* Projects list */}
|
||||||
|
<div className="max-h-[300px] overflow-y-auto">
|
||||||
|
{projects.map((project) => {
|
||||||
|
const isSelected = selectedProjects.some((p) => p.id === project.id);
|
||||||
|
const isOnlyOne = selectedProjects.length === 1 && isSelected;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={project.id}
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-3 px-2 py-2 rounded-md cursor-pointer hover:bg-accent",
|
||||||
|
isSelected && "bg-accent/50"
|
||||||
|
)}
|
||||||
|
onClick={() => !isOnlyOne && toggleProject(project)}
|
||||||
|
>
|
||||||
|
<Checkbox
|
||||||
|
checked={isSelected}
|
||||||
|
disabled={isOnlyOne}
|
||||||
|
onCheckedChange={() => !isOnlyOne && toggleProject(project)}
|
||||||
|
className="shrink-0"
|
||||||
|
/>
|
||||||
|
<Tv className="h-4 w-4 text-muted-foreground shrink-0" />
|
||||||
|
<div className="flex flex-col min-w-0 flex-1">
|
||||||
|
<span className="font-medium truncate">{project.title}</span>
|
||||||
|
{project.username && (
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
@{project.username}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -2,5 +2,4 @@
|
|||||||
// Providers Export
|
// Providers Export
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
export { TargetChannelProvider, useTargetChannel } from "./target-channel-provider";
|
export { WorkspaceProvider, useWorkspace } from "./workspace-provider";
|
||||||
|
|
||||||
|
|||||||
@@ -1,101 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// Target Channel Provider
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
import React, { createContext, useContext, useState, useEffect } from "react";
|
|
||||||
import { channelsApi } from "@/lib/api";
|
|
||||||
import type { TargetChannel } from "@/lib/types/api";
|
|
||||||
|
|
||||||
interface TargetChannelContextType {
|
|
||||||
channels: TargetChannel[];
|
|
||||||
selectedChannel: TargetChannel | null;
|
|
||||||
setSelectedChannel: (channel: TargetChannel | null) => void;
|
|
||||||
isLoading: boolean;
|
|
||||||
error: string | null;
|
|
||||||
refreshChannels: () => Promise<void>;
|
|
||||||
}
|
|
||||||
|
|
||||||
const TargetChannelContext = createContext<TargetChannelContextType | undefined>(
|
|
||||||
undefined
|
|
||||||
);
|
|
||||||
|
|
||||||
export const useTargetChannel = () => {
|
|
||||||
const context = useContext(TargetChannelContext);
|
|
||||||
if (!context) {
|
|
||||||
throw new Error(
|
|
||||||
"useTargetChannel must be used within TargetChannelProvider"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return context;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const TargetChannelProvider: React.FC<{ children: React.ReactNode }> = ({
|
|
||||||
children,
|
|
||||||
}) => {
|
|
||||||
const [channels, setChannels] = useState<TargetChannel[]>([]);
|
|
||||||
const [selectedChannel, setSelectedChannelState] = useState<TargetChannel | null>(null);
|
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
|
|
||||||
const loadChannels = async () => {
|
|
||||||
try {
|
|
||||||
setIsLoading(true);
|
|
||||||
setError(null);
|
|
||||||
const response = await channelsApi.list();
|
|
||||||
const activeChannels = response.target_channels.filter((c) => c.is_active);
|
|
||||||
setChannels(activeChannels);
|
|
||||||
|
|
||||||
// Загружаем сохраненный канал из localStorage
|
|
||||||
const savedChannelId = localStorage.getItem("selected_channel_id");
|
|
||||||
if (savedChannelId && activeChannels.length > 0) {
|
|
||||||
const savedChannel = activeChannels.find((c) => c.id === savedChannelId);
|
|
||||||
if (savedChannel) {
|
|
||||||
setSelectedChannelState(savedChannel);
|
|
||||||
} else {
|
|
||||||
// Если сохраненный канал не найден, выбираем первый
|
|
||||||
setSelectedChannelState(activeChannels[0]);
|
|
||||||
localStorage.setItem("selected_channel_id", activeChannels[0].id);
|
|
||||||
}
|
|
||||||
} else if (activeChannels.length > 0) {
|
|
||||||
// Если нет сохраненного канала, выбираем первый
|
|
||||||
setSelectedChannelState(activeChannels[0]);
|
|
||||||
localStorage.setItem("selected_channel_id", activeChannels[0].id);
|
|
||||||
}
|
|
||||||
} catch (err: any) {
|
|
||||||
setError(err?.error?.message || "Ошибка загрузки каналов");
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const setSelectedChannel = (channel: TargetChannel | null) => {
|
|
||||||
setSelectedChannelState(channel);
|
|
||||||
if (channel) {
|
|
||||||
localStorage.setItem("selected_channel_id", channel.id);
|
|
||||||
} else {
|
|
||||||
localStorage.removeItem("selected_channel_id");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
loadChannels();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<TargetChannelContext.Provider
|
|
||||||
value={{
|
|
||||||
channels,
|
|
||||||
selectedChannel,
|
|
||||||
setSelectedChannel,
|
|
||||||
isLoading,
|
|
||||||
error,
|
|
||||||
refreshChannels: loadChannels,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</TargetChannelContext.Provider>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
468
components/providers/workspace-provider.tsx
Normal file
468
components/providers/workspace-provider.tsx
Normal file
@@ -0,0 +1,468 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Workspace Provider
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
import React, {
|
||||||
|
createContext,
|
||||||
|
useContext,
|
||||||
|
useState,
|
||||||
|
useEffect,
|
||||||
|
useCallback,
|
||||||
|
} from "react";
|
||||||
|
import { useParams, useRouter, usePathname } from "next/navigation";
|
||||||
|
import { workspacesApi, projectsApi, membersApi, authApi } from "@/lib/api";
|
||||||
|
import type {
|
||||||
|
Workspace,
|
||||||
|
Project,
|
||||||
|
Permission,
|
||||||
|
PermissionKey,
|
||||||
|
} from "@/lib/types/api";
|
||||||
|
import { STORAGE_KEYS } from "@/lib/utils/constants";
|
||||||
|
import { isDemoWorkspace, demoApi, demoWorkspace, demoProjects } from "@/lib/demo";
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Types
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
interface WorkspaceContextType {
|
||||||
|
// Workspaces
|
||||||
|
workspaces: Workspace[];
|
||||||
|
currentWorkspace: Workspace | null;
|
||||||
|
setCurrentWorkspace: (workspace: Workspace) => void;
|
||||||
|
|
||||||
|
// Projects (Target Channels)
|
||||||
|
projects: Project[];
|
||||||
|
selectedProjects: Project[];
|
||||||
|
currentProject: Project | null; // First selected or null (for backward compatibility)
|
||||||
|
setSelectedProjects: (projects: Project[]) => void;
|
||||||
|
toggleProject: (project: Project) => void;
|
||||||
|
selectAllProjects: () => void;
|
||||||
|
clearProjectSelection: () => void;
|
||||||
|
|
||||||
|
// Helper for API filtering
|
||||||
|
// Returns project_id only if single project selected, undefined otherwise
|
||||||
|
getProjectFilterId: () => string | undefined;
|
||||||
|
// Returns true if all projects are selected
|
||||||
|
allProjectsSelected: boolean;
|
||||||
|
|
||||||
|
// Demo mode
|
||||||
|
isDemoMode: boolean;
|
||||||
|
|
||||||
|
// Permissions
|
||||||
|
permissions: Permission[];
|
||||||
|
hasPermission: (key: PermissionKey, projectId?: string) => boolean;
|
||||||
|
isAdmin: boolean;
|
||||||
|
|
||||||
|
// Loading states
|
||||||
|
isLoading: boolean;
|
||||||
|
isLoadingProjects: boolean;
|
||||||
|
error: string | null;
|
||||||
|
|
||||||
|
// Actions
|
||||||
|
refreshWorkspaces: () => Promise<Workspace[]>;
|
||||||
|
refreshProjects: () => Promise<void>;
|
||||||
|
createWorkspace: (name: string) => Promise<Workspace>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const WorkspaceContext = createContext<WorkspaceContextType | undefined>(
|
||||||
|
undefined
|
||||||
|
);
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Hook
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export const useWorkspace = () => {
|
||||||
|
const context = useContext(WorkspaceContext);
|
||||||
|
if (!context) {
|
||||||
|
throw new Error("useWorkspace must be used within WorkspaceProvider");
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
};
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Provider
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
interface WorkspaceProviderProps {
|
||||||
|
children: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const WorkspaceProvider: React.FC<WorkspaceProviderProps> = ({
|
||||||
|
children,
|
||||||
|
}) => {
|
||||||
|
const params = useParams();
|
||||||
|
const router = useRouter();
|
||||||
|
const pathname = usePathname();
|
||||||
|
|
||||||
|
// State
|
||||||
|
const [workspaces, setWorkspaces] = useState<Workspace[]>([]);
|
||||||
|
const [currentWorkspace, setCurrentWorkspaceState] = useState<Workspace | null>(null);
|
||||||
|
const [projects, setProjects] = useState<Project[]>([]);
|
||||||
|
const [selectedProjects, setSelectedProjectsState] = useState<Project[]>([]);
|
||||||
|
const [permissions, setPermissions] = useState<Permission[]>([]);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const [isLoadingProjects, setIsLoadingProjects] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Get workspaceId from URL params
|
||||||
|
const workspaceIdFromUrl = params?.workspaceId as string | undefined;
|
||||||
|
|
||||||
|
// Check if demo mode
|
||||||
|
const isDemoMode = isDemoWorkspace(workspaceIdFromUrl);
|
||||||
|
|
||||||
|
// Current project (first selected for backward compatibility)
|
||||||
|
const currentProject = selectedProjects.length > 0 ? selectedProjects[0] : null;
|
||||||
|
|
||||||
|
// Check if all projects are selected
|
||||||
|
const allProjectsSelected =
|
||||||
|
projects.length > 0 && selectedProjects.length === projects.length;
|
||||||
|
|
||||||
|
// Get project_id for API filtering
|
||||||
|
// Returns undefined if multiple projects selected (to get workspace-wide data)
|
||||||
|
const getProjectFilterId = useCallback(() => {
|
||||||
|
if (selectedProjects.length === 1) {
|
||||||
|
return selectedProjects[0].id;
|
||||||
|
}
|
||||||
|
// Multiple projects selected - return undefined to get all data
|
||||||
|
return undefined;
|
||||||
|
}, [selectedProjects]);
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Permission Helpers
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
const isAdmin = permissions.some((p) => p.key === "admin_full");
|
||||||
|
|
||||||
|
const hasPermission = useCallback(
|
||||||
|
(key: PermissionKey, projectId?: string): boolean => {
|
||||||
|
// Admin has all permissions
|
||||||
|
if (isAdmin) return true;
|
||||||
|
|
||||||
|
// Find permission by key
|
||||||
|
const permission = permissions.find((p) => p.key === key);
|
||||||
|
if (!permission) return false;
|
||||||
|
|
||||||
|
// If no scopes defined, permission applies globally
|
||||||
|
if (!permission.scopes || permission.scopes.length === 0) return true;
|
||||||
|
|
||||||
|
// If projectId provided, check if it's in scopes
|
||||||
|
if (projectId) {
|
||||||
|
return permission.scopes.some(
|
||||||
|
(s) => s.type === "project" && s.id === projectId
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Permission exists but is scoped - need projectId to verify
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
[permissions, isAdmin]
|
||||||
|
);
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Load Workspaces
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
const loadWorkspaces = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
setIsLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
// Demo mode - use mock data
|
||||||
|
if (isDemoMode) {
|
||||||
|
setWorkspaces([demoWorkspace]);
|
||||||
|
setCurrentWorkspaceState(demoWorkspace);
|
||||||
|
setIsLoading(false);
|
||||||
|
return [demoWorkspace];
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await workspacesApi.list({ size: 100 });
|
||||||
|
setWorkspaces(response.items);
|
||||||
|
|
||||||
|
// If we have workspaceId in URL, select that workspace
|
||||||
|
if (workspaceIdFromUrl && response.items.length > 0) {
|
||||||
|
const workspace = response.items.find((w) => w.id === workspaceIdFromUrl);
|
||||||
|
if (workspace) {
|
||||||
|
setCurrentWorkspaceState(workspace);
|
||||||
|
localStorage.setItem(STORAGE_KEYS.SELECTED_WORKSPACE, workspace.id);
|
||||||
|
} else {
|
||||||
|
// Workspace from URL not found, redirect to first available
|
||||||
|
const firstWorkspace = response.items[0];
|
||||||
|
setCurrentWorkspaceState(firstWorkspace);
|
||||||
|
localStorage.setItem(STORAGE_KEYS.SELECTED_WORKSPACE, firstWorkspace.id);
|
||||||
|
// Redirect to correct workspace
|
||||||
|
const newPath = pathname.replace(
|
||||||
|
`/dashboard/${workspaceIdFromUrl}`,
|
||||||
|
`/dashboard/${firstWorkspace.id}`
|
||||||
|
);
|
||||||
|
router.replace(newPath);
|
||||||
|
}
|
||||||
|
} else if (response.items.length > 0) {
|
||||||
|
// Try to restore from localStorage or use first
|
||||||
|
const savedId = localStorage.getItem(STORAGE_KEYS.SELECTED_WORKSPACE);
|
||||||
|
const saved = savedId
|
||||||
|
? response.items.find((w) => w.id === savedId)
|
||||||
|
: null;
|
||||||
|
const workspace = saved || response.items[0];
|
||||||
|
setCurrentWorkspaceState(workspace);
|
||||||
|
localStorage.setItem(STORAGE_KEYS.SELECTED_WORKSPACE, workspace.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.items;
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error("Failed to load workspaces:", err);
|
||||||
|
setError(err?.error?.message || "Ошибка загрузки воркспейсов");
|
||||||
|
return [];
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}, [workspaceIdFromUrl, pathname, router, isDemoMode]);
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Load Projects & Permissions
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
const loadProjectsAndPermissions = useCallback(async (workspaceId: string) => {
|
||||||
|
try {
|
||||||
|
setIsLoadingProjects(true);
|
||||||
|
|
||||||
|
// Demo mode - use mock data
|
||||||
|
if (isDemoMode) {
|
||||||
|
const activeProjects = demoProjects.filter((p) => p.status === "active");
|
||||||
|
setProjects(activeProjects);
|
||||||
|
setPermissions([{ key: "admin_full" }]);
|
||||||
|
setSelectedProjectsState(activeProjects.length > 0 ? [activeProjects[0]] : []);
|
||||||
|
setIsLoadingProjects(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load projects
|
||||||
|
const projectsResponse = await projectsApi.list(workspaceId, { size: 100 });
|
||||||
|
const activeProjects = projectsResponse.items.filter(
|
||||||
|
(p) => p.status === "active"
|
||||||
|
);
|
||||||
|
setProjects(activeProjects);
|
||||||
|
|
||||||
|
// Load current user's permissions
|
||||||
|
const me = await authApi.me();
|
||||||
|
const membersResponse = await membersApi.list(workspaceId, { size: 100 });
|
||||||
|
// Find member by user.id (not by member record id)
|
||||||
|
const currentMember = membersResponse.items.find((m) => m.user.id === me.id);
|
||||||
|
|
||||||
|
if (currentMember) {
|
||||||
|
// If member found but no permissions, they're likely the creator - grant admin
|
||||||
|
if (currentMember.permissions.length === 0) {
|
||||||
|
setPermissions([{ key: "admin_full" }]);
|
||||||
|
} else {
|
||||||
|
setPermissions(currentMember.permissions);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// User not in members list - might be creator, check if only workspace
|
||||||
|
// Grant admin access by default for workspace creators
|
||||||
|
if (membersResponse.items.length === 0) {
|
||||||
|
setPermissions([{ key: "admin_full" }]);
|
||||||
|
} else {
|
||||||
|
setPermissions([]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Restore selected projects from localStorage
|
||||||
|
const savedProjectIds = localStorage.getItem(
|
||||||
|
`${STORAGE_KEYS.SELECTED_PROJECT}_${workspaceId}`
|
||||||
|
);
|
||||||
|
if (savedProjectIds && activeProjects.length > 0) {
|
||||||
|
try {
|
||||||
|
const ids = JSON.parse(savedProjectIds) as string[];
|
||||||
|
const savedProjects = activeProjects.filter((p) => ids.includes(p.id));
|
||||||
|
if (savedProjects.length > 0) {
|
||||||
|
setSelectedProjectsState(savedProjects);
|
||||||
|
} else if (activeProjects.length > 0) {
|
||||||
|
setSelectedProjectsState([activeProjects[0]]);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Old format (single id), migrate to array
|
||||||
|
const savedProject = activeProjects.find((p) => p.id === savedProjectIds);
|
||||||
|
if (savedProject) {
|
||||||
|
setSelectedProjectsState([savedProject]);
|
||||||
|
} else if (activeProjects.length > 0) {
|
||||||
|
setSelectedProjectsState([activeProjects[0]]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (activeProjects.length > 0) {
|
||||||
|
setSelectedProjectsState([activeProjects[0]]);
|
||||||
|
} else {
|
||||||
|
setSelectedProjectsState([]);
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error("Failed to load projects/permissions:", err);
|
||||||
|
} finally {
|
||||||
|
setIsLoadingProjects(false);
|
||||||
|
}
|
||||||
|
}, [isDemoMode]);
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Set Current Workspace
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
const setCurrentWorkspace = useCallback(
|
||||||
|
(workspace: Workspace) => {
|
||||||
|
setCurrentWorkspaceState(workspace);
|
||||||
|
localStorage.setItem(STORAGE_KEYS.SELECTED_WORKSPACE, workspace.id);
|
||||||
|
|
||||||
|
// Update URL if we're in dashboard
|
||||||
|
if (pathname.startsWith("/dashboard/")) {
|
||||||
|
const currentWorkspaceId = workspaceIdFromUrl;
|
||||||
|
if (currentWorkspaceId && currentWorkspaceId !== workspace.id) {
|
||||||
|
const newPath = pathname.replace(
|
||||||
|
`/dashboard/${currentWorkspaceId}`,
|
||||||
|
`/dashboard/${workspace.id}`
|
||||||
|
);
|
||||||
|
router.push(newPath);
|
||||||
|
}
|
||||||
|
} else if (pathname === "/dashboard" || pathname === "/") {
|
||||||
|
router.push(`/dashboard/${workspace.id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load projects for new workspace
|
||||||
|
loadProjectsAndPermissions(workspace.id);
|
||||||
|
},
|
||||||
|
[pathname, router, workspaceIdFromUrl, loadProjectsAndPermissions]
|
||||||
|
);
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Project Selection Methods
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
const saveSelectedProjects = useCallback(
|
||||||
|
(selected: Project[]) => {
|
||||||
|
if (currentWorkspace) {
|
||||||
|
localStorage.setItem(
|
||||||
|
`${STORAGE_KEYS.SELECTED_PROJECT}_${currentWorkspace.id}`,
|
||||||
|
JSON.stringify(selected.map((p) => p.id))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[currentWorkspace]
|
||||||
|
);
|
||||||
|
|
||||||
|
const setSelectedProjects = useCallback(
|
||||||
|
(selected: Project[]) => {
|
||||||
|
setSelectedProjectsState(selected);
|
||||||
|
saveSelectedProjects(selected);
|
||||||
|
},
|
||||||
|
[saveSelectedProjects]
|
||||||
|
);
|
||||||
|
|
||||||
|
const toggleProject = useCallback(
|
||||||
|
(project: Project) => {
|
||||||
|
setSelectedProjectsState((prev) => {
|
||||||
|
const isSelected = prev.some((p) => p.id === project.id);
|
||||||
|
let newSelection: Project[];
|
||||||
|
|
||||||
|
if (isSelected) {
|
||||||
|
// Don't allow deselecting if it's the only one
|
||||||
|
if (prev.length <= 1) {
|
||||||
|
return prev;
|
||||||
|
}
|
||||||
|
newSelection = prev.filter((p) => p.id !== project.id);
|
||||||
|
} else {
|
||||||
|
newSelection = [...prev, project];
|
||||||
|
}
|
||||||
|
|
||||||
|
saveSelectedProjects(newSelection);
|
||||||
|
return newSelection;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[saveSelectedProjects]
|
||||||
|
);
|
||||||
|
|
||||||
|
const selectAllProjects = useCallback(() => {
|
||||||
|
setSelectedProjectsState(projects);
|
||||||
|
saveSelectedProjects(projects);
|
||||||
|
}, [projects, saveSelectedProjects]);
|
||||||
|
|
||||||
|
const clearProjectSelection = useCallback(() => {
|
||||||
|
if (projects.length > 0) {
|
||||||
|
const firstProject = [projects[0]];
|
||||||
|
setSelectedProjectsState(firstProject);
|
||||||
|
saveSelectedProjects(firstProject);
|
||||||
|
}
|
||||||
|
}, [projects, saveSelectedProjects]);
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Create Workspace
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
const createWorkspace = useCallback(async (name: string): Promise<Workspace> => {
|
||||||
|
const workspace = await workspacesApi.create({ name });
|
||||||
|
setWorkspaces((prev) => [...prev, workspace]);
|
||||||
|
return workspace;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Effects
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
// Initial load
|
||||||
|
useEffect(() => {
|
||||||
|
loadWorkspaces();
|
||||||
|
}, [loadWorkspaces]);
|
||||||
|
|
||||||
|
// Load projects when workspace changes
|
||||||
|
useEffect(() => {
|
||||||
|
if (currentWorkspace) {
|
||||||
|
loadProjectsAndPermissions(currentWorkspace.id);
|
||||||
|
}
|
||||||
|
}, [currentWorkspace?.id, loadProjectsAndPermissions]);
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Render
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
return (
|
||||||
|
<WorkspaceContext.Provider
|
||||||
|
value={{
|
||||||
|
// Workspaces
|
||||||
|
workspaces,
|
||||||
|
currentWorkspace,
|
||||||
|
setCurrentWorkspace,
|
||||||
|
|
||||||
|
// Projects
|
||||||
|
projects,
|
||||||
|
selectedProjects,
|
||||||
|
currentProject,
|
||||||
|
setSelectedProjects,
|
||||||
|
toggleProject,
|
||||||
|
selectAllProjects,
|
||||||
|
clearProjectSelection,
|
||||||
|
getProjectFilterId,
|
||||||
|
allProjectsSelected,
|
||||||
|
|
||||||
|
// Demo mode
|
||||||
|
isDemoMode,
|
||||||
|
|
||||||
|
// Permissions
|
||||||
|
permissions,
|
||||||
|
hasPermission,
|
||||||
|
isAdmin,
|
||||||
|
|
||||||
|
// Loading states
|
||||||
|
isLoading,
|
||||||
|
isLoadingProjects,
|
||||||
|
error,
|
||||||
|
|
||||||
|
// Actions
|
||||||
|
refreshWorkspaces: loadWorkspaces,
|
||||||
|
refreshProjects: () =>
|
||||||
|
currentWorkspace
|
||||||
|
? loadProjectsAndPermissions(currentWorkspace.id)
|
||||||
|
: Promise.resolve(),
|
||||||
|
createWorkspace,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</WorkspaceContext.Provider>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,276 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import React, { useState, useEffect, useRef } from "react";
|
|
||||||
import {
|
|
||||||
Select,
|
|
||||||
SelectContent,
|
|
||||||
SelectItem,
|
|
||||||
SelectTrigger,
|
|
||||||
SelectValue,
|
|
||||||
} from "@/components/ui/select";
|
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogDescription,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
} from "@/components/ui/dialog";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { useTargetChannel } from "@/components/providers/target-channel-provider";
|
|
||||||
import {
|
|
||||||
Loader2,
|
|
||||||
AlertCircle,
|
|
||||||
Plus,
|
|
||||||
ExternalLink,
|
|
||||||
CheckCircle2,
|
|
||||||
} from "lucide-react";
|
|
||||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
|
||||||
import { BOT_USERNAME } from "@/lib/utils/constants";
|
|
||||||
import { channelsApi } from "@/lib/api";
|
|
||||||
|
|
||||||
export const TargetChannelSelector: React.FC = () => {
|
|
||||||
const { channels, selectedChannel, setSelectedChannel, isLoading, error } =
|
|
||||||
useTargetChannel();
|
|
||||||
const [isAddDialogOpen, setIsAddDialogOpen] = useState(false);
|
|
||||||
const [isSuccess, setIsSuccess] = useState(false);
|
|
||||||
const [initialChannelCount, setInitialChannelCount] = useState(0);
|
|
||||||
const pollingIntervalRef = useRef<NodeJS.Timeout | null>(null);
|
|
||||||
|
|
||||||
// Очистка интервала при размонтировании
|
|
||||||
useEffect(() => {
|
|
||||||
return () => {
|
|
||||||
if (pollingIntervalRef.current) {
|
|
||||||
clearInterval(pollingIntervalRef.current);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Автоматический запуск polling при открытии диалога
|
|
||||||
useEffect(() => {
|
|
||||||
if (isAddDialogOpen && !isSuccess && initialChannelCount >= 0) {
|
|
||||||
pollingIntervalRef.current = setInterval(() => {
|
|
||||||
checkForNewChannels();
|
|
||||||
}, 1000);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
if (pollingIntervalRef.current) {
|
|
||||||
clearInterval(pollingIntervalRef.current);
|
|
||||||
pollingIntervalRef.current = null;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}, [isAddDialogOpen, isSuccess, initialChannelCount]);
|
|
||||||
|
|
||||||
const checkForNewChannels = async () => {
|
|
||||||
try {
|
|
||||||
const response = await channelsApi.list();
|
|
||||||
const activeChannels = response.target_channels.filter(
|
|
||||||
(c) => c.is_active
|
|
||||||
);
|
|
||||||
|
|
||||||
if (activeChannels.length > initialChannelCount) {
|
|
||||||
setIsSuccess(true);
|
|
||||||
|
|
||||||
if (pollingIntervalRef.current) {
|
|
||||||
clearInterval(pollingIntervalRef.current);
|
|
||||||
pollingIntervalRef.current = null;
|
|
||||||
}
|
|
||||||
setTimeout(() => {
|
|
||||||
setIsAddDialogOpen(false);
|
|
||||||
setIsSuccess(false);
|
|
||||||
window.location.reload();
|
|
||||||
}, 2000);
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error("Error polling channels:", err);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleAddChannel = () => {
|
|
||||||
setInitialChannelCount(channels.length);
|
|
||||||
setIsSuccess(false);
|
|
||||||
setIsAddDialogOpen(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDialogOpenChange = (open: boolean) => {
|
|
||||||
if (!open) {
|
|
||||||
// Разрешаем закрытие всегда (в том числе через кнопку крестик)
|
|
||||||
setIsAddDialogOpen(false);
|
|
||||||
setIsSuccess(false);
|
|
||||||
if (pollingIntervalRef.current) {
|
|
||||||
clearInterval(pollingIntervalRef.current);
|
|
||||||
pollingIntervalRef.current = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if (isLoading) {
|
|
||||||
return (
|
|
||||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
|
||||||
<Loader2 className="h-4 w-4 animate-spin" />
|
|
||||||
Загрузка каналов...
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (error) {
|
|
||||||
return (
|
|
||||||
<Alert variant="destructive" className="p-2 h-auto">
|
|
||||||
<AlertCircle className="h-4 w-4" />
|
|
||||||
<AlertDescription className="text-xs">Ошибка: {error}</AlertDescription>
|
|
||||||
</Alert>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (channels.length === 0) {
|
|
||||||
return (
|
|
||||||
<Button variant="outline" size="sm" onClick={handleAddChannel}>
|
|
||||||
<Plus className="h-4 w-4 mr-2" />
|
|
||||||
Добавить канал
|
|
||||||
</Button>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<Select
|
|
||||||
value={selectedChannel?.id || "add-new"}
|
|
||||||
onValueChange={(value) => {
|
|
||||||
if (value === "add-new") {
|
|
||||||
handleAddChannel();
|
|
||||||
} else {
|
|
||||||
const channel = channels.find((c) => c.id === value);
|
|
||||||
if (channel) {
|
|
||||||
setSelectedChannel(channel);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<SelectTrigger className="w-[260px] h-auto min-h-[52px] py-2 rounded-lg">
|
|
||||||
<SelectValue placeholder="Выберите канал">
|
|
||||||
{selectedChannel && (
|
|
||||||
<div className="flex flex-col items-start gap-0.5">
|
|
||||||
<span className="font-semibold text-base">
|
|
||||||
{selectedChannel.title}
|
|
||||||
</span>
|
|
||||||
{selectedChannel.username && (
|
|
||||||
<span className="text-xs text-muted-foreground -mt-1">
|
|
||||||
@{selectedChannel.username}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</SelectValue>
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{channels.map((channel) => (
|
|
||||||
<SelectItem
|
|
||||||
key={channel.id}
|
|
||||||
value={channel.id}
|
|
||||||
className="cursor-pointer py-3"
|
|
||||||
>
|
|
||||||
<div className="flex items-center justify-between w-full gap-2">
|
|
||||||
<div className="flex flex-col items-start flex-1 min-w-0 gap-0.5">
|
|
||||||
<span className="font-semibold text-base truncate w-full">
|
|
||||||
{channel.title}
|
|
||||||
</span>
|
|
||||||
{channel.username && (
|
|
||||||
<span className="text-xs text-muted-foreground flex items-center gap-1">
|
|
||||||
@{channel.username}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
<SelectItem
|
|
||||||
value="add-new"
|
|
||||||
className="cursor-pointer border-t mt-1 py-3"
|
|
||||||
>
|
|
||||||
<div className="flex items-center gap-2 text-primary">
|
|
||||||
<Plus className="h-4 w-4" />
|
|
||||||
<span className="font-medium">Добавить канал</span>
|
|
||||||
</div>
|
|
||||||
</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
|
|
||||||
<Dialog open={isAddDialogOpen} onOpenChange={handleDialogOpenChange}>
|
|
||||||
<DialogContent
|
|
||||||
className="sm:max-w-md"
|
|
||||||
onInteractOutside={(e) => !isSuccess && e.preventDefault()}
|
|
||||||
onEscapeKeyDown={(e) => !isSuccess && e.preventDefault()}
|
|
||||||
>
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>Добавить целевой канал</DialogTitle>
|
|
||||||
<DialogDescription>
|
|
||||||
Следуйте инструкциям ниже, чтобы подключить новый канал
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
|
|
||||||
{isSuccess ? (
|
|
||||||
<div className="flex flex-col items-center justify-center py-8 space-y-4">
|
|
||||||
<div className="rounded-full bg-green-100 dark:bg-green-900 p-3">
|
|
||||||
<CheckCircle2 className="h-8 w-8 text-green-600 dark:text-green-400" />
|
|
||||||
</div>
|
|
||||||
<div className="text-center">
|
|
||||||
<h3 className="text-lg font-semibold">
|
|
||||||
Канал успешно добавлен!
|
|
||||||
</h3>
|
|
||||||
<p className="text-sm text-muted-foreground mt-1">
|
|
||||||
Страница обновится автоматически...
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="space-y-4">
|
|
||||||
<Alert>
|
|
||||||
<AlertDescription className="space-y-2">
|
|
||||||
<p className="font-medium">Шаги для добавления канала:</p>
|
|
||||||
<ol className="list-decimal list-inside space-y-1.5 text-sm">
|
|
||||||
<li>Откройте свой Telegram канал</li>
|
|
||||||
<li>Перейдите в настройки канала → Администраторы</li>
|
|
||||||
<li>
|
|
||||||
Добавьте бота{" "}
|
|
||||||
<a
|
|
||||||
href={`https://t.me/${BOT_USERNAME}`}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="font-mono text-primary hover:underline inline-flex items-center gap-1"
|
|
||||||
tabIndex={0}
|
|
||||||
aria-label={`Открыть бота @${BOT_USERNAME} в Telegram`}
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
onKeyDown={(e) => e.stopPropagation()}
|
|
||||||
>
|
|
||||||
@{BOT_USERNAME}
|
|
||||||
<ExternalLink className="h-3 w-3" />
|
|
||||||
</a>{" "}
|
|
||||||
как администратора
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
Предоставьте боту права:{" "}
|
|
||||||
<span className="font-medium">
|
|
||||||
Публикация сообщений, Удаление сообщений
|
|
||||||
</span>
|
|
||||||
</li>
|
|
||||||
</ol>
|
|
||||||
</AlertDescription>
|
|
||||||
</Alert>
|
|
||||||
|
|
||||||
<div className="flex flex-col items-center justify-center py-6 space-y-3">
|
|
||||||
<Loader2 className="h-10 w-10 animate-spin text-primary" />
|
|
||||||
<p className="text-sm text-muted-foreground text-center">
|
|
||||||
Ждем добавления бота...
|
|
||||||
<br />
|
|
||||||
<span className="text-xs">
|
|
||||||
Как только вы добавите бота, канал автоматически появится
|
|
||||||
</span>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
142
components/ui/alert-dialog.tsx
Normal file
142
components/ui/alert-dialog.tsx
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { buttonVariants } from "@/components/ui/button"
|
||||||
|
|
||||||
|
const AlertDialog = AlertDialogPrimitive.Root
|
||||||
|
|
||||||
|
const AlertDialogTrigger = AlertDialogPrimitive.Trigger
|
||||||
|
|
||||||
|
const AlertDialogPortal = AlertDialogPrimitive.Portal
|
||||||
|
|
||||||
|
const AlertDialogOverlay = React.forwardRef<
|
||||||
|
React.ElementRef<typeof AlertDialogPrimitive.Overlay>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Overlay>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<AlertDialogPrimitive.Overlay
|
||||||
|
className={cn(
|
||||||
|
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
ref={ref}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName
|
||||||
|
|
||||||
|
const AlertDialogContent = React.forwardRef<
|
||||||
|
React.ElementRef<typeof AlertDialogPrimitive.Content>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Content>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<AlertDialogPortal>
|
||||||
|
<AlertDialogOverlay />
|
||||||
|
<AlertDialogPrimitive.Content
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</AlertDialogPortal>
|
||||||
|
))
|
||||||
|
AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName
|
||||||
|
|
||||||
|
const AlertDialogHeader = ({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex flex-col space-y-2 text-center sm:text-left",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
AlertDialogHeader.displayName = "AlertDialogHeader"
|
||||||
|
|
||||||
|
const AlertDialogFooter = ({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
AlertDialogFooter.displayName = "AlertDialogFooter"
|
||||||
|
|
||||||
|
const AlertDialogTitle = React.forwardRef<
|
||||||
|
React.ElementRef<typeof AlertDialogPrimitive.Title>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Title>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<AlertDialogPrimitive.Title
|
||||||
|
ref={ref}
|
||||||
|
className={cn("text-lg font-semibold", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName
|
||||||
|
|
||||||
|
const AlertDialogDescription = React.forwardRef<
|
||||||
|
React.ElementRef<typeof AlertDialogPrimitive.Description>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Description>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<AlertDialogPrimitive.Description
|
||||||
|
ref={ref}
|
||||||
|
className={cn("text-sm text-muted-foreground", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
AlertDialogDescription.displayName =
|
||||||
|
AlertDialogPrimitive.Description.displayName
|
||||||
|
|
||||||
|
const AlertDialogAction = React.forwardRef<
|
||||||
|
React.ElementRef<typeof AlertDialogPrimitive.Action>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Action>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<AlertDialogPrimitive.Action
|
||||||
|
ref={ref}
|
||||||
|
className={cn(buttonVariants(), className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName
|
||||||
|
|
||||||
|
const AlertDialogCancel = React.forwardRef<
|
||||||
|
React.ElementRef<typeof AlertDialogPrimitive.Cancel>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Cancel>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<AlertDialogPrimitive.Cancel
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
buttonVariants({ variant: "outline" }),
|
||||||
|
"mt-2 sm:mt-0",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName
|
||||||
|
|
||||||
|
export {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogPortal,
|
||||||
|
AlertDialogOverlay,
|
||||||
|
AlertDialogTrigger,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogTitle,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
}
|
||||||
|
|
||||||
@@ -4,41 +4,49 @@
|
|||||||
|
|
||||||
import { api } from "./client";
|
import { api } from "./client";
|
||||||
import type {
|
import type {
|
||||||
|
PlacementAnalyticsItem,
|
||||||
|
CreativeAnalyticsItem,
|
||||||
|
ChannelAnalyticsItem,
|
||||||
SpendingAnalytics,
|
SpendingAnalytics,
|
||||||
PlacementsAnalytics,
|
AnalyticsQueryParams,
|
||||||
CreativesAnalytics,
|
SpendingAnalyticsQueryParams,
|
||||||
ExternalChannelsAnalytics,
|
PaginatedResponse,
|
||||||
DateGrouping,
|
|
||||||
} from "@/lib/types/api";
|
} from "@/lib/types/api";
|
||||||
|
|
||||||
export const analyticsApi = {
|
export const analyticsApi = {
|
||||||
/**
|
|
||||||
* Get spending analytics
|
|
||||||
*/
|
|
||||||
spending: (params?: {
|
|
||||||
target_channel_id?: string;
|
|
||||||
date_from?: string;
|
|
||||||
date_to?: string;
|
|
||||||
grouping?: DateGrouping;
|
|
||||||
}) => api.get<SpendingAnalytics>("/analytics/spending", { params }),
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get placements analytics
|
* Get placements analytics
|
||||||
*/
|
*/
|
||||||
placements: (params?: { target_channel_id?: string }) =>
|
placements: (workspaceId: string, params?: AnalyticsQueryParams) =>
|
||||||
api.get<PlacementsAnalytics>("/analytics/placements", { params }),
|
api.get<PaginatedResponse<PlacementAnalyticsItem>>(
|
||||||
|
`/workspaces/${workspaceId}/analytics/placements`,
|
||||||
|
{ params }
|
||||||
|
),
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get creatives analytics
|
* Get creatives analytics
|
||||||
*/
|
*/
|
||||||
creatives: (params?: { target_channel_id?: string }) =>
|
creatives: (workspaceId: string, params?: AnalyticsQueryParams) =>
|
||||||
api.get<CreativesAnalytics>("/analytics/creatives", { params }),
|
api.get<PaginatedResponse<CreativeAnalyticsItem>>(
|
||||||
|
`/workspaces/${workspaceId}/analytics/creatives`,
|
||||||
|
{ params }
|
||||||
|
),
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get external channels analytics
|
* Get channels analytics
|
||||||
*/
|
*/
|
||||||
externalChannels: (params?: { target_channel_id?: string }) =>
|
channels: (workspaceId: string, params?: AnalyticsQueryParams) =>
|
||||||
api.get<ExternalChannelsAnalytics>("/analytics/external-channels", {
|
api.get<PaginatedResponse<ChannelAnalyticsItem>>(
|
||||||
params,
|
`/workspaces/${workspaceId}/analytics/channels`,
|
||||||
}),
|
{ params }
|
||||||
|
),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get spending analytics (time-series data)
|
||||||
|
*/
|
||||||
|
spending: (workspaceId: string, params?: SpendingAnalyticsQueryParams) =>
|
||||||
|
api.get<SpendingAnalytics>(
|
||||||
|
`/workspaces/${workspaceId}/analytics/spending`,
|
||||||
|
{ params }
|
||||||
|
),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -2,33 +2,19 @@
|
|||||||
// Auth API
|
// Auth API
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
import { api, saveAuthTokens, clearAuthTokens } from "./client";
|
import { api, clearAuthTokens } from "./client";
|
||||||
import { STORAGE_KEYS } from "@/lib/utils/constants";
|
import { STORAGE_KEYS } from "@/lib/utils/constants";
|
||||||
import type {
|
import type { User, AuthCompleteResponse } from "@/lib/types/api";
|
||||||
User,
|
|
||||||
AuthInitResponse,
|
|
||||||
AuthCompleteRequest,
|
|
||||||
AuthCompleteResponse,
|
|
||||||
AuthRefreshRequest,
|
|
||||||
AuthRefreshResponse,
|
|
||||||
} from "@/lib/types/api";
|
|
||||||
|
|
||||||
export const authApi = {
|
export const authApi = {
|
||||||
/**
|
|
||||||
* Initialize Telegram auth flow
|
|
||||||
*/
|
|
||||||
init: () => api.get<AuthInitResponse>("/auth/init", { requireAuth: false }),
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Complete auth with one-time token from bot
|
* Complete auth with one-time token from bot
|
||||||
|
* GET /auth/complete?token=...
|
||||||
*/
|
*/
|
||||||
complete: async (token: string): Promise<{ access_token: string }> => {
|
complete: async (token: string): Promise<AuthCompleteResponse> => {
|
||||||
// GET request with token as query parameter
|
const response = await api.get<AuthCompleteResponse>(
|
||||||
const response = await api.get<{ access_token: string }>(
|
|
||||||
`/auth/complete?token=${token}`,
|
`/auth/complete?token=${token}`,
|
||||||
{
|
{ requireAuth: false }
|
||||||
requireAuth: false,
|
|
||||||
}
|
|
||||||
);
|
);
|
||||||
|
|
||||||
// Save access token to storage
|
// Save access token to storage
|
||||||
@@ -39,36 +25,9 @@ export const authApi = {
|
|||||||
return response;
|
return response;
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
|
||||||
* Refresh access token
|
|
||||||
*/
|
|
||||||
refresh: async (): Promise<string> => {
|
|
||||||
if (typeof window === "undefined") {
|
|
||||||
throw new Error("Cannot refresh token on server");
|
|
||||||
}
|
|
||||||
|
|
||||||
const refreshToken = localStorage.getItem(STORAGE_KEYS.REFRESH_TOKEN);
|
|
||||||
if (!refreshToken) {
|
|
||||||
throw new Error("No refresh token available");
|
|
||||||
}
|
|
||||||
|
|
||||||
const data: AuthRefreshRequest = { refresh_token: refreshToken };
|
|
||||||
const response = await api.post<AuthRefreshResponse>(
|
|
||||||
"/auth/refresh",
|
|
||||||
data,
|
|
||||||
{
|
|
||||||
requireAuth: false,
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
// Update access token
|
|
||||||
localStorage.setItem(STORAGE_KEYS.ACCESS_TOKEN, response.access_token);
|
|
||||||
|
|
||||||
return response.access_token;
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get current user info
|
* Get current user info
|
||||||
|
* GET /auth/me
|
||||||
*/
|
*/
|
||||||
me: () => api.get<User>("/auth/me"),
|
me: () => api.get<User>("/auth/me"),
|
||||||
|
|
||||||
|
|||||||
@@ -1,24 +1,18 @@
|
|||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Target Channels API
|
// Channels API (Catalog for Placements)
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
import { api } from "./client";
|
import { api } from "./client";
|
||||||
import type {
|
import type {
|
||||||
TargetChannel,
|
Channel,
|
||||||
TargetChannelsListResponse,
|
ChannelsQueryParams,
|
||||||
SuccessResponse,
|
PaginatedResponse,
|
||||||
} from "@/lib/types/api";
|
} from "@/lib/types/api";
|
||||||
|
|
||||||
export const channelsApi = {
|
export const channelsApi = {
|
||||||
/**
|
/**
|
||||||
* Get list of target channels
|
* Get catalog of channels for placements
|
||||||
* GET /api/v1/target_channels
|
|
||||||
*/
|
*/
|
||||||
list: () => api.get<TargetChannelsListResponse>("/target_channels"),
|
list: (params?: ChannelsQueryParams) =>
|
||||||
|
api.get<PaginatedResponse<Channel>>("/channels", { params }),
|
||||||
/**
|
|
||||||
* Delete (disconnect) target channel
|
|
||||||
* DELETE /api/v1/target_channels/{channel_id}
|
|
||||||
*/
|
|
||||||
delete: (id: string) => api.delete<SuccessResponse>(`/target_channels/${id}`),
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
// ============================================================================
|
// ============================================================================
|
||||||
// API Client with Mock Support
|
// API Client
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
import { API_URL, USE_MOCKS, STORAGE_KEYS } from "@/lib/utils/constants";
|
import { API_URL, STORAGE_KEYS } from "@/lib/utils/constants";
|
||||||
import { mockApiHandlers } from "@/lib/mocks/handlers";
|
|
||||||
import type { ApiError } from "@/lib/types/api";
|
import type { ApiError } from "@/lib/types/api";
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@@ -61,169 +60,13 @@ export const clearAuthTokens = (): void => {
|
|||||||
*/
|
*/
|
||||||
export const saveAuthTokens = (
|
export const saveAuthTokens = (
|
||||||
accessToken: string,
|
accessToken: string,
|
||||||
refreshToken: string
|
refreshToken?: string
|
||||||
): void => {
|
): void => {
|
||||||
if (typeof window === "undefined") return;
|
if (typeof window === "undefined") return;
|
||||||
localStorage.setItem(STORAGE_KEYS.ACCESS_TOKEN, accessToken);
|
localStorage.setItem(STORAGE_KEYS.ACCESS_TOKEN, accessToken);
|
||||||
localStorage.setItem(STORAGE_KEYS.REFRESH_TOKEN, refreshToken);
|
if (refreshToken) {
|
||||||
};
|
localStorage.setItem(STORAGE_KEYS.REFRESH_TOKEN, refreshToken);
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// Mock Router
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Route request to appropriate mock handler
|
|
||||||
*/
|
|
||||||
const routeMockRequest = async (
|
|
||||||
endpoint: string,
|
|
||||||
options: RequestOptions
|
|
||||||
): Promise<any> => {
|
|
||||||
const method = options.method || "GET";
|
|
||||||
const params = options.params || {};
|
|
||||||
const body = options.body ? JSON.parse(options.body as string) : undefined;
|
|
||||||
|
|
||||||
console.log(`[MOCK API] ${method} ${endpoint}`, { params, body });
|
|
||||||
|
|
||||||
// Auth endpoints
|
|
||||||
if (endpoint === "/auth/init") {
|
|
||||||
return mockApiHandlers.authInit();
|
|
||||||
}
|
}
|
||||||
if (endpoint.startsWith("/auth/complete")) {
|
|
||||||
// Extract token from query params
|
|
||||||
const token =
|
|
||||||
params.token ||
|
|
||||||
(endpoint.includes("?")
|
|
||||||
? new URLSearchParams(endpoint.split("?")[1]).get("token")
|
|
||||||
: null);
|
|
||||||
return mockApiHandlers.authComplete({ token });
|
|
||||||
}
|
|
||||||
if (endpoint === "/auth/refresh") {
|
|
||||||
return mockApiHandlers.authRefresh(body);
|
|
||||||
}
|
|
||||||
if (endpoint === "/auth/me") {
|
|
||||||
return mockApiHandlers.authMe();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Target Channels
|
|
||||||
if (endpoint === "/target_channels" && method === "GET") {
|
|
||||||
return mockApiHandlers.getTargetChannels();
|
|
||||||
}
|
|
||||||
if (endpoint.startsWith("/target_channels/") && method === "DELETE") {
|
|
||||||
const id = endpoint.split("/")[2];
|
|
||||||
return mockApiHandlers.deleteTargetChannel(id);
|
|
||||||
}
|
|
||||||
|
|
||||||
// External Channels
|
|
||||||
if (endpoint.startsWith("/external_channels/target/") && method === "GET") {
|
|
||||||
const targetChannelId = endpoint.split("/")[3];
|
|
||||||
return mockApiHandlers.getExternalChannels(targetChannelId);
|
|
||||||
}
|
|
||||||
if (endpoint === "/external_channels" && method === "POST") {
|
|
||||||
return mockApiHandlers.createExternalChannel(body);
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
endpoint.startsWith("/external_channels/") &&
|
|
||||||
!endpoint.includes("/target/") &&
|
|
||||||
!endpoint.includes("/links") &&
|
|
||||||
method === "GET"
|
|
||||||
) {
|
|
||||||
const id = endpoint.split("/")[2];
|
|
||||||
return mockApiHandlers.getExternalChannel(id);
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
endpoint.startsWith("/external_channels/") &&
|
|
||||||
endpoint.includes("/links") &&
|
|
||||||
method === "PATCH"
|
|
||||||
) {
|
|
||||||
const id = endpoint.split("/")[2];
|
|
||||||
return mockApiHandlers.updateExternalChannelLinks(id, body);
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
endpoint.startsWith("/external_channels/") &&
|
|
||||||
!endpoint.includes("/links") &&
|
|
||||||
method === "PATCH"
|
|
||||||
) {
|
|
||||||
const id = endpoint.split("/")[2];
|
|
||||||
return mockApiHandlers.updateExternalChannel(id, body);
|
|
||||||
}
|
|
||||||
if (endpoint.startsWith("/external_channels/") && method === "DELETE") {
|
|
||||||
const id = endpoint.split("/")[2];
|
|
||||||
return mockApiHandlers.deleteExternalChannel(id);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Creatives
|
|
||||||
if (endpoint === "/creatives" && method === "GET") {
|
|
||||||
return mockApiHandlers.getCreatives(params);
|
|
||||||
}
|
|
||||||
if (endpoint === "/creatives" && method === "POST") {
|
|
||||||
return mockApiHandlers.createCreative(body);
|
|
||||||
}
|
|
||||||
if (endpoint.startsWith("/creatives/") && method === "GET") {
|
|
||||||
const id = endpoint.split("/")[2];
|
|
||||||
return mockApiHandlers.getCreative(id);
|
|
||||||
}
|
|
||||||
if (endpoint.startsWith("/creatives/") && method === "PATCH") {
|
|
||||||
const id = endpoint.split("/")[2];
|
|
||||||
return mockApiHandlers.updateCreative(id, body);
|
|
||||||
}
|
|
||||||
if (endpoint.startsWith("/creatives/") && method === "DELETE") {
|
|
||||||
const id = endpoint.split("/")[2];
|
|
||||||
return mockApiHandlers.deleteCreative(id);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Placements
|
|
||||||
if (endpoint === "/placements" && method === "GET") {
|
|
||||||
return mockApiHandlers.getPlacements(params);
|
|
||||||
}
|
|
||||||
if (endpoint === "/placements" && method === "POST") {
|
|
||||||
return mockApiHandlers.createPlacement(body);
|
|
||||||
}
|
|
||||||
if (endpoint.match(/^\/placements\/[^\/]+$/) && method === "GET") {
|
|
||||||
const id = endpoint.split("/")[2];
|
|
||||||
return mockApiHandlers.getPlacement(id);
|
|
||||||
}
|
|
||||||
if (endpoint.match(/^\/placements\/[^\/]+$/) && method === "PATCH") {
|
|
||||||
const id = endpoint.split("/")[2];
|
|
||||||
return mockApiHandlers.updatePlacement(id, body);
|
|
||||||
}
|
|
||||||
if (endpoint.match(/^\/placements\/[^\/]+$/) && method === "DELETE") {
|
|
||||||
const id = endpoint.split("/")[2];
|
|
||||||
return mockApiHandlers.deletePlacement(id);
|
|
||||||
}
|
|
||||||
if (endpoint.includes("/views/fetch") && method === "POST") {
|
|
||||||
const id = endpoint.split("/")[2];
|
|
||||||
return mockApiHandlers.fetchPlacementViews(id);
|
|
||||||
}
|
|
||||||
if (endpoint.includes("/views/history") && method === "GET") {
|
|
||||||
const id = endpoint.split("/")[2];
|
|
||||||
return mockApiHandlers.getPlacementViewsHistory(id, params);
|
|
||||||
}
|
|
||||||
if (endpoint.includes("/views/manual") && method === "POST") {
|
|
||||||
const id = endpoint.split("/")[2];
|
|
||||||
return mockApiHandlers.setPlacementViewsManually(id, params?.views_count);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Analytics
|
|
||||||
if (endpoint === "/analytics/spending") {
|
|
||||||
return mockApiHandlers.getSpendingAnalytics(params);
|
|
||||||
}
|
|
||||||
if (endpoint === "/analytics/placements") {
|
|
||||||
return mockApiHandlers.getPlacementsAnalytics(params);
|
|
||||||
}
|
|
||||||
if (endpoint === "/analytics/creatives") {
|
|
||||||
return mockApiHandlers.getCreativesAnalytics(params);
|
|
||||||
}
|
|
||||||
if (endpoint === "/analytics/external-channels") {
|
|
||||||
return mockApiHandlers.getExternalChannelsAnalytics(params);
|
|
||||||
}
|
|
||||||
|
|
||||||
throw {
|
|
||||||
error: {
|
|
||||||
code: "NOT_IMPLEMENTED",
|
|
||||||
message: `Mock handler not implemented for ${method} ${endpoint}`,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@@ -231,7 +74,7 @@ const routeMockRequest = async (
|
|||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Main API request function with mock support
|
* Main API request function
|
||||||
*/
|
*/
|
||||||
export const apiRequest = async <T = any>(
|
export const apiRequest = async <T = any>(
|
||||||
endpoint: string,
|
endpoint: string,
|
||||||
@@ -239,17 +82,7 @@ export const apiRequest = async <T = any>(
|
|||||||
): Promise<T> => {
|
): Promise<T> => {
|
||||||
const { params, requireAuth = true, ...fetchOptions } = options;
|
const { params, requireAuth = true, ...fetchOptions } = options;
|
||||||
|
|
||||||
// Use mocks if enabled
|
// Build URL
|
||||||
if (USE_MOCKS) {
|
|
||||||
try {
|
|
||||||
const result = await routeMockRequest(endpoint, options);
|
|
||||||
return result as T;
|
|
||||||
} catch (error) {
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Real API request
|
|
||||||
const url = buildUrl(`${API_URL}${endpoint}`, params);
|
const url = buildUrl(`${API_URL}${endpoint}`, params);
|
||||||
|
|
||||||
const headers: HeadersInit = {
|
const headers: HeadersInit = {
|
||||||
@@ -271,45 +104,29 @@ export const apiRequest = async <T = any>(
|
|||||||
headers,
|
headers,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Handle 204 No Content
|
||||||
|
if (response.status === 204) {
|
||||||
|
return undefined as T;
|
||||||
|
}
|
||||||
|
|
||||||
// Parse response
|
// Parse response
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
|
|
||||||
// Handle errors
|
// Handle errors
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
// FastAPI returns errors in format { "detail": "..." }
|
// FastAPI returns errors in format { "detail": "..." }
|
||||||
// Transform to our ApiError format
|
const error: ApiError = {
|
||||||
if (data.detail) {
|
detail: data.detail || "An error occurred",
|
||||||
throw {
|
};
|
||||||
error: {
|
|
||||||
code:
|
|
||||||
response.status === 401
|
|
||||||
? "UNAUTHORIZED"
|
|
||||||
: response.status === 403
|
|
||||||
? "FORBIDDEN"
|
|
||||||
: response.status === 404
|
|
||||||
? "NOT_FOUND"
|
|
||||||
: response.status === 422
|
|
||||||
? "VALIDATION_ERROR"
|
|
||||||
: "API_ERROR",
|
|
||||||
message: data.detail,
|
|
||||||
},
|
|
||||||
detail: data.detail,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
// If it's already in our format, throw as is
|
|
||||||
const error: ApiError = data;
|
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
|
|
||||||
return data as T;
|
return data as T;
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
// Network error or parsing error
|
// Network error or parsing error
|
||||||
if (!error.error) {
|
if (!error.detail) {
|
||||||
throw {
|
throw {
|
||||||
error: {
|
detail: error.message || "Network error occurred",
|
||||||
code: "NETWORK_ERROR",
|
|
||||||
message: error.message || "Network error occurred",
|
|
||||||
},
|
|
||||||
} as ApiError;
|
} as ApiError;
|
||||||
}
|
}
|
||||||
throw error;
|
throw error;
|
||||||
@@ -328,47 +145,23 @@ export const api = {
|
|||||||
apiRequest<T>(endpoint, {
|
apiRequest<T>(endpoint, {
|
||||||
...options,
|
...options,
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: JSON.stringify(body),
|
body: body ? JSON.stringify(body) : undefined,
|
||||||
|
}),
|
||||||
|
|
||||||
|
put: <T = any>(endpoint: string, body?: any, options?: RequestOptions) =>
|
||||||
|
apiRequest<T>(endpoint, {
|
||||||
|
...options,
|
||||||
|
method: "PUT",
|
||||||
|
body: body ? JSON.stringify(body) : undefined,
|
||||||
}),
|
}),
|
||||||
|
|
||||||
patch: <T = any>(endpoint: string, body?: any, options?: RequestOptions) =>
|
patch: <T = any>(endpoint: string, body?: any, options?: RequestOptions) =>
|
||||||
apiRequest<T>(endpoint, {
|
apiRequest<T>(endpoint, {
|
||||||
...options,
|
...options,
|
||||||
method: "PATCH",
|
method: "PATCH",
|
||||||
body: JSON.stringify(body),
|
body: body ? JSON.stringify(body) : undefined,
|
||||||
}),
|
}),
|
||||||
|
|
||||||
delete: <T = any>(endpoint: string, options?: RequestOptions) =>
|
delete: <T = any>(endpoint: string, options?: RequestOptions) =>
|
||||||
apiRequest<T>(endpoint, { ...options, method: "DELETE" }),
|
apiRequest<T>(endpoint, { ...options, method: "DELETE" }),
|
||||||
|
|
||||||
// Special method for file upload
|
|
||||||
upload: async <T = any>(
|
|
||||||
endpoint: string,
|
|
||||||
formData: FormData,
|
|
||||||
options?: RequestOptions
|
|
||||||
): Promise<T> => {
|
|
||||||
// File import is now done on client side, no need for mock handling
|
|
||||||
const url = `${API_URL}${endpoint}`;
|
|
||||||
const token = getAuthToken();
|
|
||||||
|
|
||||||
const headers: HeadersInit = {};
|
|
||||||
if (token) {
|
|
||||||
headers["Authorization"] = `Bearer ${token}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
const response = await fetch(url, {
|
|
||||||
method: "POST",
|
|
||||||
headers,
|
|
||||||
body: formData,
|
|
||||||
...options,
|
|
||||||
});
|
|
||||||
|
|
||||||
const data = await response.json();
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
throw data as ApiError;
|
|
||||||
}
|
|
||||||
|
|
||||||
return data as T;
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -5,39 +5,57 @@
|
|||||||
import { api } from "./client";
|
import { api } from "./client";
|
||||||
import type {
|
import type {
|
||||||
Creative,
|
Creative,
|
||||||
CreativeQueryParams,
|
|
||||||
CreativeCreateRequest,
|
CreativeCreateRequest,
|
||||||
CreativeUpdateRequest,
|
CreativeUpdateRequest,
|
||||||
CreativesListResponse,
|
CreativesQueryParams,
|
||||||
SuccessResponse,
|
PaginatedResponse,
|
||||||
} from "@/lib/types/api";
|
} from "@/lib/types/api";
|
||||||
|
|
||||||
export const creativesApi = {
|
export const creativesApi = {
|
||||||
/**
|
/**
|
||||||
* Get list of creatives
|
* Get list of creatives for workspace
|
||||||
*/
|
*/
|
||||||
list: (params?: CreativeQueryParams) =>
|
list: (workspaceId: string, params?: CreativesQueryParams) =>
|
||||||
api.get<CreativesListResponse>("/creatives", { params }),
|
api.get<PaginatedResponse<Creative>>(
|
||||||
|
`/workspaces/${workspaceId}/creatives`,
|
||||||
|
{ params }
|
||||||
|
),
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get creative details
|
* Get single creative
|
||||||
*/
|
*/
|
||||||
get: (id: string) => api.get<Creative>(`/creatives/${id}`),
|
get: (workspaceId: string, creativeId: string) =>
|
||||||
|
api.get<Creative>(`/workspaces/${workspaceId}/creatives/${creativeId}`),
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create new creative
|
* Create new creative
|
||||||
|
* @param projectId - Required query param for the project
|
||||||
*/
|
*/
|
||||||
create: (data: CreativeCreateRequest) =>
|
create: (
|
||||||
api.post<Creative>("/creatives", data),
|
workspaceId: string,
|
||||||
|
projectId: string,
|
||||||
|
data: CreativeCreateRequest
|
||||||
|
) =>
|
||||||
|
api.post<Creative>(`/workspaces/${workspaceId}/creatives`, data, {
|
||||||
|
params: { project_id: projectId },
|
||||||
|
}),
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Update creative
|
* Update creative
|
||||||
*/
|
*/
|
||||||
update: (id: string, data: CreativeUpdateRequest) =>
|
update: (
|
||||||
api.patch<Creative>(`/creatives/${id}`, data),
|
workspaceId: string,
|
||||||
|
creativeId: string,
|
||||||
|
data: CreativeUpdateRequest
|
||||||
|
) =>
|
||||||
|
api.patch<Creative>(
|
||||||
|
`/workspaces/${workspaceId}/creatives/${creativeId}`,
|
||||||
|
data
|
||||||
|
),
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Delete creative
|
* Delete (archive) creative
|
||||||
*/
|
*/
|
||||||
delete: (id: string) => api.delete<SuccessResponse>(`/creatives/${id}`),
|
delete: (workspaceId: string, creativeId: string) =>
|
||||||
|
api.delete<void>(`/workspaces/${workspaceId}/creatives/${creativeId}`),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,52 +0,0 @@
|
|||||||
// ============================================================================
|
|
||||||
// External Channels API
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
import { api } from "./client";
|
|
||||||
import type {
|
|
||||||
ExternalChannel,
|
|
||||||
ExternalChannelsListResponse,
|
|
||||||
ExternalChannelCreateRequest,
|
|
||||||
ExternalChannelUpdateRequest,
|
|
||||||
ExternalChannelLinksUpdateRequest,
|
|
||||||
SuccessResponse,
|
|
||||||
} from "@/lib/types/api";
|
|
||||||
|
|
||||||
export const externalChannelsApi = {
|
|
||||||
/**
|
|
||||||
* Get list of external channels for target channel
|
|
||||||
* GET /api/v1/external_channels/target/{target_channel_id}
|
|
||||||
*/
|
|
||||||
list: (targetChannelId: string) =>
|
|
||||||
api.get<ExternalChannelsListResponse>(
|
|
||||||
`/external_channels/target/${targetChannelId}`
|
|
||||||
),
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create new external channel
|
|
||||||
* POST /api/v1/external_channels
|
|
||||||
*/
|
|
||||||
create: (data: ExternalChannelCreateRequest) =>
|
|
||||||
api.post<ExternalChannel>("/external_channels", data),
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Update external channel
|
|
||||||
* PATCH /api/v1/external_channels/{channel_id}
|
|
||||||
*/
|
|
||||||
update: (id: string, data: ExternalChannelUpdateRequest) =>
|
|
||||||
api.patch<ExternalChannel>(`/external_channels/${id}`, data),
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Update external channel links
|
|
||||||
* PATCH /api/v1/external_channels/{channel_id}/links
|
|
||||||
*/
|
|
||||||
updateLinks: (id: string, data: ExternalChannelLinksUpdateRequest) =>
|
|
||||||
api.patch<ExternalChannel>(`/external_channels/${id}/links`, data),
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Delete external channel
|
|
||||||
* DELETE /api/v1/external_channels/{channel_id}
|
|
||||||
*/
|
|
||||||
delete: (id: string) =>
|
|
||||||
api.delete<SuccessResponse>(`/external_channels/${id}`),
|
|
||||||
};
|
|
||||||
@@ -1,11 +1,15 @@
|
|||||||
// ============================================================================
|
// ============================================================================
|
||||||
// API Export
|
// API Exports
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
export * from "./client";
|
export { api, apiRequest, clearAuthTokens, saveAuthTokens } from "./client";
|
||||||
export * from "./auth";
|
export { authApi } from "./auth";
|
||||||
export * from "./channels";
|
export { workspacesApi } from "./workspaces";
|
||||||
export * from "./external-channels";
|
export { membersApi } from "./members";
|
||||||
export * from "./creatives";
|
export { invitesApi } from "./invites";
|
||||||
export * from "./placements";
|
export { projectsApi } from "./projects";
|
||||||
export * from "./analytics";
|
export { channelsApi } from "./channels";
|
||||||
|
export { purchasePlanApi } from "./purchase-plan";
|
||||||
|
export { creativesApi } from "./creatives";
|
||||||
|
export { placementsApi } from "./placements";
|
||||||
|
export { analyticsApi } from "./analytics";
|
||||||
|
|||||||
29
lib/api/invites.ts
Normal file
29
lib/api/invites.ts
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
// ============================================================================
|
||||||
|
// Workspace Invites API
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
import { api } from "./client";
|
||||||
|
import type {
|
||||||
|
WorkspaceInvite,
|
||||||
|
WorkspaceInviteCreateRequest,
|
||||||
|
PaginatedResponse,
|
||||||
|
PaginationParams,
|
||||||
|
} from "@/lib/types/api";
|
||||||
|
|
||||||
|
export const invitesApi = {
|
||||||
|
/**
|
||||||
|
* Get list of workspace invites
|
||||||
|
*/
|
||||||
|
list: (workspaceId: string, params?: PaginationParams) =>
|
||||||
|
api.get<PaginatedResponse<WorkspaceInvite>>(
|
||||||
|
`/workspaces/${workspaceId}/invites`,
|
||||||
|
{ params }
|
||||||
|
),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create new invite
|
||||||
|
*/
|
||||||
|
create: (workspaceId: string, data: WorkspaceInviteCreateRequest) =>
|
||||||
|
api.post<WorkspaceInvite>(`/workspaces/${workspaceId}/invites`, data),
|
||||||
|
};
|
||||||
|
|
||||||
36
lib/api/members.ts
Normal file
36
lib/api/members.ts
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
// ============================================================================
|
||||||
|
// Workspace Members API
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
import { api } from "./client";
|
||||||
|
import type {
|
||||||
|
WorkspaceMember,
|
||||||
|
UpdatePermissionsRequest,
|
||||||
|
PaginatedResponse,
|
||||||
|
PaginationParams,
|
||||||
|
} from "@/lib/types/api";
|
||||||
|
|
||||||
|
export const membersApi = {
|
||||||
|
/**
|
||||||
|
* Get list of workspace members
|
||||||
|
*/
|
||||||
|
list: (workspaceId: string, params?: PaginationParams) =>
|
||||||
|
api.get<PaginatedResponse<WorkspaceMember>>(
|
||||||
|
`/workspaces/${workspaceId}/members`,
|
||||||
|
{ params }
|
||||||
|
),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update member permissions
|
||||||
|
*/
|
||||||
|
updatePermissions: (
|
||||||
|
workspaceId: string,
|
||||||
|
userId: string,
|
||||||
|
data: UpdatePermissionsRequest
|
||||||
|
) =>
|
||||||
|
api.put<WorkspaceMember>(
|
||||||
|
`/workspaces/${workspaceId}/members/${userId}/permissions`,
|
||||||
|
data
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
@@ -5,69 +5,65 @@
|
|||||||
import { api } from "./client";
|
import { api } from "./client";
|
||||||
import type {
|
import type {
|
||||||
Placement,
|
Placement,
|
||||||
PlacementQueryParams,
|
|
||||||
PlacementCreateRequest,
|
PlacementCreateRequest,
|
||||||
PlacementUpdateRequest,
|
PlacementUpdateRequest,
|
||||||
PlacementsListResponse,
|
PlacementsQueryParams,
|
||||||
ViewsFetchResponse,
|
ViewsHistoryItem,
|
||||||
ViewsHistoryResponse,
|
ViewsHistoryQueryParams,
|
||||||
SuccessResponse,
|
PaginatedResponse,
|
||||||
} from "@/lib/types/api";
|
} from "@/lib/types/api";
|
||||||
|
|
||||||
export const placementsApi = {
|
export const placementsApi = {
|
||||||
/**
|
/**
|
||||||
* Get list of placements
|
* Get list of placements for workspace
|
||||||
*/
|
*/
|
||||||
list: (params?: PlacementQueryParams) =>
|
list: (workspaceId: string, params?: PlacementsQueryParams) =>
|
||||||
api.get<PlacementsListResponse>("/placements", { params }),
|
api.get<PaginatedResponse<Placement>>(
|
||||||
|
`/workspaces/${workspaceId}/placements`,
|
||||||
|
{ params }
|
||||||
|
),
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get placement details
|
* Get single placement
|
||||||
*/
|
*/
|
||||||
get: (id: string) => api.get<Placement>(`/placements/${id}`),
|
get: (workspaceId: string, placementId: string) =>
|
||||||
|
api.get<Placement>(`/workspaces/${workspaceId}/placements/${placementId}`),
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create new placement
|
* Create new placement
|
||||||
*/
|
*/
|
||||||
create: (data: PlacementCreateRequest) =>
|
create: (workspaceId: string, data: PlacementCreateRequest) =>
|
||||||
api.post<Placement>("/placements", data),
|
api.post<Placement>(`/workspaces/${workspaceId}/placements`, data),
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Update placement
|
* Update placement
|
||||||
*/
|
*/
|
||||||
update: (id: string, data: PlacementUpdateRequest) =>
|
update: (
|
||||||
api.patch<Placement>(`/placements/${id}`, data),
|
workspaceId: string,
|
||||||
|
placementId: string,
|
||||||
|
data: PlacementUpdateRequest
|
||||||
|
) =>
|
||||||
|
api.patch<Placement>(
|
||||||
|
`/workspaces/${workspaceId}/placements/${placementId}`,
|
||||||
|
data
|
||||||
|
),
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Delete placement
|
* Delete (archive) placement
|
||||||
*/
|
*/
|
||||||
delete: (id: string) => api.delete<SuccessResponse>(`/placements/${id}`),
|
delete: (workspaceId: string, placementId: string) =>
|
||||||
|
api.delete<void>(`/workspaces/${workspaceId}/placements/${placementId}`),
|
||||||
/**
|
|
||||||
* Fetch views for placement
|
|
||||||
*/
|
|
||||||
fetchViews: (id: string) =>
|
|
||||||
api.post<ViewsFetchResponse>(`/placements/${id}/views/fetch`),
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get views history for placement
|
* Get views history for placement
|
||||||
*/
|
*/
|
||||||
viewsHistory: (
|
getViewsHistory: (
|
||||||
id: string,
|
workspaceId: string,
|
||||||
params?: { from_date?: string; to_date?: string }
|
placementId: string,
|
||||||
|
params?: ViewsHistoryQueryParams
|
||||||
) =>
|
) =>
|
||||||
api.get<ViewsHistoryResponse>(`/placements/${id}/views/history`, {
|
api.get<PaginatedResponse<ViewsHistoryItem>>(
|
||||||
params,
|
`/workspaces/${workspaceId}/placements/${placementId}/views/history`,
|
||||||
}),
|
{ params }
|
||||||
|
),
|
||||||
/**
|
|
||||||
* Set views manually
|
|
||||||
*/
|
|
||||||
setViewsManually: (id: string, views_count: number) =>
|
|
||||||
api.post<Placement>(`/placements/${id}/views/manual`, null, {
|
|
||||||
params: { views_count },
|
|
||||||
}),
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Keep purchasesApi as an alias for backwards compatibility
|
|
||||||
export const purchasesApi = placementsApi;
|
|
||||||
|
|||||||
19
lib/api/projects.ts
Normal file
19
lib/api/projects.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
// ============================================================================
|
||||||
|
// Projects API (Target Channels)
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
import { api } from "./client";
|
||||||
|
import type { Project, PaginatedResponse, PaginationParams } from "@/lib/types/api";
|
||||||
|
|
||||||
|
export const projectsApi = {
|
||||||
|
/**
|
||||||
|
* Get list of projects for workspace
|
||||||
|
* Projects are created via Telegram bot (add/remove channel)
|
||||||
|
*/
|
||||||
|
list: (workspaceId: string, params?: PaginationParams) =>
|
||||||
|
api.get<PaginatedResponse<Project>>(
|
||||||
|
`/workspaces/${workspaceId}/projects`,
|
||||||
|
{ params }
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
61
lib/api/purchase-plan.ts
Normal file
61
lib/api/purchase-plan.ts
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
// ============================================================================
|
||||||
|
// Purchase Plan API
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
import { api } from "./client";
|
||||||
|
import type {
|
||||||
|
PurchasePlanChannel,
|
||||||
|
PurchasePlanChannelCreateRequest,
|
||||||
|
PurchasePlanChannelUpdateRequest,
|
||||||
|
PaginatedResponse,
|
||||||
|
PaginationParams,
|
||||||
|
} from "@/lib/types/api";
|
||||||
|
|
||||||
|
const buildPath = (workspaceId: string, projectId: string) =>
|
||||||
|
`/workspaces/${workspaceId}/projects/${projectId}/purchase-plan/channels`;
|
||||||
|
|
||||||
|
export const purchasePlanApi = {
|
||||||
|
/**
|
||||||
|
* Get list of channels in purchase plan
|
||||||
|
*/
|
||||||
|
list: (
|
||||||
|
workspaceId: string,
|
||||||
|
projectId: string,
|
||||||
|
params?: PaginationParams
|
||||||
|
) =>
|
||||||
|
api.get<PaginatedResponse<PurchasePlanChannel>>(
|
||||||
|
buildPath(workspaceId, projectId),
|
||||||
|
{ params }
|
||||||
|
),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add channel to purchase plan
|
||||||
|
*/
|
||||||
|
create: (
|
||||||
|
workspaceId: string,
|
||||||
|
projectId: string,
|
||||||
|
data: PurchasePlanChannelCreateRequest
|
||||||
|
) =>
|
||||||
|
api.post<PurchasePlanChannel>(buildPath(workspaceId, projectId), data),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update channel in purchase plan
|
||||||
|
*/
|
||||||
|
update: (
|
||||||
|
workspaceId: string,
|
||||||
|
projectId: string,
|
||||||
|
channelId: string,
|
||||||
|
data: PurchasePlanChannelUpdateRequest
|
||||||
|
) =>
|
||||||
|
api.patch<PurchasePlanChannel>(
|
||||||
|
`${buildPath(workspaceId, projectId)}/${channelId}`,
|
||||||
|
data
|
||||||
|
),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove channel from purchase plan
|
||||||
|
*/
|
||||||
|
delete: (workspaceId: string, projectId: string, channelId: string) =>
|
||||||
|
api.delete<void>(`${buildPath(workspaceId, projectId)}/${channelId}`),
|
||||||
|
};
|
||||||
|
|
||||||
41
lib/api/workspaces.ts
Normal file
41
lib/api/workspaces.ts
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
// ============================================================================
|
||||||
|
// Workspaces API
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
import { api } from "./client";
|
||||||
|
import type {
|
||||||
|
Workspace,
|
||||||
|
WorkspaceCreateRequest,
|
||||||
|
WorkspaceUpdateRequest,
|
||||||
|
PaginatedResponse,
|
||||||
|
PaginationParams,
|
||||||
|
} from "@/lib/types/api";
|
||||||
|
|
||||||
|
const BASE_PATH = "/workspaces";
|
||||||
|
|
||||||
|
export const workspacesApi = {
|
||||||
|
/**
|
||||||
|
* Get list of workspaces for current user
|
||||||
|
*/
|
||||||
|
list: (params?: PaginationParams) =>
|
||||||
|
api.get<PaginatedResponse<Workspace>>(BASE_PATH, { params }),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create new workspace
|
||||||
|
*/
|
||||||
|
create: (data: WorkspaceCreateRequest) =>
|
||||||
|
api.post<Workspace>(BASE_PATH, data),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update workspace
|
||||||
|
*/
|
||||||
|
update: (workspaceId: string, data: WorkspaceUpdateRequest) =>
|
||||||
|
api.patch<Workspace>(`${BASE_PATH}/${workspaceId}`, data),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete workspace
|
||||||
|
*/
|
||||||
|
delete: (workspaceId: string) =>
|
||||||
|
api.delete<void>(`${BASE_PATH}/${workspaceId}`),
|
||||||
|
};
|
||||||
|
|
||||||
196
lib/demo/api.ts
Normal file
196
lib/demo/api.ts
Normal file
@@ -0,0 +1,196 @@
|
|||||||
|
// ============================================================================
|
||||||
|
// Demo API - Mock responses for demo mode
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
import type {
|
||||||
|
PaginatedResponse,
|
||||||
|
Workspace,
|
||||||
|
Project,
|
||||||
|
Channel,
|
||||||
|
Creative,
|
||||||
|
Placement,
|
||||||
|
PurchasePlanChannel,
|
||||||
|
WorkspaceMember,
|
||||||
|
SpendingAnalytics,
|
||||||
|
CreativeAnalyticsItem,
|
||||||
|
ChannelAnalyticsItem,
|
||||||
|
} from "@/lib/types/api";
|
||||||
|
import {
|
||||||
|
demoWorkspace,
|
||||||
|
demoMember,
|
||||||
|
demoProjects,
|
||||||
|
demoChannels,
|
||||||
|
demoCreatives,
|
||||||
|
demoPlacements,
|
||||||
|
demoPurchasePlanChannels,
|
||||||
|
demoSpendingAnalytics,
|
||||||
|
demoCreativeAnalytics,
|
||||||
|
demoChannelAnalytics,
|
||||||
|
} from "./mock-data";
|
||||||
|
import { DEMO_USER } from "./constants";
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Helper to create paginated response
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
const paginate = <T>(items: T[], page = 1, size = 50): PaginatedResponse<T> => ({
|
||||||
|
items: items.slice((page - 1) * size, page * size),
|
||||||
|
total: items.length,
|
||||||
|
page,
|
||||||
|
size,
|
||||||
|
pages: Math.ceil(items.length / size),
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Demo API Methods
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export const demoApi = {
|
||||||
|
// Auth
|
||||||
|
auth: {
|
||||||
|
me: () => Promise.resolve(DEMO_USER),
|
||||||
|
},
|
||||||
|
|
||||||
|
// Workspaces
|
||||||
|
workspaces: {
|
||||||
|
list: () => Promise.resolve(paginate([demoWorkspace])),
|
||||||
|
get: () => Promise.resolve(demoWorkspace),
|
||||||
|
},
|
||||||
|
|
||||||
|
// Members
|
||||||
|
members: {
|
||||||
|
list: () => Promise.resolve(paginate([demoMember])),
|
||||||
|
},
|
||||||
|
|
||||||
|
// Projects
|
||||||
|
projects: {
|
||||||
|
list: (params?: { project_id?: string }) => {
|
||||||
|
let items = demoProjects;
|
||||||
|
if (params?.project_id) {
|
||||||
|
items = items.filter((p) => p.id === params.project_id);
|
||||||
|
}
|
||||||
|
return Promise.resolve(paginate(items));
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// Channels
|
||||||
|
channels: {
|
||||||
|
list: () => Promise.resolve(paginate(demoChannels)),
|
||||||
|
},
|
||||||
|
|
||||||
|
// Creatives
|
||||||
|
creatives: {
|
||||||
|
list: (params?: { project_id?: string; include_archived?: boolean }) => {
|
||||||
|
let items = demoCreatives;
|
||||||
|
if (params?.project_id) {
|
||||||
|
items = items.filter((c) => c.project_id === params.project_id);
|
||||||
|
}
|
||||||
|
if (!params?.include_archived) {
|
||||||
|
items = items.filter((c) => c.status === "active");
|
||||||
|
}
|
||||||
|
return Promise.resolve(paginate(items));
|
||||||
|
},
|
||||||
|
get: (id: string) => {
|
||||||
|
const creative = demoCreatives.find((c) => c.id === id);
|
||||||
|
if (!creative) throw new Error("Creative not found");
|
||||||
|
return Promise.resolve(creative);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// Placements
|
||||||
|
placements: {
|
||||||
|
list: (params?: {
|
||||||
|
project_id?: string;
|
||||||
|
placement_channel_id?: string;
|
||||||
|
creative_id?: string;
|
||||||
|
include_archived?: boolean;
|
||||||
|
}) => {
|
||||||
|
let items = demoPlacements;
|
||||||
|
if (params?.project_id) {
|
||||||
|
items = items.filter((p) => p.project_id === params.project_id);
|
||||||
|
}
|
||||||
|
if (params?.placement_channel_id) {
|
||||||
|
items = items.filter(
|
||||||
|
(p) => p.placement_channel_id === params.placement_channel_id
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (params?.creative_id) {
|
||||||
|
items = items.filter((p) => p.creative_id === params.creative_id);
|
||||||
|
}
|
||||||
|
if (!params?.include_archived) {
|
||||||
|
items = items.filter((p) => p.status === "active");
|
||||||
|
}
|
||||||
|
return Promise.resolve(paginate(items));
|
||||||
|
},
|
||||||
|
get: (id: string) => {
|
||||||
|
const placement = demoPlacements.find((p) => p.id === id);
|
||||||
|
if (!placement) throw new Error("Placement not found");
|
||||||
|
return Promise.resolve(placement);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// Purchase Plan
|
||||||
|
purchasePlan: {
|
||||||
|
list: (projectId: string) => {
|
||||||
|
const items = demoPurchasePlanChannels[projectId] || [];
|
||||||
|
return Promise.resolve(paginate(items));
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// Analytics
|
||||||
|
analytics: {
|
||||||
|
spending: (params?: { project_id?: string; grouping?: string }) => {
|
||||||
|
// Filter by project if specified
|
||||||
|
if (params?.project_id) {
|
||||||
|
const projectPlacements = demoPlacements.filter(
|
||||||
|
(p) => p.project_id === params.project_id && p.status === "active"
|
||||||
|
);
|
||||||
|
const totalCost = projectPlacements.reduce(
|
||||||
|
(sum, p) => sum + (p.cost || 0),
|
||||||
|
0
|
||||||
|
);
|
||||||
|
const totalSubs = projectPlacements.reduce(
|
||||||
|
(sum, p) => sum + (p.subscriptions_count || 0),
|
||||||
|
0
|
||||||
|
);
|
||||||
|
const totalViews = projectPlacements.reduce(
|
||||||
|
(sum, p) => sum + (p.views_count || 0),
|
||||||
|
0
|
||||||
|
);
|
||||||
|
|
||||||
|
return Promise.resolve({
|
||||||
|
total_cost: totalCost,
|
||||||
|
total_subscriptions: totalSubs,
|
||||||
|
total_views: totalViews,
|
||||||
|
avg_cpf: totalSubs > 0 ? totalCost / totalSubs : null,
|
||||||
|
avg_cpm: totalViews > 0 ? (totalCost / totalViews) * 1000 : null,
|
||||||
|
chart_data: demoSpendingAnalytics.chart_data.slice(0, 3),
|
||||||
|
} as SpendingAnalytics);
|
||||||
|
}
|
||||||
|
return Promise.resolve(demoSpendingAnalytics);
|
||||||
|
},
|
||||||
|
creatives: (params?: { project_id?: string }) => {
|
||||||
|
let items = demoCreativeAnalytics;
|
||||||
|
if (params?.project_id) {
|
||||||
|
const projectCreativeIds = demoCreatives
|
||||||
|
.filter((c) => c.project_id === params.project_id)
|
||||||
|
.map((c) => c.id);
|
||||||
|
items = items.filter((a) => projectCreativeIds.includes(a.id));
|
||||||
|
}
|
||||||
|
return Promise.resolve(paginate(items));
|
||||||
|
},
|
||||||
|
channels: (params?: { project_id?: string }) => {
|
||||||
|
let items = demoChannelAnalytics;
|
||||||
|
if (params?.project_id) {
|
||||||
|
const projectPlacementChannelIds = new Set(
|
||||||
|
demoPlacements
|
||||||
|
.filter((p) => p.project_id === params.project_id)
|
||||||
|
.map((p) => p.placement_channel_id)
|
||||||
|
);
|
||||||
|
items = items.filter((a) => projectPlacementChannelIds.has(a.channel_id));
|
||||||
|
}
|
||||||
|
return Promise.resolve(paginate(items));
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
25
lib/demo/constants.ts
Normal file
25
lib/demo/constants.ts
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
// ============================================================================
|
||||||
|
// Demo Mode Constants
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
// Demo workspace ID - красивый и запоминающийся
|
||||||
|
export const DEMO_WORKSPACE_ID = "demo";
|
||||||
|
|
||||||
|
// Check if workspace is demo
|
||||||
|
export const isDemoWorkspace = (workspaceId: string | undefined): boolean => {
|
||||||
|
return workspaceId === DEMO_WORKSPACE_ID;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Demo user
|
||||||
|
export const DEMO_USER = {
|
||||||
|
id: "demo-user-0000-0000-000000000000",
|
||||||
|
telegram_id: 123456789,
|
||||||
|
username: "demo_user",
|
||||||
|
};
|
||||||
|
|
||||||
|
// Demo workspace
|
||||||
|
export const DEMO_WORKSPACE = {
|
||||||
|
id: DEMO_WORKSPACE_ID,
|
||||||
|
name: "Демо Workspace",
|
||||||
|
};
|
||||||
|
|
||||||
309
lib/demo/demo-aware-api.ts
Normal file
309
lib/demo/demo-aware-api.ts
Normal file
@@ -0,0 +1,309 @@
|
|||||||
|
// ============================================================================
|
||||||
|
// Demo-Aware API Wrapper
|
||||||
|
// ============================================================================
|
||||||
|
// This wrapper checks if the workspace is demo and returns mock data instead
|
||||||
|
|
||||||
|
import { isDemoWorkspace, DEMO_WORKSPACE_ID } from "./constants";
|
||||||
|
import {
|
||||||
|
demoChannels,
|
||||||
|
demoCreatives,
|
||||||
|
demoPlacements,
|
||||||
|
demoPurchasePlanChannels,
|
||||||
|
demoSpendingAnalytics,
|
||||||
|
demoCreativeAnalytics,
|
||||||
|
demoChannelAnalytics,
|
||||||
|
demoProjects,
|
||||||
|
demoWorkspace,
|
||||||
|
demoMember,
|
||||||
|
} from "./mock-data";
|
||||||
|
import type {
|
||||||
|
PaginatedResponse,
|
||||||
|
Channel,
|
||||||
|
Creative,
|
||||||
|
Placement,
|
||||||
|
PurchasePlanChannel,
|
||||||
|
SpendingAnalytics,
|
||||||
|
CreativeAnalyticsItem,
|
||||||
|
ChannelAnalyticsItem,
|
||||||
|
Project,
|
||||||
|
Workspace,
|
||||||
|
WorkspaceMember,
|
||||||
|
DateGrouping,
|
||||||
|
} from "@/lib/types/api";
|
||||||
|
import {
|
||||||
|
workspacesApi,
|
||||||
|
projectsApi,
|
||||||
|
channelsApi,
|
||||||
|
creativesApi,
|
||||||
|
placementsApi,
|
||||||
|
purchasePlanApi,
|
||||||
|
analyticsApi,
|
||||||
|
membersApi,
|
||||||
|
} from "@/lib/api";
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Helper
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
const paginate = <T>(items: T[], page = 1, size = 50): PaginatedResponse<T> => ({
|
||||||
|
items: items.slice((page - 1) * size, page * size),
|
||||||
|
total: items.length,
|
||||||
|
page,
|
||||||
|
size,
|
||||||
|
pages: Math.ceil(items.length / size),
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Demo-Aware Workspaces API
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export const demoAwareWorkspacesApi = {
|
||||||
|
list: async (params?: { page?: number; size?: number }) => {
|
||||||
|
// Always include demo workspace in list for demo mode
|
||||||
|
return workspacesApi.list(params);
|
||||||
|
},
|
||||||
|
get: async (workspaceId: string): Promise<Workspace> => {
|
||||||
|
if (isDemoWorkspace(workspaceId)) {
|
||||||
|
return demoWorkspace;
|
||||||
|
}
|
||||||
|
// Workspaces API doesn't have get method, fetch from list
|
||||||
|
const response = await workspacesApi.list({ size: 100 });
|
||||||
|
const workspace = response.items.find(w => w.id === workspaceId);
|
||||||
|
if (!workspace) throw new Error("Workspace not found");
|
||||||
|
return workspace;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Demo-Aware Projects API
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export const demoAwareProjectsApi = {
|
||||||
|
list: async (
|
||||||
|
workspaceId: string,
|
||||||
|
params?: { page?: number; size?: number }
|
||||||
|
): Promise<PaginatedResponse<Project>> => {
|
||||||
|
if (isDemoWorkspace(workspaceId)) {
|
||||||
|
return paginate(demoProjects, params?.page, params?.size);
|
||||||
|
}
|
||||||
|
return projectsApi.list(workspaceId, params);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Demo-Aware Members API
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export const demoAwareMembersApi = {
|
||||||
|
list: async (
|
||||||
|
workspaceId: string,
|
||||||
|
params?: { page?: number; size?: number }
|
||||||
|
): Promise<PaginatedResponse<WorkspaceMember>> => {
|
||||||
|
if (isDemoWorkspace(workspaceId)) {
|
||||||
|
return paginate([demoMember], params?.page, params?.size);
|
||||||
|
}
|
||||||
|
return membersApi.list(workspaceId, params);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Demo-Aware Channels API
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export const demoAwareChannelsApi = {
|
||||||
|
list: async (params?: {
|
||||||
|
page?: number;
|
||||||
|
size?: number;
|
||||||
|
username?: string;
|
||||||
|
}): Promise<PaginatedResponse<Channel>> => {
|
||||||
|
// Channels are global, check if we're in demo context via some mechanism
|
||||||
|
// For now, always return real data - demo channels are handled per-workspace
|
||||||
|
return channelsApi.list(params);
|
||||||
|
},
|
||||||
|
listForDemo: (): PaginatedResponse<Channel> => {
|
||||||
|
return paginate(demoChannels);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Demo-Aware Creatives API
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export const demoAwareCreativesApi = {
|
||||||
|
list: async (
|
||||||
|
workspaceId: string,
|
||||||
|
params?: {
|
||||||
|
project_id?: string;
|
||||||
|
include_archived?: boolean;
|
||||||
|
page?: number;
|
||||||
|
size?: number;
|
||||||
|
}
|
||||||
|
): Promise<PaginatedResponse<Creative>> => {
|
||||||
|
if (isDemoWorkspace(workspaceId)) {
|
||||||
|
let items = demoCreatives;
|
||||||
|
if (params?.project_id) {
|
||||||
|
items = items.filter((c) => c.project_id === params.project_id);
|
||||||
|
}
|
||||||
|
if (!params?.include_archived) {
|
||||||
|
items = items.filter((c) => c.status === "active");
|
||||||
|
}
|
||||||
|
return paginate(items, params?.page, params?.size);
|
||||||
|
}
|
||||||
|
return creativesApi.list(workspaceId, params);
|
||||||
|
},
|
||||||
|
get: async (workspaceId: string, creativeId: string): Promise<Creative> => {
|
||||||
|
if (isDemoWorkspace(workspaceId)) {
|
||||||
|
const creative = demoCreatives.find((c) => c.id === creativeId);
|
||||||
|
if (!creative) throw new Error("Creative not found");
|
||||||
|
return creative;
|
||||||
|
}
|
||||||
|
return creativesApi.get(workspaceId, creativeId);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Demo-Aware Placements API
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export const demoAwarePlacementsApi = {
|
||||||
|
list: async (
|
||||||
|
workspaceId: string,
|
||||||
|
params?: {
|
||||||
|
project_id?: string;
|
||||||
|
placement_channel_id?: string;
|
||||||
|
creative_id?: string;
|
||||||
|
include_archived?: boolean;
|
||||||
|
page?: number;
|
||||||
|
size?: number;
|
||||||
|
}
|
||||||
|
): Promise<PaginatedResponse<Placement>> => {
|
||||||
|
if (isDemoWorkspace(workspaceId)) {
|
||||||
|
let items = demoPlacements;
|
||||||
|
if (params?.project_id) {
|
||||||
|
items = items.filter((p) => p.project_id === params.project_id);
|
||||||
|
}
|
||||||
|
if (params?.placement_channel_id) {
|
||||||
|
items = items.filter(
|
||||||
|
(p) => p.placement_channel_id === params.placement_channel_id
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (params?.creative_id) {
|
||||||
|
items = items.filter((p) => p.creative_id === params.creative_id);
|
||||||
|
}
|
||||||
|
if (!params?.include_archived) {
|
||||||
|
items = items.filter((p) => p.status === "active");
|
||||||
|
}
|
||||||
|
return paginate(items, params?.page, params?.size);
|
||||||
|
}
|
||||||
|
return placementsApi.list(workspaceId, params);
|
||||||
|
},
|
||||||
|
get: async (workspaceId: string, placementId: string): Promise<Placement> => {
|
||||||
|
if (isDemoWorkspace(workspaceId)) {
|
||||||
|
const placement = demoPlacements.find((p) => p.id === placementId);
|
||||||
|
if (!placement) throw new Error("Placement not found");
|
||||||
|
return placement;
|
||||||
|
}
|
||||||
|
return placementsApi.get(workspaceId, placementId);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Demo-Aware Purchase Plan API
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export const demoAwarePurchasePlanApi = {
|
||||||
|
list: async (
|
||||||
|
workspaceId: string,
|
||||||
|
projectId: string,
|
||||||
|
params?: { page?: number; size?: number }
|
||||||
|
): Promise<PaginatedResponse<PurchasePlanChannel>> => {
|
||||||
|
if (isDemoWorkspace(workspaceId)) {
|
||||||
|
const items = demoPurchasePlanChannels[projectId] || [];
|
||||||
|
return paginate(items, params?.page, params?.size);
|
||||||
|
}
|
||||||
|
return purchasePlanApi.list(workspaceId, projectId, params);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Demo-Aware Analytics API
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export const demoAwareAnalyticsApi = {
|
||||||
|
spending: async (
|
||||||
|
workspaceId: string,
|
||||||
|
params?: {
|
||||||
|
project_id?: string;
|
||||||
|
date_from?: string;
|
||||||
|
date_to?: string;
|
||||||
|
grouping?: DateGrouping;
|
||||||
|
}
|
||||||
|
): Promise<SpendingAnalytics> => {
|
||||||
|
if (isDemoWorkspace(workspaceId)) {
|
||||||
|
if (params?.project_id) {
|
||||||
|
const projectPlacements = demoPlacements.filter(
|
||||||
|
(p) => p.project_id === params.project_id && p.status === "active"
|
||||||
|
);
|
||||||
|
const totalCost = projectPlacements.reduce(
|
||||||
|
(sum, p) => sum + (p.cost || 0),
|
||||||
|
0
|
||||||
|
);
|
||||||
|
const totalSubs = projectPlacements.reduce(
|
||||||
|
(sum, p) => sum + (p.subscriptions_count || 0),
|
||||||
|
0
|
||||||
|
);
|
||||||
|
const totalViews = projectPlacements.reduce(
|
||||||
|
(sum, p) => sum + (p.views_count || 0),
|
||||||
|
0
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
total_cost: totalCost,
|
||||||
|
total_subscriptions: totalSubs,
|
||||||
|
total_views: totalViews,
|
||||||
|
avg_cpf: totalSubs > 0 ? totalCost / totalSubs : null,
|
||||||
|
avg_cpm: totalViews > 0 ? (totalCost / totalViews) * 1000 : null,
|
||||||
|
chart_data: demoSpendingAnalytics.chart_data.slice(0, 4),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return demoSpendingAnalytics;
|
||||||
|
}
|
||||||
|
return analyticsApi.spending(workspaceId, params);
|
||||||
|
},
|
||||||
|
creatives: async (
|
||||||
|
workspaceId: string,
|
||||||
|
params?: { project_id?: string; page?: number; size?: number }
|
||||||
|
): Promise<PaginatedResponse<CreativeAnalyticsItem>> => {
|
||||||
|
if (isDemoWorkspace(workspaceId)) {
|
||||||
|
let items = demoCreativeAnalytics;
|
||||||
|
if (params?.project_id) {
|
||||||
|
const projectCreativeIds = demoCreatives
|
||||||
|
.filter((c) => c.project_id === params.project_id)
|
||||||
|
.map((c) => c.id);
|
||||||
|
items = items.filter((a) => projectCreativeIds.includes(a.id));
|
||||||
|
}
|
||||||
|
return paginate(items, params?.page, params?.size);
|
||||||
|
}
|
||||||
|
return analyticsApi.creatives(workspaceId, params);
|
||||||
|
},
|
||||||
|
channels: async (
|
||||||
|
workspaceId: string,
|
||||||
|
params?: { project_id?: string; page?: number; size?: number }
|
||||||
|
): Promise<PaginatedResponse<ChannelAnalyticsItem>> => {
|
||||||
|
if (isDemoWorkspace(workspaceId)) {
|
||||||
|
let items = demoChannelAnalytics;
|
||||||
|
if (params?.project_id) {
|
||||||
|
const projectPlacementChannelIds = new Set(
|
||||||
|
demoPlacements
|
||||||
|
.filter((p) => p.project_id === params.project_id)
|
||||||
|
.map((p) => p.placement_channel_id)
|
||||||
|
);
|
||||||
|
items = items.filter((a) =>
|
||||||
|
projectPlacementChannelIds.has(a.channel_id)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return paginate(items, params?.page, params?.size);
|
||||||
|
}
|
||||||
|
return analyticsApi.channels(workspaceId, params);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
272
lib/demo/hooks.ts
Normal file
272
lib/demo/hooks.ts
Normal file
@@ -0,0 +1,272 @@
|
|||||||
|
// ============================================================================
|
||||||
|
// Demo Mode Hooks
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
import { useWorkspace } from "@/components/providers/workspace-provider";
|
||||||
|
import { demoApi } from "./api";
|
||||||
|
import {
|
||||||
|
demoChannels,
|
||||||
|
demoCreatives,
|
||||||
|
demoPlacements,
|
||||||
|
demoPurchasePlanChannels,
|
||||||
|
demoSpendingAnalytics,
|
||||||
|
demoCreativeAnalytics,
|
||||||
|
demoChannelAnalytics,
|
||||||
|
} from "./mock-data";
|
||||||
|
import type {
|
||||||
|
Channel,
|
||||||
|
Creative,
|
||||||
|
Placement,
|
||||||
|
PurchasePlanChannel,
|
||||||
|
SpendingAnalytics,
|
||||||
|
CreativeAnalyticsItem,
|
||||||
|
ChannelAnalyticsItem,
|
||||||
|
PaginatedResponse,
|
||||||
|
} from "@/lib/types/api";
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Types
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
interface UseDemoDataResult<T> {
|
||||||
|
data: T | null;
|
||||||
|
isDemo: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Hook: Check if should use demo mode
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export const useIsDemoMode = (): boolean => {
|
||||||
|
const { isDemoMode } = useWorkspace();
|
||||||
|
return isDemoMode;
|
||||||
|
};
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Hook: Get demo channels
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export const useDemoChannels = (): UseDemoDataResult<PaginatedResponse<Channel>> => {
|
||||||
|
const { isDemoMode } = useWorkspace();
|
||||||
|
|
||||||
|
if (isDemoMode) {
|
||||||
|
return {
|
||||||
|
data: {
|
||||||
|
items: demoChannels,
|
||||||
|
total: demoChannels.length,
|
||||||
|
page: 1,
|
||||||
|
size: 50,
|
||||||
|
pages: 1,
|
||||||
|
},
|
||||||
|
isDemo: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return { data: null, isDemo: false };
|
||||||
|
};
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Hook: Get demo creatives
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export const useDemoCreatives = (
|
||||||
|
projectId?: string
|
||||||
|
): UseDemoDataResult<PaginatedResponse<Creative>> => {
|
||||||
|
const { isDemoMode } = useWorkspace();
|
||||||
|
|
||||||
|
if (isDemoMode) {
|
||||||
|
let items = demoCreatives;
|
||||||
|
if (projectId) {
|
||||||
|
items = items.filter((c) => c.project_id === projectId);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
data: {
|
||||||
|
items,
|
||||||
|
total: items.length,
|
||||||
|
page: 1,
|
||||||
|
size: 50,
|
||||||
|
pages: 1,
|
||||||
|
},
|
||||||
|
isDemo: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return { data: null, isDemo: false };
|
||||||
|
};
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Hook: Get demo placements
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export const useDemoPlacements = (
|
||||||
|
projectId?: string
|
||||||
|
): UseDemoDataResult<PaginatedResponse<Placement>> => {
|
||||||
|
const { isDemoMode } = useWorkspace();
|
||||||
|
|
||||||
|
if (isDemoMode) {
|
||||||
|
let items = demoPlacements;
|
||||||
|
if (projectId) {
|
||||||
|
items = items.filter((p) => p.project_id === projectId);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
data: {
|
||||||
|
items,
|
||||||
|
total: items.length,
|
||||||
|
page: 1,
|
||||||
|
size: 50,
|
||||||
|
pages: 1,
|
||||||
|
},
|
||||||
|
isDemo: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return { data: null, isDemo: false };
|
||||||
|
};
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Hook: Get demo purchase plan
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export const useDemoPurchasePlan = (
|
||||||
|
projectId: string
|
||||||
|
): UseDemoDataResult<PaginatedResponse<PurchasePlanChannel>> => {
|
||||||
|
const { isDemoMode } = useWorkspace();
|
||||||
|
|
||||||
|
if (isDemoMode) {
|
||||||
|
const items = demoPurchasePlanChannels[projectId] || [];
|
||||||
|
|
||||||
|
return {
|
||||||
|
data: {
|
||||||
|
items,
|
||||||
|
total: items.length,
|
||||||
|
page: 1,
|
||||||
|
size: 50,
|
||||||
|
pages: 1,
|
||||||
|
},
|
||||||
|
isDemo: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return { data: null, isDemo: false };
|
||||||
|
};
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Hook: Get demo spending analytics
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export const useDemoSpendingAnalytics = (
|
||||||
|
projectId?: string
|
||||||
|
): UseDemoDataResult<SpendingAnalytics> => {
|
||||||
|
const { isDemoMode } = useWorkspace();
|
||||||
|
|
||||||
|
if (isDemoMode) {
|
||||||
|
if (projectId) {
|
||||||
|
// Filter by project
|
||||||
|
const projectPlacements = demoPlacements.filter(
|
||||||
|
(p) => p.project_id === projectId && p.status === "active"
|
||||||
|
);
|
||||||
|
const totalCost = projectPlacements.reduce(
|
||||||
|
(sum, p) => sum + (p.cost || 0),
|
||||||
|
0
|
||||||
|
);
|
||||||
|
const totalSubs = projectPlacements.reduce(
|
||||||
|
(sum, p) => sum + (p.subscriptions_count || 0),
|
||||||
|
0
|
||||||
|
);
|
||||||
|
const totalViews = projectPlacements.reduce(
|
||||||
|
(sum, p) => sum + (p.views_count || 0),
|
||||||
|
0
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
data: {
|
||||||
|
total_cost: totalCost,
|
||||||
|
total_subscriptions: totalSubs,
|
||||||
|
total_views: totalViews,
|
||||||
|
avg_cpf: totalSubs > 0 ? totalCost / totalSubs : null,
|
||||||
|
avg_cpm: totalViews > 0 ? (totalCost / totalViews) * 1000 : null,
|
||||||
|
chart_data: demoSpendingAnalytics.chart_data.slice(0, 3),
|
||||||
|
},
|
||||||
|
isDemo: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
data: demoSpendingAnalytics,
|
||||||
|
isDemo: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return { data: null, isDemo: false };
|
||||||
|
};
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Hook: Get demo creative analytics
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export const useDemoCreativeAnalytics = (
|
||||||
|
projectId?: string
|
||||||
|
): UseDemoDataResult<PaginatedResponse<CreativeAnalyticsItem>> => {
|
||||||
|
const { isDemoMode } = useWorkspace();
|
||||||
|
|
||||||
|
if (isDemoMode) {
|
||||||
|
let items = demoCreativeAnalytics;
|
||||||
|
if (projectId) {
|
||||||
|
const projectCreativeIds = demoCreatives
|
||||||
|
.filter((c) => c.project_id === projectId)
|
||||||
|
.map((c) => c.id);
|
||||||
|
items = items.filter((a) => projectCreativeIds.includes(a.id));
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
data: {
|
||||||
|
items,
|
||||||
|
total: items.length,
|
||||||
|
page: 1,
|
||||||
|
size: 50,
|
||||||
|
pages: 1,
|
||||||
|
},
|
||||||
|
isDemo: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return { data: null, isDemo: false };
|
||||||
|
};
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Hook: Get demo channel analytics
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export const useDemoChannelAnalytics = (
|
||||||
|
projectId?: string
|
||||||
|
): UseDemoDataResult<PaginatedResponse<ChannelAnalyticsItem>> => {
|
||||||
|
const { isDemoMode } = useWorkspace();
|
||||||
|
|
||||||
|
if (isDemoMode) {
|
||||||
|
let items = demoChannelAnalytics;
|
||||||
|
if (projectId) {
|
||||||
|
const projectPlacementChannelIds = new Set(
|
||||||
|
demoPlacements
|
||||||
|
.filter((p) => p.project_id === projectId)
|
||||||
|
.map((p) => p.placement_channel_id)
|
||||||
|
);
|
||||||
|
items = items.filter((a) => projectPlacementChannelIds.has(a.channel_id));
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
data: {
|
||||||
|
items,
|
||||||
|
total: items.length,
|
||||||
|
page: 1,
|
||||||
|
size: 50,
|
||||||
|
pages: 1,
|
||||||
|
},
|
||||||
|
isDemo: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return { data: null, isDemo: false };
|
||||||
|
};
|
||||||
|
|
||||||
10
lib/demo/index.ts
Normal file
10
lib/demo/index.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
// ============================================================================
|
||||||
|
// Demo Module Exports
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export * from "./constants";
|
||||||
|
export * from "./mock-data";
|
||||||
|
export { demoApi } from "./api";
|
||||||
|
export * from "./hooks";
|
||||||
|
export * from "./demo-aware-api";
|
||||||
|
|
||||||
583
lib/demo/mock-data.ts
Normal file
583
lib/demo/mock-data.ts
Normal file
@@ -0,0 +1,583 @@
|
|||||||
|
// ============================================================================
|
||||||
|
// Demo Mock Data
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
import type {
|
||||||
|
Workspace,
|
||||||
|
Project,
|
||||||
|
Channel,
|
||||||
|
Creative,
|
||||||
|
Placement,
|
||||||
|
PurchasePlanChannel,
|
||||||
|
WorkspaceMember,
|
||||||
|
Permission,
|
||||||
|
SpendingAnalytics,
|
||||||
|
CreativeAnalyticsItem,
|
||||||
|
ChannelAnalyticsItem,
|
||||||
|
} from "@/lib/types/api";
|
||||||
|
import { DEMO_WORKSPACE_ID, DEMO_USER } from "./constants";
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Workspace & User
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export const demoWorkspace: Workspace = {
|
||||||
|
id: DEMO_WORKSPACE_ID,
|
||||||
|
name: "Демо Workspace",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const demoMember: WorkspaceMember = {
|
||||||
|
id: "demo-member-0000-0000-000000000001",
|
||||||
|
user: {
|
||||||
|
id: DEMO_USER.id,
|
||||||
|
telegram_id: DEMO_USER.telegram_id,
|
||||||
|
username: DEMO_USER.username,
|
||||||
|
},
|
||||||
|
status: "active",
|
||||||
|
permissions: [{ key: "admin_full" }],
|
||||||
|
};
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Projects (Target Channels)
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export const demoProjects: Project[] = [
|
||||||
|
{
|
||||||
|
id: "proj-0001-0000-0000-000000000001",
|
||||||
|
telegram_id: -1001234567001,
|
||||||
|
title: "Крипто Новости",
|
||||||
|
username: "crypto_news_demo",
|
||||||
|
status: "active",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "proj-0002-0000-0000-000000000002",
|
||||||
|
telegram_id: -1001234567002,
|
||||||
|
title: "IT Вакансии",
|
||||||
|
username: "it_jobs_demo",
|
||||||
|
status: "active",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "proj-0003-0000-0000-000000000003",
|
||||||
|
telegram_id: -1001234567003,
|
||||||
|
title: "Стартапы и Бизнес",
|
||||||
|
username: "startups_demo",
|
||||||
|
status: "active",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Channels (External Channels for placements)
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export const demoChannels: Channel[] = [
|
||||||
|
{
|
||||||
|
id: "chan-0001-0000-0000-000000000001",
|
||||||
|
telegram_id: -1002345678001,
|
||||||
|
title: "Технологии Будущего",
|
||||||
|
username: "tech_future",
|
||||||
|
description: "Новости о технологиях и инновациях",
|
||||||
|
subscribers_count: 125000,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "chan-0002-0000-0000-000000000002",
|
||||||
|
telegram_id: -1002345678002,
|
||||||
|
title: "Инвестиции Pro",
|
||||||
|
username: "invest_pro",
|
||||||
|
description: "Аналитика и инвестиционные идеи",
|
||||||
|
subscribers_count: 89000,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "chan-0003-0000-0000-000000000003",
|
||||||
|
telegram_id: -1002345678003,
|
||||||
|
title: "Программисты",
|
||||||
|
username: "programmers_hub",
|
||||||
|
description: "Сообщество разработчиков",
|
||||||
|
subscribers_count: 156000,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "chan-0004-0000-0000-000000000004",
|
||||||
|
telegram_id: -1002345678004,
|
||||||
|
title: "Маркетинг Pro",
|
||||||
|
username: "marketing_pro",
|
||||||
|
description: "Digital маркетинг и реклама",
|
||||||
|
subscribers_count: 67000,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "chan-0005-0000-0000-000000000005",
|
||||||
|
telegram_id: -1002345678005,
|
||||||
|
title: "Финансовая грамотность",
|
||||||
|
username: "finance_edu",
|
||||||
|
description: "Обучение финансам",
|
||||||
|
subscribers_count: 234000,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "chan-0006-0000-0000-000000000006",
|
||||||
|
telegram_id: -1002345678006,
|
||||||
|
title: "Startup Daily",
|
||||||
|
username: "startup_daily",
|
||||||
|
description: "Ежедневные новости стартапов",
|
||||||
|
subscribers_count: 45000,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Creatives
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export const demoCreatives: Creative[] = [
|
||||||
|
{
|
||||||
|
id: "crea-0001-0000-0000-000000000001",
|
||||||
|
name: "Крипто - Основной",
|
||||||
|
text: "🚀 Присоединяйтесь к лучшему крипто-каналу!\n\n💰 Ежедневные сигналы\n📊 Аналитика рынка\n🎯 Точные прогнозы\n\n{invite_link}",
|
||||||
|
status: "active",
|
||||||
|
placements_count: 12,
|
||||||
|
project_id: "proj-0001-0000-0000-000000000001",
|
||||||
|
project_channel_title: "Крипто Новости",
|
||||||
|
created_at: "2024-11-15T10:00:00Z",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "crea-0002-0000-0000-000000000002",
|
||||||
|
name: "Крипто - Агрессивный",
|
||||||
|
text: "⚡️ СРОЧНО! Секретный канал для избранных!\n\n🔥 Закрытая информация\n💎 Альткоины x100\n🚀 Успей до памп!\n\n{invite_link}",
|
||||||
|
status: "active",
|
||||||
|
placements_count: 5,
|
||||||
|
project_id: "proj-0001-0000-0000-000000000001",
|
||||||
|
project_channel_title: "Крипто Новости",
|
||||||
|
created_at: "2024-11-20T14:30:00Z",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "crea-0003-0000-0000-000000000003",
|
||||||
|
name: "IT Jobs - Основной",
|
||||||
|
text: "👨💻 Ищешь работу в IT?\n\n✅ Вакансии от топ-компаний\n✅ Удаленка и офис\n✅ Junior - Senior\n\nПодписывайся: {invite_link}",
|
||||||
|
status: "active",
|
||||||
|
placements_count: 8,
|
||||||
|
project_id: "proj-0002-0000-0000-000000000002",
|
||||||
|
project_channel_title: "IT Вакансии",
|
||||||
|
created_at: "2024-10-01T09:00:00Z",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "crea-0004-0000-0000-000000000004",
|
||||||
|
name: "Startups - Нетворкинг",
|
||||||
|
text: "🤝 Нетворкинг для стартаперов\n\n🎯 Найди кофаундера\n💡 Обменяйся идеями\n🚀 Развивай проект\n\n{invite_link}",
|
||||||
|
status: "active",
|
||||||
|
placements_count: 6,
|
||||||
|
project_id: "proj-0003-0000-0000-000000000003",
|
||||||
|
project_channel_title: "Стартапы и Бизнес",
|
||||||
|
created_at: "2024-12-01T16:00:00Z",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "crea-0005-0000-0000-000000000005",
|
||||||
|
name: "IT Jobs - Тестовый",
|
||||||
|
text: "🔥 Тестовый креатив для IT вакансий\n\n{invite_link}",
|
||||||
|
status: "archived",
|
||||||
|
placements_count: 2,
|
||||||
|
project_id: "proj-0002-0000-0000-000000000002",
|
||||||
|
project_channel_title: "IT Вакансии",
|
||||||
|
created_at: "2024-09-15T11:00:00Z",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Placements
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export const demoPlacements: Placement[] = [
|
||||||
|
{
|
||||||
|
id: "plac-0001-0000-0000-000000000001",
|
||||||
|
placement_date: "2024-12-20T12:00:00Z",
|
||||||
|
cost: 5500,
|
||||||
|
comment: "Отличное размещение",
|
||||||
|
ad_post_url: "https://t.me/tech_future/1234",
|
||||||
|
invite_link_type: "approval",
|
||||||
|
invite_link: "https://t.me/+ABC123demo1",
|
||||||
|
status: "active",
|
||||||
|
subscriptions_count: 156,
|
||||||
|
views_count: 8500,
|
||||||
|
views_availability: "available",
|
||||||
|
last_views_fetch_at: "2024-12-21T10:00:00Z",
|
||||||
|
created_at: "2024-12-19T15:00:00Z",
|
||||||
|
project_id: "proj-0001-0000-0000-000000000001",
|
||||||
|
project_channel_title: "Крипто Новости",
|
||||||
|
placement_channel_id: "chan-0001-0000-0000-000000000001",
|
||||||
|
placement_channel_title: "Технологии Будущего",
|
||||||
|
creative_id: "crea-0001-0000-0000-000000000001",
|
||||||
|
creative_name: "Крипто - Основной",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "plac-0002-0000-0000-000000000002",
|
||||||
|
placement_date: "2024-12-18T14:00:00Z",
|
||||||
|
cost: 3200,
|
||||||
|
comment: null,
|
||||||
|
ad_post_url: "https://t.me/invest_pro/5678",
|
||||||
|
invite_link_type: "approval",
|
||||||
|
invite_link: "https://t.me/+ABC123demo2",
|
||||||
|
status: "active",
|
||||||
|
subscriptions_count: 89,
|
||||||
|
views_count: 4200,
|
||||||
|
views_availability: "available",
|
||||||
|
last_views_fetch_at: "2024-12-20T08:00:00Z",
|
||||||
|
created_at: "2024-12-17T10:00:00Z",
|
||||||
|
project_id: "proj-0001-0000-0000-000000000001",
|
||||||
|
project_channel_title: "Крипто Новости",
|
||||||
|
placement_channel_id: "chan-0002-0000-0000-000000000002",
|
||||||
|
placement_channel_title: "Инвестиции Pro",
|
||||||
|
creative_id: "crea-0001-0000-0000-000000000001",
|
||||||
|
creative_name: "Крипто - Основной",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "plac-0003-0000-0000-000000000003",
|
||||||
|
placement_date: "2024-12-15T10:00:00Z",
|
||||||
|
cost: 4800,
|
||||||
|
comment: "Программисты - хорошая аудитория",
|
||||||
|
ad_post_url: "https://t.me/programmers_hub/9012",
|
||||||
|
invite_link_type: "public",
|
||||||
|
invite_link: "https://t.me/+ABC123demo3",
|
||||||
|
status: "active",
|
||||||
|
subscriptions_count: 234,
|
||||||
|
views_count: 12000,
|
||||||
|
views_availability: "available",
|
||||||
|
last_views_fetch_at: "2024-12-19T14:00:00Z",
|
||||||
|
created_at: "2024-12-14T09:00:00Z",
|
||||||
|
project_id: "proj-0002-0000-0000-000000000002",
|
||||||
|
project_channel_title: "IT Вакансии",
|
||||||
|
placement_channel_id: "chan-0003-0000-0000-000000000003",
|
||||||
|
placement_channel_title: "Программисты",
|
||||||
|
creative_id: "crea-0003-0000-0000-000000000003",
|
||||||
|
creative_name: "IT Jobs - Основной",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "plac-0004-0000-0000-000000000004",
|
||||||
|
placement_date: "2024-12-10T16:00:00Z",
|
||||||
|
cost: 2500,
|
||||||
|
comment: null,
|
||||||
|
ad_post_url: "https://t.me/marketing_pro/3456",
|
||||||
|
invite_link_type: "approval",
|
||||||
|
invite_link: "https://t.me/+ABC123demo4",
|
||||||
|
status: "active",
|
||||||
|
subscriptions_count: 67,
|
||||||
|
views_count: 3100,
|
||||||
|
views_availability: "manual",
|
||||||
|
last_views_fetch_at: "2024-12-12T11:00:00Z",
|
||||||
|
created_at: "2024-12-09T14:00:00Z",
|
||||||
|
project_id: "proj-0003-0000-0000-000000000003",
|
||||||
|
project_channel_title: "Стартапы и Бизнес",
|
||||||
|
placement_channel_id: "chan-0004-0000-0000-000000000004",
|
||||||
|
placement_channel_title: "Маркетинг Pro",
|
||||||
|
creative_id: "crea-0004-0000-0000-000000000004",
|
||||||
|
creative_name: "Startups - Нетворкинг",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "plac-0005-0000-0000-000000000005",
|
||||||
|
placement_date: "2024-12-05T11:00:00Z",
|
||||||
|
cost: 6200,
|
||||||
|
comment: "Финансовая аудитория",
|
||||||
|
ad_post_url: "https://t.me/finance_edu/7890",
|
||||||
|
invite_link_type: "approval",
|
||||||
|
invite_link: "https://t.me/+ABC123demo5",
|
||||||
|
status: "active",
|
||||||
|
subscriptions_count: 312,
|
||||||
|
views_count: 15600,
|
||||||
|
views_availability: "available",
|
||||||
|
last_views_fetch_at: "2024-12-08T09:00:00Z",
|
||||||
|
created_at: "2024-12-04T10:00:00Z",
|
||||||
|
project_id: "proj-0001-0000-0000-000000000001",
|
||||||
|
project_channel_title: "Крипто Новости",
|
||||||
|
placement_channel_id: "chan-0005-0000-0000-000000000005",
|
||||||
|
placement_channel_title: "Финансовая грамотность",
|
||||||
|
creative_id: "crea-0002-0000-0000-000000000002",
|
||||||
|
creative_name: "Крипто - Агрессивный",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "plac-0006-0000-0000-000000000006",
|
||||||
|
placement_date: "2024-11-28T13:00:00Z",
|
||||||
|
cost: 1800,
|
||||||
|
comment: null,
|
||||||
|
ad_post_url: "https://t.me/startup_daily/2345",
|
||||||
|
invite_link_type: "public",
|
||||||
|
invite_link: "https://t.me/+ABC123demo6",
|
||||||
|
status: "archived",
|
||||||
|
subscriptions_count: 45,
|
||||||
|
views_count: 2100,
|
||||||
|
views_availability: "unavailable",
|
||||||
|
last_views_fetch_at: null,
|
||||||
|
created_at: "2024-11-27T15:00:00Z",
|
||||||
|
project_id: "proj-0003-0000-0000-000000000003",
|
||||||
|
project_channel_title: "Стартапы и Бизнес",
|
||||||
|
placement_channel_id: "chan-0006-0000-0000-000000000006",
|
||||||
|
placement_channel_title: "Startup Daily",
|
||||||
|
creative_id: "crea-0004-0000-0000-000000000004",
|
||||||
|
creative_name: "Startups - Нетворкинг",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Purchase Plan Channels
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export const demoPurchasePlanChannels: Record<string, PurchasePlanChannel[]> = {
|
||||||
|
"proj-0001-0000-0000-000000000001": [
|
||||||
|
{
|
||||||
|
id: "plan-0001-0000-0000-000000000001",
|
||||||
|
status: "completed",
|
||||||
|
planned_cost: 5500,
|
||||||
|
comment: "Основной канал",
|
||||||
|
channel: {
|
||||||
|
id: "chan-0001-0000-0000-000000000001",
|
||||||
|
telegram_id: -1002345678001,
|
||||||
|
title: "Технологии Будущего",
|
||||||
|
username: "tech_future",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "plan-0002-0000-0000-000000000002",
|
||||||
|
status: "completed",
|
||||||
|
planned_cost: 3500,
|
||||||
|
comment: null,
|
||||||
|
channel: {
|
||||||
|
id: "chan-0002-0000-0000-000000000002",
|
||||||
|
telegram_id: -1002345678002,
|
||||||
|
title: "Инвестиции Pro",
|
||||||
|
username: "invest_pro",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "plan-0003-0000-0000-000000000003",
|
||||||
|
status: "planned",
|
||||||
|
planned_cost: 4000,
|
||||||
|
comment: "На следующую неделю",
|
||||||
|
channel: {
|
||||||
|
id: "chan-0005-0000-0000-000000000005",
|
||||||
|
telegram_id: -1002345678005,
|
||||||
|
title: "Финансовая грамотность",
|
||||||
|
username: "finance_edu",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
"proj-0002-0000-0000-000000000002": [
|
||||||
|
{
|
||||||
|
id: "plan-0004-0000-0000-000000000004",
|
||||||
|
status: "completed",
|
||||||
|
planned_cost: 4800,
|
||||||
|
comment: "IT аудитория",
|
||||||
|
channel: {
|
||||||
|
id: "chan-0003-0000-0000-000000000003",
|
||||||
|
telegram_id: -1002345678003,
|
||||||
|
title: "Программисты",
|
||||||
|
username: "programmers_hub",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "plan-0005-0000-0000-000000000005",
|
||||||
|
status: "planned",
|
||||||
|
planned_cost: 3000,
|
||||||
|
comment: null,
|
||||||
|
channel: {
|
||||||
|
id: "chan-0001-0000-0000-000000000001",
|
||||||
|
telegram_id: -1002345678001,
|
||||||
|
title: "Технологии Будущего",
|
||||||
|
username: "tech_future",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
"proj-0003-0000-0000-000000000003": [
|
||||||
|
{
|
||||||
|
id: "plan-0006-0000-0000-000000000006",
|
||||||
|
status: "completed",
|
||||||
|
planned_cost: 2500,
|
||||||
|
comment: null,
|
||||||
|
channel: {
|
||||||
|
id: "chan-0004-0000-0000-000000000004",
|
||||||
|
telegram_id: -1002345678004,
|
||||||
|
title: "Маркетинг Pro",
|
||||||
|
username: "marketing_pro",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "plan-0007-0000-0000-000000000007",
|
||||||
|
status: "completed",
|
||||||
|
planned_cost: 1800,
|
||||||
|
comment: "Стартап аудитория",
|
||||||
|
channel: {
|
||||||
|
id: "chan-0006-0000-0000-000000000006",
|
||||||
|
telegram_id: -1002345678006,
|
||||||
|
title: "Startup Daily",
|
||||||
|
username: "startup_daily",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Analytics Data
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export const demoSpendingAnalytics: SpendingAnalytics = {
|
||||||
|
total_cost: 24000,
|
||||||
|
total_subscriptions: 903,
|
||||||
|
total_views: 45500,
|
||||||
|
avg_cpf: 26.58,
|
||||||
|
avg_cpm: 527.47,
|
||||||
|
chart_data: [
|
||||||
|
{
|
||||||
|
period: "2024-11",
|
||||||
|
cost: 1800,
|
||||||
|
subscriptions: 45,
|
||||||
|
views: 2100,
|
||||||
|
cpf: 40.0,
|
||||||
|
cpm: 857.14,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
period: "2024-12-05",
|
||||||
|
cost: 6200,
|
||||||
|
subscriptions: 312,
|
||||||
|
views: 15600,
|
||||||
|
cpf: 19.87,
|
||||||
|
cpm: 397.44,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
period: "2024-12-10",
|
||||||
|
cost: 2500,
|
||||||
|
subscriptions: 67,
|
||||||
|
views: 3100,
|
||||||
|
cpf: 37.31,
|
||||||
|
cpm: 806.45,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
period: "2024-12-15",
|
||||||
|
cost: 4800,
|
||||||
|
subscriptions: 234,
|
||||||
|
views: 12000,
|
||||||
|
cpf: 20.51,
|
||||||
|
cpm: 400.0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
period: "2024-12-18",
|
||||||
|
cost: 3200,
|
||||||
|
subscriptions: 89,
|
||||||
|
views: 4200,
|
||||||
|
cpf: 35.96,
|
||||||
|
cpm: 761.9,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
period: "2024-12-20",
|
||||||
|
cost: 5500,
|
||||||
|
subscriptions: 156,
|
||||||
|
views: 8500,
|
||||||
|
cpf: 35.26,
|
||||||
|
cpm: 647.06,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
export const demoCreativeAnalytics: CreativeAnalyticsItem[] = [
|
||||||
|
{
|
||||||
|
id: "crea-0001-0000-0000-000000000001",
|
||||||
|
name: "Крипто - Основной",
|
||||||
|
placements_count: 12,
|
||||||
|
total_cost: 8700,
|
||||||
|
total_subscriptions: 245,
|
||||||
|
total_views: 12700,
|
||||||
|
avg_cpf: 35.51,
|
||||||
|
avg_cpm: 685.04,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "crea-0002-0000-0000-000000000002",
|
||||||
|
name: "Крипто - Агрессивный",
|
||||||
|
placements_count: 5,
|
||||||
|
total_cost: 6200,
|
||||||
|
total_subscriptions: 312,
|
||||||
|
total_views: 15600,
|
||||||
|
avg_cpf: 19.87,
|
||||||
|
avg_cpm: 397.44,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "crea-0003-0000-0000-000000000003",
|
||||||
|
name: "IT Jobs - Основной",
|
||||||
|
placements_count: 8,
|
||||||
|
total_cost: 4800,
|
||||||
|
total_subscriptions: 234,
|
||||||
|
total_views: 12000,
|
||||||
|
avg_cpf: 20.51,
|
||||||
|
avg_cpm: 400.0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "crea-0004-0000-0000-000000000004",
|
||||||
|
name: "Startups - Нетворкинг",
|
||||||
|
placements_count: 6,
|
||||||
|
total_cost: 4300,
|
||||||
|
total_subscriptions: 112,
|
||||||
|
total_views: 5200,
|
||||||
|
avg_cpf: 38.39,
|
||||||
|
avg_cpm: 826.92,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export const demoChannelAnalytics: ChannelAnalyticsItem[] = [
|
||||||
|
{
|
||||||
|
channel_id: "chan-0001-0000-0000-000000000001",
|
||||||
|
channel_title: "Технологии Будущего",
|
||||||
|
channel_username: "tech_future",
|
||||||
|
placements_count: 3,
|
||||||
|
total_cost: 5500,
|
||||||
|
total_subscriptions: 156,
|
||||||
|
total_views: 8500,
|
||||||
|
avg_cps: 35.26,
|
||||||
|
avg_cpm: 647.06,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
channel_id: "chan-0002-0000-0000-000000000002",
|
||||||
|
channel_title: "Инвестиции Pro",
|
||||||
|
channel_username: "invest_pro",
|
||||||
|
placements_count: 2,
|
||||||
|
total_cost: 3200,
|
||||||
|
total_subscriptions: 89,
|
||||||
|
total_views: 4200,
|
||||||
|
avg_cps: 35.96,
|
||||||
|
avg_cpm: 761.9,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
channel_id: "chan-0003-0000-0000-000000000003",
|
||||||
|
channel_title: "Программисты",
|
||||||
|
channel_username: "programmers_hub",
|
||||||
|
placements_count: 4,
|
||||||
|
total_cost: 4800,
|
||||||
|
total_subscriptions: 234,
|
||||||
|
total_views: 12000,
|
||||||
|
avg_cps: 20.51,
|
||||||
|
avg_cpm: 400.0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
channel_id: "chan-0005-0000-0000-000000000005",
|
||||||
|
channel_title: "Финансовая грамотность",
|
||||||
|
channel_username: "finance_edu",
|
||||||
|
placements_count: 2,
|
||||||
|
total_cost: 6200,
|
||||||
|
total_subscriptions: 312,
|
||||||
|
total_views: 15600,
|
||||||
|
avg_cps: 19.87,
|
||||||
|
avg_cpm: 397.44,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
channel_id: "chan-0004-0000-0000-000000000004",
|
||||||
|
channel_title: "Маркетинг Pro",
|
||||||
|
channel_username: "marketing_pro",
|
||||||
|
placements_count: 1,
|
||||||
|
total_cost: 2500,
|
||||||
|
total_subscriptions: 67,
|
||||||
|
total_views: 3100,
|
||||||
|
avg_cps: 37.31,
|
||||||
|
avg_cpm: 806.45,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
channel_id: "chan-0006-0000-0000-000000000006",
|
||||||
|
channel_title: "Startup Daily",
|
||||||
|
channel_username: "startup_daily",
|
||||||
|
placements_count: 1,
|
||||||
|
total_cost: 1800,
|
||||||
|
total_subscriptions: 45,
|
||||||
|
total_views: 2100,
|
||||||
|
avg_cps: 40.0,
|
||||||
|
avg_cpm: 857.14,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
import { TargetChannel } from "@/lib/types/api";
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// Mock Target Channels Data
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
export const mockTargetChannels: TargetChannel[] = [
|
|
||||||
{
|
|
||||||
id: "550e8400-e29b-41d4-a716-446655440010",
|
|
||||||
telegram_id: -1001234567890,
|
|
||||||
title: "Мой Главный Канал",
|
|
||||||
username: "my_main_channel",
|
|
||||||
is_active: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "550e8400-e29b-41d4-a716-446655440011",
|
|
||||||
telegram_id: -1009876543210,
|
|
||||||
title: "Тестовый Канал",
|
|
||||||
username: "test_channel_promo",
|
|
||||||
is_active: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "550e8400-e29b-41d4-a716-446655440012",
|
|
||||||
telegram_id: -1005555555555,
|
|
||||||
title: "Новости и Обновления",
|
|
||||||
username: null,
|
|
||||||
is_active: false,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
@@ -1,49 +0,0 @@
|
|||||||
import { Creative } from "@/lib/types/api";
|
|
||||||
import { mockTargetChannels } from "./channels";
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// Mock Creatives Data
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
export const mockCreatives: Creative[] = [
|
|
||||||
{
|
|
||||||
id: "cr1",
|
|
||||||
name: 'Креатив "Присоединяйся"',
|
|
||||||
text: "🔥 Присоединяйся к нашему сообществу!\n\nУзнавай первым о новых возможностях и фишках.\n\n👉 {link}",
|
|
||||||
target_channel_id: "tc1",
|
|
||||||
target_channel_title: mockTargetChannels[0].title,
|
|
||||||
created_at: "2024-01-15T10:00:00.000Z",
|
|
||||||
status: "active",
|
|
||||||
placements_count: 10,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "cr2",
|
|
||||||
name: 'Креатив "Эксклюзив"',
|
|
||||||
text: "⭐ Эксклюзивный контент только для подписчиков!\n\nНе упусти возможность быть в курсе всех трендов.\n\nПереходи: {link}",
|
|
||||||
target_channel_id: "tc1",
|
|
||||||
target_channel_title: mockTargetChannels[0].title,
|
|
||||||
created_at: "2024-01-20T12:00:00.000Z",
|
|
||||||
status: "active",
|
|
||||||
placements_count: 5,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "cr3",
|
|
||||||
name: 'Креатив "Обучение"',
|
|
||||||
text: "📚 Хочешь научиться зарабатывать больше?\n\nПодписывайся на канал с проверенными методиками!\n\n{link}",
|
|
||||||
target_channel_id: "tc2",
|
|
||||||
target_channel_title: mockTargetChannels[1].title,
|
|
||||||
created_at: "2024-02-05T09:00:00.000Z",
|
|
||||||
status: "active",
|
|
||||||
placements_count: 8,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "cr4",
|
|
||||||
name: "Старый креатив (архив)",
|
|
||||||
text: "Устаревший текст с предложением. {link}",
|
|
||||||
target_channel_id: "tc1",
|
|
||||||
target_channel_title: mockTargetChannels[0].title,
|
|
||||||
created_at: "2023-12-01T10:00:00.000Z",
|
|
||||||
status: "archived",
|
|
||||||
placements_count: 2,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
@@ -1,48 +0,0 @@
|
|||||||
import { ExternalChannel } from "@/lib/types/api";
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// Mock External Channels Data
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
export const mockExternalChannels: ExternalChannel[] = [
|
|
||||||
{
|
|
||||||
id: "550e8400-e29b-41d4-a716-446655440020",
|
|
||||||
telegram_id: -1001111111111,
|
|
||||||
title: "Канал о Маркетинге",
|
|
||||||
username: "marketing_pro",
|
|
||||||
subscribers_count: 50000,
|
|
||||||
description: "Лучшие практики маркетинга",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "550e8400-e29b-41d4-a716-446655440021",
|
|
||||||
telegram_id: -1002222222222,
|
|
||||||
title: "Бизнес и Стартапы",
|
|
||||||
username: "business_startups",
|
|
||||||
subscribers_count: 120000,
|
|
||||||
description: "Новости мира бизнеса",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "550e8400-e29b-41d4-a716-446655440022",
|
|
||||||
telegram_id: -1003333333333,
|
|
||||||
title: "IT и Технологии",
|
|
||||||
username: "it_tech_news",
|
|
||||||
subscribers_count: 85000,
|
|
||||||
description: "Все о новых технологиях",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "550e8400-e29b-41d4-a716-446655440023",
|
|
||||||
telegram_id: -1004444444444,
|
|
||||||
title: "Крипто Инвестиции",
|
|
||||||
username: "crypto_invest",
|
|
||||||
subscribers_count: 200000,
|
|
||||||
description: null,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "550e8400-e29b-41d4-a716-446655440024",
|
|
||||||
telegram_id: -1005555555555,
|
|
||||||
title: "Продажи и Переговоры",
|
|
||||||
username: "sales_expert",
|
|
||||||
subscribers_count: 35000,
|
|
||||||
description: "Экспертиза в продажах",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
@@ -1,213 +0,0 @@
|
|||||||
import { Placement, Subscription, ViewsSnapshot } from "@/lib/types/api";
|
|
||||||
import { mockTargetChannels } from "./channels";
|
|
||||||
import { mockExternalChannels } from "./external-channels";
|
|
||||||
import { mockCreatives } from "./creatives";
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// Mock Placements Data
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
export const mockPlacements: Placement[] = [
|
|
||||||
{
|
|
||||||
id: "p1",
|
|
||||||
target_channel_id: "tc1",
|
|
||||||
target_channel_title: mockTargetChannels[0].title,
|
|
||||||
external_channel_id: "ec1",
|
|
||||||
external_channel_title: mockExternalChannels[0].title,
|
|
||||||
creative_id: "cr1",
|
|
||||||
creative_name: mockCreatives[0].name,
|
|
||||||
placement_date: "2024-03-15T10:00:00.000Z",
|
|
||||||
cost: 5000,
|
|
||||||
comment: "Тестовая закупка на утро",
|
|
||||||
ad_post_url: "https://t.me/marketing_pro/1234",
|
|
||||||
invite_link_type: "approval",
|
|
||||||
invite_link: "https://t.me/+AbCdEfGhIjKlMnOp",
|
|
||||||
status: "active",
|
|
||||||
subscriptions_count: 125,
|
|
||||||
views_count: 12500,
|
|
||||||
views_availability: "available",
|
|
||||||
last_views_fetch_at: "2024-03-16T10:00:00.000Z",
|
|
||||||
created_at: "2024-03-14T15:00:00.000Z",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "p2",
|
|
||||||
target_channel_id: "tc1",
|
|
||||||
target_channel_title: mockTargetChannels[0].title,
|
|
||||||
external_channel_id: "ec2",
|
|
||||||
external_channel_title: mockExternalChannels[1].title,
|
|
||||||
creative_id: "cr2",
|
|
||||||
creative_name: mockCreatives[1].name,
|
|
||||||
placement_date: "2024-03-18T14:00:00.000Z",
|
|
||||||
cost: 12000,
|
|
||||||
comment: "Большой канал, ожидаем хороших результатов",
|
|
||||||
ad_post_url: "https://t.me/business_startups/5678",
|
|
||||||
invite_link_type: "approval",
|
|
||||||
invite_link: "https://t.me/+QrStUvWxYzAbCdEf",
|
|
||||||
status: "active",
|
|
||||||
subscriptions_count: 210,
|
|
||||||
views_count: 28000,
|
|
||||||
views_availability: "available",
|
|
||||||
last_views_fetch_at: "2024-03-19T10:00:00.000Z",
|
|
||||||
created_at: "2024-03-17T10:00:00.000Z",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "p3",
|
|
||||||
target_channel_id: "tc2",
|
|
||||||
target_channel_title: mockTargetChannels[1].title,
|
|
||||||
external_channel_id: "ec3",
|
|
||||||
external_channel_title: mockExternalChannels[2].title,
|
|
||||||
creative_id: "cr3",
|
|
||||||
creative_name: mockCreatives[2].name,
|
|
||||||
placement_date: "2024-03-20T16:00:00.000Z",
|
|
||||||
cost: 3500,
|
|
||||||
comment: null,
|
|
||||||
ad_post_url: "https://t.me/it_tech_news/9101",
|
|
||||||
invite_link_type: "public",
|
|
||||||
invite_link: "https://t.me/+GhIjKlMnOpQrStUv",
|
|
||||||
status: "active",
|
|
||||||
subscriptions_count: 75,
|
|
||||||
views_count: 8500,
|
|
||||||
views_availability: "available",
|
|
||||||
last_views_fetch_at: "2024-03-21T10:00:00.000Z",
|
|
||||||
created_at: "2024-03-19T12:00:00.000Z",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "p4",
|
|
||||||
target_channel_id: "tc1",
|
|
||||||
target_channel_title: mockTargetChannels[0].title,
|
|
||||||
external_channel_id: "ec1",
|
|
||||||
external_channel_title: mockExternalChannels[0].title,
|
|
||||||
creative_id: "cr1",
|
|
||||||
creative_name: mockCreatives[0].name,
|
|
||||||
placement_date: "2024-03-22T08:00:00.000Z",
|
|
||||||
cost: 4500,
|
|
||||||
comment: "Повтор в этом же канале",
|
|
||||||
ad_post_url: "https://t.me/marketing_pro/1290",
|
|
||||||
invite_link_type: "approval",
|
|
||||||
invite_link: "https://t.me/+XyZaBcDeFgHiJkLm",
|
|
||||||
status: "active",
|
|
||||||
subscriptions_count: 95,
|
|
||||||
views_count: null,
|
|
||||||
views_availability: "unavailable",
|
|
||||||
last_views_fetch_at: "2024-03-23T09:00:00.000Z",
|
|
||||||
created_at: "2024-03-21T14:00:00.000Z",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "p5",
|
|
||||||
target_channel_id: "tc1",
|
|
||||||
target_channel_title: mockTargetChannels[0].title,
|
|
||||||
external_channel_id: "ec2",
|
|
||||||
external_channel_title: mockExternalChannels[1].title,
|
|
||||||
creative_id: "cr1",
|
|
||||||
creative_name: mockCreatives[0].name,
|
|
||||||
placement_date: "2024-02-10T10:00:00.000Z",
|
|
||||||
cost: 10000,
|
|
||||||
comment: "Старая закупка (архив)",
|
|
||||||
ad_post_url: "https://t.me/business_startups/4567",
|
|
||||||
invite_link_type: "public",
|
|
||||||
invite_link: "https://t.me/+OldLinkArch12345",
|
|
||||||
status: "archived",
|
|
||||||
subscriptions_count: 150,
|
|
||||||
views_count: 20000,
|
|
||||||
views_availability: "available",
|
|
||||||
last_views_fetch_at: "2024-02-12T10:00:00.000Z",
|
|
||||||
created_at: "2024-02-09T10:00:00.000Z",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
// Keep old name for backward compatibility
|
|
||||||
export const mockPurchases = mockPlacements;
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// Mock Subscriptions
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
export const mockSubscriptions: Subscription[] = [
|
|
||||||
{
|
|
||||||
id: "s1",
|
|
||||||
purchase_id: "p1",
|
|
||||||
telegram_user_id: 123456789,
|
|
||||||
user_first_name: "Иван",
|
|
||||||
user_last_name: "Иванов",
|
|
||||||
user_username: "ivan_ivanov",
|
|
||||||
subscribed_at: "2024-03-15T11:35:00.000Z",
|
|
||||||
is_active: true,
|
|
||||||
event_type: "join_request",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "s2",
|
|
||||||
purchase_id: "p1",
|
|
||||||
telegram_user_id: 987654321,
|
|
||||||
user_first_name: "Мария",
|
|
||||||
user_last_name: null,
|
|
||||||
user_username: "maria123",
|
|
||||||
subscribed_at: "2024-03-15T12:05:00.000Z",
|
|
||||||
is_active: true,
|
|
||||||
event_type: "join_request",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "s3",
|
|
||||||
purchase_id: "p1",
|
|
||||||
telegram_user_id: 456789123,
|
|
||||||
user_first_name: "Алексей",
|
|
||||||
user_last_name: "Петров",
|
|
||||||
user_username: null,
|
|
||||||
subscribed_at: "2024-03-15T13:20:00.000Z",
|
|
||||||
is_active: false,
|
|
||||||
event_type: "join_request",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "s4",
|
|
||||||
purchase_id: "p2",
|
|
||||||
telegram_user_id: 789123456,
|
|
||||||
user_first_name: "Елена",
|
|
||||||
user_last_name: "Сидорова",
|
|
||||||
user_username: "elena_s",
|
|
||||||
subscribed_at: "2024-03-18T14:30:00.000Z",
|
|
||||||
is_active: true,
|
|
||||||
event_type: "chat_member",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// Mock Views History
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
export const mockViewsHistory: ViewsSnapshot[] = [
|
|
||||||
{
|
|
||||||
id: "v1",
|
|
||||||
purchase_id: "p1",
|
|
||||||
views: 10000,
|
|
||||||
fetched_at: "2024-03-15T12:00:00.000Z",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "v2",
|
|
||||||
purchase_id: "p1",
|
|
||||||
views: 11500,
|
|
||||||
fetched_at: "2024-03-15T18:00:00.000Z",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "v3",
|
|
||||||
purchase_id: "p1",
|
|
||||||
views: 12500,
|
|
||||||
fetched_at: "2024-03-16T10:00:00.000Z",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "v4",
|
|
||||||
purchase_id: "p2",
|
|
||||||
views: 25000,
|
|
||||||
fetched_at: "2024-03-18T15:00:00.000Z",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "v5",
|
|
||||||
purchase_id: "p2",
|
|
||||||
views: 27000,
|
|
||||||
fetched_at: "2024-03-18T20:00:00.000Z",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "v6",
|
|
||||||
purchase_id: "p2",
|
|
||||||
views: 28000,
|
|
||||||
fetched_at: "2024-03-19T10:00:00.000Z",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
import { User } from "@/lib/types/api";
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// Mock Users Data
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
export const mockUsers: User[] = [
|
|
||||||
{
|
|
||||||
id: "550e8400-e29b-41d4-a716-446655440001",
|
|
||||||
telegram_id: 123456789,
|
|
||||||
username: "ivan_test",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "550e8400-e29b-41d4-a716-446655440002",
|
|
||||||
telegram_id: 987654321,
|
|
||||||
username: "maria_test",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
export const mockCurrentUser = mockUsers[0];
|
|
||||||
|
|
||||||
// Mock tokens
|
|
||||||
export const mockTokens = {
|
|
||||||
access_token: "mock_access_token_123456789",
|
|
||||||
refresh_token: "mock_refresh_token_123456789",
|
|
||||||
};
|
|
||||||
@@ -1,573 +0,0 @@
|
|||||||
// ============================================================================
|
|
||||||
// Mock API Handlers
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
import {
|
|
||||||
mockCurrentUser,
|
|
||||||
mockTokens,
|
|
||||||
mockTargetChannels,
|
|
||||||
mockExternalChannels,
|
|
||||||
mockCreatives,
|
|
||||||
mockPlacements,
|
|
||||||
mockSubscriptions,
|
|
||||||
mockViewsHistory,
|
|
||||||
} from "./index";
|
|
||||||
|
|
||||||
import type {
|
|
||||||
ListResponse,
|
|
||||||
SuccessResponse,
|
|
||||||
ApiError,
|
|
||||||
TargetChannel,
|
|
||||||
ExternalChannel,
|
|
||||||
Creative,
|
|
||||||
Placement,
|
|
||||||
PlacementCreateRequest,
|
|
||||||
PlacementUpdateRequest,
|
|
||||||
CreativeCreateRequest,
|
|
||||||
ExternalChannelCreateRequest,
|
|
||||||
ViewsFetchResponse,
|
|
||||||
ViewsHistoryResponse,
|
|
||||||
AuthCompleteRequest,
|
|
||||||
AuthCompleteResponse,
|
|
||||||
AuthRefreshRequest,
|
|
||||||
AuthRefreshResponse,
|
|
||||||
AuthInitResponse,
|
|
||||||
ExternalChannelUpdateRequest,
|
|
||||||
CreativeUpdateRequest,
|
|
||||||
ExternalChannelsListResponse,
|
|
||||||
} from "@/lib/types/api";
|
|
||||||
|
|
||||||
// Simulate network delay
|
|
||||||
const delay = (ms: number = 300) =>
|
|
||||||
new Promise((resolve) => setTimeout(resolve, ms));
|
|
||||||
|
|
||||||
// Helper to generate mock error
|
|
||||||
const mockError = (code: string, message: string): ApiError => ({
|
|
||||||
error: { code, message },
|
|
||||||
});
|
|
||||||
|
|
||||||
// Helper to generate IDs
|
|
||||||
let idCounter = 1000;
|
|
||||||
const generateId = () => `mock_${idCounter++}`;
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// Mock API Handlers
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
export const mockApiHandlers = {
|
|
||||||
// --------------------------------------------------------------------------
|
|
||||||
// Auth
|
|
||||||
// --------------------------------------------------------------------------
|
|
||||||
async authInit(): Promise<AuthInitResponse> {
|
|
||||||
await delay();
|
|
||||||
return {
|
|
||||||
bot_username: "tgex_bot",
|
|
||||||
start_param: "login",
|
|
||||||
};
|
|
||||||
},
|
|
||||||
|
|
||||||
async authComplete(
|
|
||||||
data: AuthCompleteRequest | { token: string | null }
|
|
||||||
): Promise<{ access_token: string }> {
|
|
||||||
await delay();
|
|
||||||
if (!data.token) {
|
|
||||||
throw mockError("VALIDATION_ERROR", "Token is required");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Save mock user to localStorage for later retrieval
|
|
||||||
if (typeof window !== "undefined") {
|
|
||||||
localStorage.setItem("tgex_user", JSON.stringify(mockCurrentUser));
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
access_token: mockTokens.access_token,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
|
|
||||||
async authRefresh(data: AuthRefreshRequest): Promise<AuthRefreshResponse> {
|
|
||||||
await delay();
|
|
||||||
if (!data.refresh_token) {
|
|
||||||
throw mockError("VALIDATION_ERROR", "Refresh token is required");
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
access_token: `${mockTokens.access_token}_refreshed`,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
|
|
||||||
async authMe() {
|
|
||||||
await delay();
|
|
||||||
return mockCurrentUser;
|
|
||||||
},
|
|
||||||
|
|
||||||
// --------------------------------------------------------------------------
|
|
||||||
// Target Channels
|
|
||||||
// --------------------------------------------------------------------------
|
|
||||||
async getTargetChannels() {
|
|
||||||
await delay();
|
|
||||||
return {
|
|
||||||
target_channels: mockTargetChannels,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
|
|
||||||
async deleteTargetChannel(id: string): Promise<SuccessResponse> {
|
|
||||||
await delay();
|
|
||||||
const channel = mockTargetChannels.find((c) => c.id === id);
|
|
||||||
if (!channel) {
|
|
||||||
throw mockError("NOT_FOUND", "Target channel not found");
|
|
||||||
}
|
|
||||||
return { success: true };
|
|
||||||
},
|
|
||||||
|
|
||||||
// --------------------------------------------------------------------------
|
|
||||||
// External Channels
|
|
||||||
// --------------------------------------------------------------------------
|
|
||||||
async getExternalChannels(
|
|
||||||
targetChannelId: string
|
|
||||||
): Promise<ExternalChannelsListResponse> {
|
|
||||||
await delay();
|
|
||||||
// Возвращаем все каналы для указанного target channel
|
|
||||||
// В реальном API это будет фильтрация по связям через промежуточную таблицу
|
|
||||||
return {
|
|
||||||
external_channels: mockExternalChannels,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
|
|
||||||
async getExternalChannel(id: string): Promise<ExternalChannel> {
|
|
||||||
await delay();
|
|
||||||
const channel = mockExternalChannels.find((c) => c.id === id);
|
|
||||||
if (!channel) {
|
|
||||||
throw mockError("NOT_FOUND", "External channel not found");
|
|
||||||
}
|
|
||||||
return channel;
|
|
||||||
},
|
|
||||||
|
|
||||||
async createExternalChannel(
|
|
||||||
data: ExternalChannelCreateRequest
|
|
||||||
): Promise<ExternalChannel> {
|
|
||||||
await delay(500);
|
|
||||||
const newChannel: ExternalChannel = {
|
|
||||||
id: generateId(),
|
|
||||||
telegram_id: data.telegram_id,
|
|
||||||
title: data.title,
|
|
||||||
username: data.username || null,
|
|
||||||
subscribers_count: data.subscribers_count || null,
|
|
||||||
description: data.description || null,
|
|
||||||
};
|
|
||||||
mockExternalChannels.push(newChannel);
|
|
||||||
return newChannel;
|
|
||||||
},
|
|
||||||
|
|
||||||
async updateExternalChannel(
|
|
||||||
id: string,
|
|
||||||
data: ExternalChannelUpdateRequest
|
|
||||||
): Promise<ExternalChannel> {
|
|
||||||
await delay();
|
|
||||||
const channel = mockExternalChannels.find((c) => c.id === id);
|
|
||||||
if (!channel) {
|
|
||||||
throw mockError("NOT_FOUND", "External channel not found");
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
...channel,
|
|
||||||
...data,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
|
|
||||||
async deleteExternalChannel(id: string): Promise<SuccessResponse> {
|
|
||||||
await delay();
|
|
||||||
const index = mockExternalChannels.findIndex((c) => c.id === id);
|
|
||||||
if (index === -1) {
|
|
||||||
throw mockError("NOT_FOUND", "External channel not found");
|
|
||||||
}
|
|
||||||
mockExternalChannels.splice(index, 1);
|
|
||||||
return { success: true };
|
|
||||||
},
|
|
||||||
|
|
||||||
async updateExternalChannelLinks(
|
|
||||||
id: string,
|
|
||||||
data: {
|
|
||||||
add_target_channel_ids?: string[];
|
|
||||||
remove_target_channel_ids?: string[];
|
|
||||||
}
|
|
||||||
): Promise<ExternalChannel> {
|
|
||||||
await delay();
|
|
||||||
const channel = mockExternalChannels.find((c) => c.id === id);
|
|
||||||
if (!channel) {
|
|
||||||
throw mockError("NOT_FOUND", "External channel not found");
|
|
||||||
}
|
|
||||||
// В упрощенной версии просто возвращаем канал
|
|
||||||
return channel;
|
|
||||||
},
|
|
||||||
|
|
||||||
// --------------------------------------------------------------------------
|
|
||||||
// Creatives
|
|
||||||
// --------------------------------------------------------------------------
|
|
||||||
async getCreatives(params?: {
|
|
||||||
target_channel_id?: string;
|
|
||||||
include_archived?: boolean;
|
|
||||||
}) {
|
|
||||||
await delay();
|
|
||||||
let filtered = [...mockCreatives];
|
|
||||||
|
|
||||||
if (params?.target_channel_id) {
|
|
||||||
filtered = filtered.filter(
|
|
||||||
(c) => c.target_channel_id === params.target_channel_id
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!params?.include_archived) {
|
|
||||||
filtered = filtered.filter((c) => c.status !== "archived");
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
creatives: filtered,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
|
|
||||||
async getCreative(id: string): Promise<Creative> {
|
|
||||||
await delay();
|
|
||||||
const creative = mockCreatives.find((c) => c.id === id);
|
|
||||||
if (!creative) {
|
|
||||||
throw mockError("NOT_FOUND", "Creative not found");
|
|
||||||
}
|
|
||||||
return creative;
|
|
||||||
},
|
|
||||||
|
|
||||||
async createCreative(data: CreativeCreateRequest): Promise<Creative> {
|
|
||||||
await delay(500);
|
|
||||||
|
|
||||||
const targetChannel = mockTargetChannels.find(
|
|
||||||
(c) => c.id === data.target_channel_id
|
|
||||||
);
|
|
||||||
if (!targetChannel) {
|
|
||||||
throw mockError("NOT_FOUND", "Target channel not found");
|
|
||||||
}
|
|
||||||
|
|
||||||
const newCreative: Creative = {
|
|
||||||
id: generateId(),
|
|
||||||
name: data.name,
|
|
||||||
text: data.text,
|
|
||||||
target_channel_id: data.target_channel_id,
|
|
||||||
target_channel_title: targetChannel.title,
|
|
||||||
created_at: new Date().toISOString(),
|
|
||||||
status: "active",
|
|
||||||
placements_count: 0,
|
|
||||||
};
|
|
||||||
mockCreatives.push(newCreative);
|
|
||||||
return newCreative;
|
|
||||||
},
|
|
||||||
|
|
||||||
async updateCreative(
|
|
||||||
id: string,
|
|
||||||
data: CreativeUpdateRequest
|
|
||||||
): Promise<Creative> {
|
|
||||||
await delay();
|
|
||||||
const creative = mockCreatives.find((c) => c.id === id);
|
|
||||||
if (!creative) {
|
|
||||||
throw mockError("NOT_FOUND", "Creative not found");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (data.text && !data.text.includes("{link}")) {
|
|
||||||
throw mockError(
|
|
||||||
"VALIDATION_ERROR",
|
|
||||||
"Creative text must contain {link} placeholder"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
...creative,
|
|
||||||
...data,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
|
|
||||||
async deleteCreative(id: string): Promise<SuccessResponse> {
|
|
||||||
await delay();
|
|
||||||
const creative = mockCreatives.find((c) => c.id === id);
|
|
||||||
if (!creative) {
|
|
||||||
throw mockError("NOT_FOUND", "Creative not found");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (creative.placements_count > 0) {
|
|
||||||
throw mockError(
|
|
||||||
"CREATIVE_IN_USE",
|
|
||||||
"Cannot delete creative with active placements"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const index = mockCreatives.findIndex((c) => c.id === id);
|
|
||||||
mockCreatives.splice(index, 1);
|
|
||||||
return { success: true };
|
|
||||||
},
|
|
||||||
|
|
||||||
// --------------------------------------------------------------------------
|
|
||||||
// Placements (Purchases)
|
|
||||||
// --------------------------------------------------------------------------
|
|
||||||
async getPlacements(params?: {
|
|
||||||
target_channel_id?: string;
|
|
||||||
external_channel_id?: string;
|
|
||||||
creative_id?: string;
|
|
||||||
include_archived?: boolean;
|
|
||||||
}) {
|
|
||||||
await delay();
|
|
||||||
let filtered = [...mockPlacements];
|
|
||||||
|
|
||||||
if (params?.target_channel_id) {
|
|
||||||
filtered = filtered.filter(
|
|
||||||
(p) => p.target_channel_id === params.target_channel_id
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (params?.external_channel_id) {
|
|
||||||
filtered = filtered.filter(
|
|
||||||
(p) => p.external_channel_id === params.external_channel_id
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (params?.creative_id) {
|
|
||||||
filtered = filtered.filter((p) => p.creative_id === params.creative_id);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!params?.include_archived) {
|
|
||||||
filtered = filtered.filter((p) => p.status !== "archived");
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
placements: filtered,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
|
|
||||||
async getPlacement(id: string): Promise<Placement> {
|
|
||||||
await delay();
|
|
||||||
const placement = mockPlacements.find((p) => p.id === id);
|
|
||||||
if (!placement) {
|
|
||||||
throw mockError("NOT_FOUND", "Placement not found");
|
|
||||||
}
|
|
||||||
return placement;
|
|
||||||
},
|
|
||||||
|
|
||||||
async createPlacement(data: PlacementCreateRequest): Promise<Placement> {
|
|
||||||
await delay(800);
|
|
||||||
|
|
||||||
const targetChannel = mockTargetChannels.find(
|
|
||||||
(c) => c.id === data.target_channel_id
|
|
||||||
);
|
|
||||||
const externalChannel = mockExternalChannels.find(
|
|
||||||
(c) => c.id === data.external_channel_id
|
|
||||||
);
|
|
||||||
const creative = mockCreatives.find((c) => c.id === data.creative_id);
|
|
||||||
|
|
||||||
if (!targetChannel || !externalChannel || !creative) {
|
|
||||||
throw mockError("NOT_FOUND", "One of the required entities not found");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Generate mock invite link
|
|
||||||
const inviteLink = `https://t.me/+${Math.random()
|
|
||||||
.toString(36)
|
|
||||||
.substring(2, 15)}`;
|
|
||||||
|
|
||||||
const newPlacement: Placement = {
|
|
||||||
id: generateId(),
|
|
||||||
target_channel_id: data.target_channel_id,
|
|
||||||
target_channel_title: targetChannel.title,
|
|
||||||
external_channel_id: data.external_channel_id,
|
|
||||||
external_channel_title: externalChannel.title,
|
|
||||||
creative_id: data.creative_id,
|
|
||||||
creative_name: creative.name,
|
|
||||||
placement_date: data.placement_date,
|
|
||||||
cost: data.cost || null,
|
|
||||||
comment: data.comment || null,
|
|
||||||
ad_post_url: data.ad_post_url || null,
|
|
||||||
invite_link_type: data.invite_link_type || "public",
|
|
||||||
invite_link: inviteLink,
|
|
||||||
status: "active",
|
|
||||||
subscriptions_count: 0,
|
|
||||||
views_count: null,
|
|
||||||
views_availability: "unknown",
|
|
||||||
last_views_fetch_at: null,
|
|
||||||
created_at: new Date().toISOString(),
|
|
||||||
};
|
|
||||||
|
|
||||||
mockPlacements.push(newPlacement);
|
|
||||||
return newPlacement;
|
|
||||||
},
|
|
||||||
|
|
||||||
async updatePlacement(
|
|
||||||
id: string,
|
|
||||||
data: PlacementUpdateRequest
|
|
||||||
): Promise<Placement> {
|
|
||||||
await delay();
|
|
||||||
const placement = mockPlacements.find((p) => p.id === id);
|
|
||||||
if (!placement) {
|
|
||||||
throw mockError("NOT_FOUND", "Placement not found");
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
...placement,
|
|
||||||
...data,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
|
|
||||||
async deletePlacement(id: string): Promise<SuccessResponse> {
|
|
||||||
await delay();
|
|
||||||
const index = mockPlacements.findIndex((p) => p.id === id);
|
|
||||||
if (index === -1) {
|
|
||||||
throw mockError("NOT_FOUND", "Placement not found");
|
|
||||||
}
|
|
||||||
mockPlacements.splice(index, 1);
|
|
||||||
return { success: true };
|
|
||||||
},
|
|
||||||
|
|
||||||
async fetchPlacementViews(id: string): Promise<ViewsFetchResponse> {
|
|
||||||
await delay(1000);
|
|
||||||
const placement = mockPlacements.find((p) => p.id === id);
|
|
||||||
if (!placement) {
|
|
||||||
throw mockError("NOT_FOUND", "Placement not found");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Mock: add some random views
|
|
||||||
const newViews =
|
|
||||||
(placement.views_count || 0) + Math.floor(Math.random() * 500);
|
|
||||||
|
|
||||||
return {
|
|
||||||
placement_id: id,
|
|
||||||
views_count: newViews,
|
|
||||||
views_availability: "available",
|
|
||||||
fetched_at: new Date().toISOString(),
|
|
||||||
error_message: null,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
|
|
||||||
async getPlacementViewsHistory(
|
|
||||||
id: string,
|
|
||||||
params?: { from_date?: string; to_date?: string }
|
|
||||||
): Promise<ViewsHistoryResponse> {
|
|
||||||
await delay();
|
|
||||||
const placement = mockPlacements.find((p) => p.id === id);
|
|
||||||
if (!placement) {
|
|
||||||
throw mockError("NOT_FOUND", "Placement not found");
|
|
||||||
}
|
|
||||||
|
|
||||||
const filteredHistory = mockViewsHistory.filter(
|
|
||||||
(v) => v.purchase_id === id
|
|
||||||
);
|
|
||||||
return {
|
|
||||||
histories: filteredHistory,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
|
|
||||||
async setPlacementViewsManually(
|
|
||||||
id: string,
|
|
||||||
views_count: number
|
|
||||||
): Promise<Placement> {
|
|
||||||
await delay();
|
|
||||||
const placement = mockPlacements.find((p) => p.id === id);
|
|
||||||
if (!placement) {
|
|
||||||
throw mockError("NOT_FOUND", "Placement not found");
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
...placement,
|
|
||||||
views_count,
|
|
||||||
views_availability: "manual",
|
|
||||||
last_views_fetch_at: new Date().toISOString(),
|
|
||||||
};
|
|
||||||
},
|
|
||||||
|
|
||||||
// --------------------------------------------------------------------------
|
|
||||||
// Analytics
|
|
||||||
// --------------------------------------------------------------------------
|
|
||||||
async getSpendingAnalytics(params?: {
|
|
||||||
target_channel_id?: string;
|
|
||||||
date_from?: string;
|
|
||||||
date_to?: string;
|
|
||||||
grouping?: "day" | "week" | "month" | "quarter" | "year";
|
|
||||||
}) {
|
|
||||||
await delay();
|
|
||||||
return {
|
|
||||||
total_cost: 45000,
|
|
||||||
total_subscriptions: 850,
|
|
||||||
total_views: 125000,
|
|
||||||
avg_cpf: 52.94,
|
|
||||||
avg_cpm: 360,
|
|
||||||
chart_data: [
|
|
||||||
{
|
|
||||||
period: "2024-03-01",
|
|
||||||
cost: 15000,
|
|
||||||
subscriptions: 280,
|
|
||||||
views: 42000,
|
|
||||||
cpf: 53.57,
|
|
||||||
cpm: 357.14,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
period: "2024-03-08",
|
|
||||||
cost: 18000,
|
|
||||||
subscriptions: 340,
|
|
||||||
views: 51000,
|
|
||||||
cpf: 52.94,
|
|
||||||
cpm: 352.94,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
period: "2024-03-15",
|
|
||||||
cost: 12000,
|
|
||||||
subscriptions: 230,
|
|
||||||
views: 32000,
|
|
||||||
cpf: 52.17,
|
|
||||||
cpm: 375,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
},
|
|
||||||
|
|
||||||
async getPlacementsAnalytics(params?: { target_channel_id?: string }) {
|
|
||||||
await delay();
|
|
||||||
return {
|
|
||||||
placements: mockPlacements.map((p) => ({
|
|
||||||
id: p.id,
|
|
||||||
target_channel_id: p.target_channel_id,
|
|
||||||
target_channel_title: p.target_channel_title,
|
|
||||||
external_channel_id: p.external_channel_id,
|
|
||||||
external_channel_title: p.external_channel_title,
|
|
||||||
creative_id: p.creative_id,
|
|
||||||
creative_name: p.creative_name,
|
|
||||||
placement_date: p.placement_date,
|
|
||||||
cost: p.cost,
|
|
||||||
subscriptions_count: p.subscriptions_count,
|
|
||||||
views_count: p.views_count,
|
|
||||||
cpf: p.cost && p.subscriptions_count > 0 ? p.cost / p.subscriptions_count : null,
|
|
||||||
cpm: p.cost && p.views_count ? (p.cost / p.views_count) * 1000 : null,
|
|
||||||
})),
|
|
||||||
};
|
|
||||||
},
|
|
||||||
|
|
||||||
async getCreativesAnalytics(params?: { target_channel_id?: string }) {
|
|
||||||
await delay();
|
|
||||||
return {
|
|
||||||
creatives: mockCreatives.map((c) => ({
|
|
||||||
id: c.id,
|
|
||||||
name: c.name,
|
|
||||||
placements_count: c.placements_count,
|
|
||||||
total_cost: 15000,
|
|
||||||
total_subscriptions: 280,
|
|
||||||
total_views: 42000,
|
|
||||||
avg_cpf: 53.57,
|
|
||||||
avg_cpm: 357.14,
|
|
||||||
})),
|
|
||||||
};
|
|
||||||
},
|
|
||||||
|
|
||||||
async getExternalChannelsAnalytics(params?: { target_channel_id?: string }) {
|
|
||||||
await delay();
|
|
||||||
return {
|
|
||||||
external_channels: mockExternalChannels.map((ec) => ({
|
|
||||||
id: ec.id,
|
|
||||||
title: ec.title,
|
|
||||||
username: ec.username,
|
|
||||||
placements_count: 3,
|
|
||||||
total_cost: 12000,
|
|
||||||
total_subscriptions: 210,
|
|
||||||
total_views: 28000,
|
|
||||||
avg_cpf: 57.14,
|
|
||||||
avg_cpm: 428.57,
|
|
||||||
})),
|
|
||||||
};
|
|
||||||
},
|
|
||||||
};
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
// ============================================================================
|
|
||||||
// Mock Data Export
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
export * from "./data/users";
|
|
||||||
export * from "./data/channels";
|
|
||||||
export * from "./data/external-channels";
|
|
||||||
export * from "./data/creatives";
|
|
||||||
export * from "./data/placements";
|
|
||||||
484
lib/types/api.ts
484
lib/types/api.ts
@@ -1,5 +1,5 @@
|
|||||||
// ============================================================================
|
// ============================================================================
|
||||||
// API Types - Generated from API_DOCUMENTATION.md
|
// API Types - Based on actual API responses
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
// ----------------------------------------------------------------------------
|
// ----------------------------------------------------------------------------
|
||||||
@@ -12,148 +12,220 @@ export interface User {
|
|||||||
username: string | null;
|
username: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AuthInitResponse {
|
|
||||||
bot_username: string;
|
|
||||||
start_param: string; // "login"
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface AuthCompleteRequest {
|
|
||||||
token: string; // one-time token from bot
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface AuthCompleteResponse {
|
export interface AuthCompleteResponse {
|
||||||
access_token: string;
|
access_token: string;
|
||||||
refresh_token: string;
|
|
||||||
user: User;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface AuthRefreshRequest {
|
|
||||||
refresh_token: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface AuthRefreshResponse {
|
|
||||||
access_token: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ----------------------------------------------------------------------------
|
// ----------------------------------------------------------------------------
|
||||||
// Target Channel
|
// Pagination
|
||||||
// ----------------------------------------------------------------------------
|
// ----------------------------------------------------------------------------
|
||||||
|
|
||||||
// Основная структура как возвращает API
|
export interface PaginatedResponse<T> {
|
||||||
export interface TargetChannel {
|
items: T[];
|
||||||
|
total: number;
|
||||||
|
page: number;
|
||||||
|
size: number;
|
||||||
|
pages: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PaginationParams {
|
||||||
|
page?: number;
|
||||||
|
size?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
// Workspaces
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export interface Workspace {
|
||||||
|
id: string; // UUID
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WorkspaceCreateRequest {
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WorkspaceUpdateRequest {
|
||||||
|
name?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
// Workspace Members & Permissions
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export type WorkspaceUserStatus = "active" | "invited";
|
||||||
|
|
||||||
|
export type PermissionKey =
|
||||||
|
| "admin_full"
|
||||||
|
| "projects_read"
|
||||||
|
| "projects_write"
|
||||||
|
| "placements_read"
|
||||||
|
| "placements_write"
|
||||||
|
| "analytics_read";
|
||||||
|
|
||||||
|
export type PermissionScopeType = "project";
|
||||||
|
|
||||||
|
export interface PermissionScope {
|
||||||
|
type: PermissionScopeType;
|
||||||
|
id: string; // UUID
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Permission {
|
||||||
|
key: PermissionKey;
|
||||||
|
scopes?: PermissionScope[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WorkspaceMemberUser {
|
||||||
|
id: string; // user_id
|
||||||
|
telegram_id: number;
|
||||||
|
username: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WorkspaceMember {
|
||||||
|
id: string; // member record id
|
||||||
|
status: WorkspaceUserStatus;
|
||||||
|
user: WorkspaceMemberUser;
|
||||||
|
permissions: Permission[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdatePermissionsRequest {
|
||||||
|
permissions: Permission[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
// Workspace Invites
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export type WorkspaceInviteStatus = "pending" | "accepted" | "rejected";
|
||||||
|
|
||||||
|
export interface WorkspaceInvite {
|
||||||
|
id: string; // UUID
|
||||||
|
username: string;
|
||||||
|
status: WorkspaceInviteStatus;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WorkspaceInviteCreateRequest {
|
||||||
|
username: string; // без @
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
// Projects (Target Channels)
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export type ProjectStatus = "active" | "inactive" | "archived";
|
||||||
|
|
||||||
|
export interface Project {
|
||||||
id: string; // UUID
|
id: string; // UUID
|
||||||
telegram_id: number;
|
telegram_id: number;
|
||||||
title: string;
|
title: string;
|
||||||
username: string | null;
|
username: string | null;
|
||||||
is_active: boolean;
|
status: ProjectStatus;
|
||||||
}
|
|
||||||
|
|
||||||
// Расширенная версия с дополнительными полями (пока не реализовано на бекенде)
|
|
||||||
export interface TargetChannelDetail extends TargetChannel {
|
|
||||||
// В будущем можно добавить статистику
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface TargetChannelUpdateRequest {
|
|
||||||
is_active?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface TargetChannelsListResponse {
|
|
||||||
target_channels: TargetChannel[];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ----------------------------------------------------------------------------
|
// ----------------------------------------------------------------------------
|
||||||
// External Channel
|
// Channels (Catalog for Placements)
|
||||||
// ----------------------------------------------------------------------------
|
// ----------------------------------------------------------------------------
|
||||||
|
|
||||||
// Основная структура как возвращает API
|
export type ChannelStatus = "active" | "inactive" | "archived";
|
||||||
export interface ExternalChannel {
|
|
||||||
|
export interface Channel {
|
||||||
id: string; // UUID
|
id: string; // UUID
|
||||||
telegram_id: number;
|
telegram_id: number;
|
||||||
title: string;
|
title: string;
|
||||||
username: string | null;
|
username: string | null;
|
||||||
description: string | null;
|
|
||||||
subscribers_count: number | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ExternalChannelCreateRequest {
|
|
||||||
telegram_id: number;
|
|
||||||
title: string;
|
|
||||||
username?: string | null;
|
|
||||||
description?: string | null;
|
description?: string | null;
|
||||||
subscribers_count?: number | null;
|
subscribers_count?: number;
|
||||||
target_channel_ids: string[]; // UUID[]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ExternalChannelUpdateRequest {
|
export interface ChannelsQueryParams extends PaginationParams {
|
||||||
title?: string;
|
username?: string;
|
||||||
username?: string | null;
|
|
||||||
description?: string | null;
|
|
||||||
subscribers_count?: number | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ExternalChannelLinksUpdateRequest {
|
|
||||||
add_target_channel_ids?: string[];
|
|
||||||
remove_target_channel_ids?: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ExternalChannelsListResponse {
|
|
||||||
external_channels: ExternalChannel[];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ----------------------------------------------------------------------------
|
// ----------------------------------------------------------------------------
|
||||||
// Creative
|
// Purchase Plan
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export type PurchasePlanStatus = "planned" | "completed" | "cancelled";
|
||||||
|
|
||||||
|
export interface PurchasePlanChannel {
|
||||||
|
id: string; // UUID
|
||||||
|
status: PurchasePlanStatus;
|
||||||
|
planned_cost: number | null;
|
||||||
|
comment: string | null;
|
||||||
|
channel: {
|
||||||
|
id: string;
|
||||||
|
telegram_id: number;
|
||||||
|
title: string;
|
||||||
|
username: string | null;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PurchasePlanChannelCreateRequest {
|
||||||
|
channel_id: string;
|
||||||
|
planned_cost?: number;
|
||||||
|
comment?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PurchasePlanChannelUpdateRequest {
|
||||||
|
planned_cost?: number;
|
||||||
|
comment?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
// Creatives
|
||||||
// ----------------------------------------------------------------------------
|
// ----------------------------------------------------------------------------
|
||||||
|
|
||||||
export type CreativeStatus = "active" | "archived";
|
export type CreativeStatus = "active" | "archived";
|
||||||
|
|
||||||
export interface Creative {
|
export interface Creative {
|
||||||
id: string;
|
id: string; // UUID
|
||||||
name: string;
|
name: string;
|
||||||
text: string;
|
text: string; // Contains {invite_link} placeholder
|
||||||
target_channel_id: string;
|
project_id: string;
|
||||||
target_channel_title: string;
|
project_channel_title: string;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
status: CreativeStatus;
|
status: CreativeStatus;
|
||||||
placements_count: number;
|
placements_count: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CreativesListResponse {
|
|
||||||
creatives: Creative[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CreativeCreateRequest {
|
export interface CreativeCreateRequest {
|
||||||
name: string;
|
name: string;
|
||||||
text: string;
|
text: string;
|
||||||
target_channel_id: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CreativeUpdateRequest {
|
export interface CreativeUpdateRequest {
|
||||||
name?: string;
|
name?: string;
|
||||||
text?: string;
|
text?: string;
|
||||||
status?: CreativeStatus;
|
}
|
||||||
|
|
||||||
|
export interface CreativesQueryParams extends PaginationParams {
|
||||||
|
project_id?: string;
|
||||||
|
include_archived?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ----------------------------------------------------------------------------
|
// ----------------------------------------------------------------------------
|
||||||
// Placement (Purchase)
|
// Placements
|
||||||
// ----------------------------------------------------------------------------
|
// ----------------------------------------------------------------------------
|
||||||
|
|
||||||
export type InviteLinkType = "public" | "approval";
|
|
||||||
export type PlacementStatus = "active" | "archived";
|
export type PlacementStatus = "active" | "archived";
|
||||||
export type ViewsAvailability =
|
export type InviteLinkType = "public" | "approval";
|
||||||
|
export type PostViewsAvailability =
|
||||||
| "unknown"
|
| "unknown"
|
||||||
| "available"
|
| "available"
|
||||||
| "unavailable"
|
| "unavailable"
|
||||||
| "manual";
|
| "manual";
|
||||||
|
|
||||||
export interface Placement {
|
export interface Placement {
|
||||||
id: string;
|
id: string; // UUID
|
||||||
target_channel_id: string;
|
project_id: string;
|
||||||
target_channel_title: string;
|
project_channel_title: string;
|
||||||
external_channel_id: string;
|
placement_channel_id: string;
|
||||||
external_channel_title: string;
|
placement_channel_title: string;
|
||||||
creative_id: string;
|
creative_id: string;
|
||||||
creative_name: string;
|
creative_name: string;
|
||||||
placement_date: string;
|
placement_date: string; // ISO 8601
|
||||||
cost: number | null;
|
cost: number | null;
|
||||||
comment: string | null;
|
comment: string | null;
|
||||||
ad_post_url: string | null;
|
ad_post_url: string | null;
|
||||||
@@ -161,84 +233,96 @@ export interface Placement {
|
|||||||
invite_link: string;
|
invite_link: string;
|
||||||
status: PlacementStatus;
|
status: PlacementStatus;
|
||||||
subscriptions_count: number;
|
subscriptions_count: number;
|
||||||
views_count: number | null;
|
views_count?: number | null;
|
||||||
views_availability: ViewsAvailability;
|
views_availability?: PostViewsAvailability;
|
||||||
last_views_fetch_at: string | null;
|
last_views_fetch_at?: string | null;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PlacementsListResponse {
|
|
||||||
placements: Placement[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface PlacementCreateRequest {
|
export interface PlacementCreateRequest {
|
||||||
target_channel_id: string;
|
project_id: string;
|
||||||
external_channel_id: string;
|
placement_channel_id: string;
|
||||||
creative_id: string;
|
creative_id: string;
|
||||||
placement_date: string;
|
placement_date: string; // ISO 8601
|
||||||
cost?: number | null;
|
cost?: number;
|
||||||
comment?: string | null;
|
comment?: string;
|
||||||
ad_post_url?: string | null;
|
ad_post_url?: string;
|
||||||
invite_link_type?: InviteLinkType;
|
invite_link_type: InviteLinkType;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PlacementUpdateRequest {
|
export interface PlacementUpdateRequest {
|
||||||
placement_date?: string;
|
placement_date?: string;
|
||||||
cost?: number | null;
|
cost?: number;
|
||||||
comment?: string | null;
|
comment?: string;
|
||||||
ad_post_url?: string | null;
|
ad_post_url?: string;
|
||||||
status?: PlacementStatus;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Legacy aliases for backwards compatibility
|
export interface PlacementsQueryParams extends PaginationParams {
|
||||||
export type Purchase = Placement;
|
project_id?: string;
|
||||||
export type PurchaseCreateRequest = PlacementCreateRequest;
|
placement_channel_id?: string;
|
||||||
export type PurchaseUpdateRequest = PlacementUpdateRequest;
|
creative_id?: string;
|
||||||
|
include_archived?: boolean;
|
||||||
export interface ViewsFetchResponse {
|
|
||||||
placement_id: string;
|
|
||||||
views_count: number | null;
|
|
||||||
views_availability: ViewsAvailability;
|
|
||||||
fetched_at: string;
|
|
||||||
error_message: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ViewsHistoryResponse {
|
|
||||||
histories: ViewsSnapshot[];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ----------------------------------------------------------------------------
|
// ----------------------------------------------------------------------------
|
||||||
// Subscription
|
// Views History
|
||||||
// ----------------------------------------------------------------------------
|
// ----------------------------------------------------------------------------
|
||||||
|
|
||||||
export interface Subscription {
|
export interface ViewsHistoryItem {
|
||||||
id: string;
|
id: string; // UUID
|
||||||
purchase_id: string;
|
views_count: number;
|
||||||
telegram_user_id: number;
|
|
||||||
user_first_name: string;
|
|
||||||
user_last_name: string | null;
|
|
||||||
user_username: string | null;
|
|
||||||
subscribed_at: string; // ISO 8601
|
|
||||||
is_active: boolean;
|
|
||||||
event_type: "chat_member" | "join_request";
|
|
||||||
}
|
|
||||||
|
|
||||||
// ----------------------------------------------------------------------------
|
|
||||||
// Views Snapshot
|
|
||||||
// ----------------------------------------------------------------------------
|
|
||||||
|
|
||||||
export interface ViewsSnapshot {
|
|
||||||
id: string;
|
|
||||||
purchase_id: string;
|
|
||||||
views: number;
|
|
||||||
fetched_at: string; // ISO 8601
|
fetched_at: string; // ISO 8601
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ViewsHistoryQueryParams extends PaginationParams {
|
||||||
|
from_date?: string;
|
||||||
|
to_date?: string;
|
||||||
|
}
|
||||||
|
|
||||||
// ----------------------------------------------------------------------------
|
// ----------------------------------------------------------------------------
|
||||||
// Analytics
|
// Analytics
|
||||||
// ----------------------------------------------------------------------------
|
// ----------------------------------------------------------------------------
|
||||||
|
|
||||||
export type DateGrouping = "day" | "week" | "month" | "quarter" | "year";
|
export type DateGrouping = "day" | "week" | "month" | "DAY" | "WEEK" | "MONTH";
|
||||||
|
|
||||||
|
// Placements Analytics
|
||||||
|
export interface PlacementAnalyticsItem {
|
||||||
|
placement_id: string;
|
||||||
|
placement_channel_title: string;
|
||||||
|
placement_channel_username: string | null;
|
||||||
|
creative_name: string;
|
||||||
|
placement_date: string;
|
||||||
|
cost: number | null;
|
||||||
|
subscriptions_count: number;
|
||||||
|
views_count: number | null;
|
||||||
|
cps: number | null; // cost per subscription
|
||||||
|
cpm: number | null; // cost per mille views
|
||||||
|
}
|
||||||
|
|
||||||
|
// Creatives Analytics (matches actual API response)
|
||||||
|
export interface CreativeAnalyticsItem {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
placements_count: number;
|
||||||
|
total_cost: number;
|
||||||
|
total_subscriptions: number;
|
||||||
|
total_views: number;
|
||||||
|
avg_cpf: number | null; // cost per follower (same as CPS)
|
||||||
|
avg_cpm: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Channels Analytics
|
||||||
|
export interface ChannelAnalyticsItem {
|
||||||
|
channel_id: string;
|
||||||
|
channel_title: string;
|
||||||
|
channel_username: string | null;
|
||||||
|
placements_count: number;
|
||||||
|
total_cost: number;
|
||||||
|
total_subscriptions: number;
|
||||||
|
total_views: number;
|
||||||
|
avg_cps: number | null;
|
||||||
|
avg_cpm: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
// Spending Analytics
|
// Spending Analytics
|
||||||
export interface SpendingDataPoint {
|
export interface SpendingDataPoint {
|
||||||
@@ -251,40 +335,7 @@ export interface SpendingDataPoint {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface SpendingAnalytics {
|
export interface SpendingAnalytics {
|
||||||
total_cost: number;
|
|
||||||
total_subscriptions: number;
|
|
||||||
total_views: number;
|
|
||||||
avg_cpf: number | null;
|
|
||||||
avg_cpm: number | null;
|
|
||||||
chart_data: SpendingDataPoint[];
|
chart_data: SpendingDataPoint[];
|
||||||
}
|
|
||||||
|
|
||||||
// Placements Analytics
|
|
||||||
export interface PlacementAnalytics {
|
|
||||||
id: string;
|
|
||||||
target_channel_id: string;
|
|
||||||
target_channel_title: string;
|
|
||||||
external_channel_id: string;
|
|
||||||
external_channel_title: string;
|
|
||||||
creative_id: string;
|
|
||||||
creative_name: string;
|
|
||||||
placement_date: string;
|
|
||||||
cost: number | null;
|
|
||||||
subscriptions_count: number;
|
|
||||||
views_count: number | null;
|
|
||||||
cpf: number | null;
|
|
||||||
cpm: number | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface PlacementsAnalytics {
|
|
||||||
placements: PlacementAnalytics[];
|
|
||||||
}
|
|
||||||
|
|
||||||
// Creatives Analytics
|
|
||||||
export interface CreativeAnalytics {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
placements_count: number;
|
|
||||||
total_cost: number;
|
total_cost: number;
|
||||||
total_subscriptions: number;
|
total_subscriptions: number;
|
||||||
total_views: number;
|
total_views: number;
|
||||||
@@ -292,99 +343,34 @@ export interface CreativeAnalytics {
|
|||||||
avg_cpm: number | null;
|
avg_cpm: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CreativesAnalytics {
|
export interface AnalyticsQueryParams extends PaginationParams {
|
||||||
creatives: CreativeAnalytics[];
|
project_id?: string;
|
||||||
}
|
|
||||||
|
|
||||||
// External Channels Analytics
|
|
||||||
export interface ExternalChannelAnalytics {
|
|
||||||
id: string;
|
|
||||||
title: string;
|
|
||||||
username: string | null;
|
|
||||||
placements_count: number;
|
|
||||||
total_cost: number;
|
|
||||||
total_subscriptions: number;
|
|
||||||
total_views: number;
|
|
||||||
avg_cpf: number | null;
|
|
||||||
avg_cpm: number | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ExternalChannelsAnalytics {
|
|
||||||
external_channels: ExternalChannelAnalytics[];
|
|
||||||
}
|
|
||||||
|
|
||||||
// Analytics Configuration Types
|
|
||||||
export type AnalyticsMetric = "cost" | "subscriptions" | "views" | "cpf" | "cpm";
|
|
||||||
|
|
||||||
export interface ChartConfig {
|
|
||||||
id: string;
|
|
||||||
targetChannelIds: string[]; // Multiple selection
|
|
||||||
metrics: AnalyticsMetric[];
|
|
||||||
dateFrom: Date | null;
|
|
||||||
dateTo: Date | null;
|
|
||||||
grouping: DateGrouping;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SpendingAnalyticsQueryParams {
|
export interface SpendingAnalyticsQueryParams {
|
||||||
target_channel_id?: string;
|
project_id?: string;
|
||||||
date_from?: string;
|
date_from?: string;
|
||||||
date_to?: string;
|
date_to?: string;
|
||||||
grouping?: DateGrouping;
|
grouping?: DateGrouping;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ----------------------------------------------------------------------------
|
// ----------------------------------------------------------------------------
|
||||||
// Common API Responses
|
// Common Types
|
||||||
// ----------------------------------------------------------------------------
|
// ----------------------------------------------------------------------------
|
||||||
|
|
||||||
export interface ListResponse<T> {
|
|
||||||
data: T[];
|
|
||||||
total: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SuccessResponse {
|
|
||||||
success: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ApiError {
|
export interface ApiError {
|
||||||
error: {
|
detail: string;
|
||||||
code: string;
|
|
||||||
message: string;
|
|
||||||
details?: any;
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ----------------------------------------------------------------------------
|
// ----------------------------------------------------------------------------
|
||||||
// Query Parameters
|
// Legacy Aliases (for backwards compatibility during migration)
|
||||||
// ----------------------------------------------------------------------------
|
// ----------------------------------------------------------------------------
|
||||||
|
|
||||||
export interface TargetChannelQueryParams {
|
/** @deprecated Use Project instead */
|
||||||
is_active?: boolean;
|
export type TargetChannel = Project;
|
||||||
}
|
|
||||||
|
|
||||||
export interface ExternalChannelQueryParams {
|
/** @deprecated Use Channel instead */
|
||||||
target_channel_id?: string;
|
export type ExternalChannel = Channel;
|
||||||
search?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CreativeQueryParams {
|
|
||||||
target_channel_id?: string;
|
|
||||||
is_archived?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface PlacementQueryParams {
|
|
||||||
target_channel_id?: string;
|
|
||||||
external_channel_id?: string;
|
|
||||||
creative_id?: string;
|
|
||||||
is_archived?: boolean;
|
|
||||||
date_from?: string; // ISO 8601
|
|
||||||
date_to?: string; // ISO 8601
|
|
||||||
sort?: "date" | "cost" | "cpf" | "subscriptions";
|
|
||||||
order?: "asc" | "desc";
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface AnalyticsOverviewQueryParams {
|
|
||||||
target_channel_id?: string;
|
|
||||||
date_from?: string;
|
|
||||||
date_to?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
/** @deprecated Use PlacementCreateRequest with new fields */
|
||||||
|
export type PurchaseCreateRequest = PlacementCreateRequest;
|
||||||
|
|||||||
@@ -14,40 +14,8 @@ export const STORAGE_KEYS = {
|
|||||||
ACCESS_TOKEN: "tgex_access_token",
|
ACCESS_TOKEN: "tgex_access_token",
|
||||||
REFRESH_TOKEN: "tgex_refresh_token",
|
REFRESH_TOKEN: "tgex_refresh_token",
|
||||||
USER: "tgex_user",
|
USER: "tgex_user",
|
||||||
SELECTED_TARGET_CHANNEL: "tgex_selected_target_channel",
|
SELECTED_WORKSPACE: "tgex_selected_workspace",
|
||||||
} as const;
|
SELECTED_PROJECT: "tgex_selected_project",
|
||||||
|
|
||||||
// API Endpoints
|
|
||||||
export const API_ENDPOINTS = {
|
|
||||||
// Auth
|
|
||||||
AUTH_INIT: "/auth/init",
|
|
||||||
AUTH_COMPLETE: "/auth/complete",
|
|
||||||
AUTH_REFRESH: "/auth/refresh",
|
|
||||||
AUTH_ME: "/auth/me",
|
|
||||||
|
|
||||||
// Target Channels
|
|
||||||
TARGET_CHANNELS: "/target-channels",
|
|
||||||
TARGET_CHANNEL: (id: string) => `/target-channels/${id}`,
|
|
||||||
|
|
||||||
// External Channels
|
|
||||||
EXTERNAL_CHANNELS: "/external-channels",
|
|
||||||
EXTERNAL_CHANNEL: (id: string) => `/external-channels/${id}`,
|
|
||||||
EXTERNAL_CHANNELS_IMPORT: "/external-channels/import",
|
|
||||||
|
|
||||||
// Creatives
|
|
||||||
CREATIVES: "/creatives",
|
|
||||||
CREATIVE: (id: string) => `/creatives/${id}`,
|
|
||||||
|
|
||||||
// Purchases
|
|
||||||
PURCHASES: "/purchases",
|
|
||||||
PURCHASE: (id: string) => `/purchases/${id}`,
|
|
||||||
PURCHASE_REFRESH_VIEWS: (id: string) => `/purchases/${id}/refresh-views`,
|
|
||||||
|
|
||||||
// Analytics
|
|
||||||
ANALYTICS_OVERVIEW: "/analytics/overview",
|
|
||||||
ANALYTICS_COSTS: "/analytics/costs",
|
|
||||||
ANALYTICS_CHANNELS: "/analytics/channels",
|
|
||||||
ANALYTICS_CREATIVES: "/analytics/creatives",
|
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
// Error Codes
|
// Error Codes
|
||||||
@@ -57,11 +25,6 @@ export const ERROR_CODES = {
|
|||||||
NOT_FOUND: "NOT_FOUND",
|
NOT_FOUND: "NOT_FOUND",
|
||||||
VALIDATION_ERROR: "VALIDATION_ERROR",
|
VALIDATION_ERROR: "VALIDATION_ERROR",
|
||||||
INTERNAL_ERROR: "INTERNAL_ERROR",
|
INTERNAL_ERROR: "INTERNAL_ERROR",
|
||||||
CHANNEL_NOT_FOUND: "CHANNEL_NOT_FOUND",
|
|
||||||
CHANNEL_ALREADY_EXISTS: "CHANNEL_ALREADY_EXISTS",
|
|
||||||
INSUFFICIENT_PERMISSIONS: "INSUFFICIENT_PERMISSIONS",
|
|
||||||
CREATIVE_IN_USE: "CREATIVE_IN_USE",
|
|
||||||
INVALID_INVITE_LINK: "INVALID_INVITE_LINK",
|
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
// Date Formats
|
// Date Formats
|
||||||
@@ -72,34 +35,57 @@ export const DATE_FORMATS = {
|
|||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
// Pagination
|
// Pagination
|
||||||
export const DEFAULT_PAGE_SIZE = 20;
|
export const DEFAULT_PAGE_SIZE = 50;
|
||||||
export const PAGE_SIZE_OPTIONS = [10, 20, 50, 100];
|
export const PAGE_SIZE_OPTIONS = [10, 20, 50, 100];
|
||||||
|
|
||||||
// Routes
|
// Routes Builder
|
||||||
|
export const buildRoute = {
|
||||||
|
// Dashboard
|
||||||
|
dashboard: (workspaceId: string) => `/dashboard/${workspaceId}`,
|
||||||
|
|
||||||
|
// Projects
|
||||||
|
projects: (workspaceId: string) => `/dashboard/${workspaceId}/projects`,
|
||||||
|
|
||||||
|
// Channels
|
||||||
|
channels: (workspaceId: string) => `/dashboard/${workspaceId}/channels`,
|
||||||
|
|
||||||
|
// Purchase Plan
|
||||||
|
purchasePlans: (workspaceId: string) => `/dashboard/${workspaceId}/purchase-plans`,
|
||||||
|
purchasePlan: (workspaceId: string, projectId: string) =>
|
||||||
|
`/dashboard/${workspaceId}/purchase-plans/${projectId}`,
|
||||||
|
|
||||||
|
// Creatives
|
||||||
|
creatives: (workspaceId: string) => `/dashboard/${workspaceId}/creatives`,
|
||||||
|
creativeNew: (workspaceId: string) => `/dashboard/${workspaceId}/creatives/new`,
|
||||||
|
creativeDetail: (workspaceId: string, creativeId: string) =>
|
||||||
|
`/dashboard/${workspaceId}/creatives/${creativeId}`,
|
||||||
|
|
||||||
|
// Placements
|
||||||
|
placements: (workspaceId: string) => `/dashboard/${workspaceId}/placements`,
|
||||||
|
placementNew: (workspaceId: string) => `/dashboard/${workspaceId}/placements/new`,
|
||||||
|
placementDetail: (workspaceId: string, placementId: string) =>
|
||||||
|
`/dashboard/${workspaceId}/placements/${placementId}`,
|
||||||
|
|
||||||
|
// Analytics
|
||||||
|
analytics: (workspaceId: string) => `/dashboard/${workspaceId}/analytics`,
|
||||||
|
analyticsSpending: (workspaceId: string) =>
|
||||||
|
`/dashboard/${workspaceId}/analytics/spending`,
|
||||||
|
analyticsCreatives: (workspaceId: string) =>
|
||||||
|
`/dashboard/${workspaceId}/analytics/creatives`,
|
||||||
|
analyticsChannels: (workspaceId: string) =>
|
||||||
|
`/dashboard/${workspaceId}/analytics/channels`,
|
||||||
|
analyticsPlacements: (workspaceId: string) =>
|
||||||
|
`/dashboard/${workspaceId}/analytics/placements`,
|
||||||
|
|
||||||
|
// Settings
|
||||||
|
settings: (workspaceId: string) => `/dashboard/${workspaceId}/settings`,
|
||||||
|
members: (workspaceId: string) => `/dashboard/${workspaceId}/settings/members`,
|
||||||
|
invites: (workspaceId: string) => `/dashboard/${workspaceId}/settings/invites`,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
// Static routes
|
||||||
export const ROUTES = {
|
export const ROUTES = {
|
||||||
HOME: "/",
|
HOME: "/",
|
||||||
LOGIN: "/login",
|
LOGIN: "/login",
|
||||||
AUTH_COMPLETE: "/auth/complete",
|
AUTH_COMPLETE: "/auth/complete",
|
||||||
|
|
||||||
CHANNELS: "/channels",
|
|
||||||
CHANNEL_DETAIL: (id: string) => `/channels/${id}`,
|
|
||||||
|
|
||||||
EXTERNAL_CHANNELS: "/external-channels",
|
|
||||||
EXTERNAL_CHANNEL_DETAIL: (id: string) => `/external-channels/${id}`,
|
|
||||||
EXTERNAL_CHANNELS_IMPORT: "/external-channels/import",
|
|
||||||
|
|
||||||
CREATIVES: "/creatives",
|
|
||||||
CREATIVE_NEW: "/creatives/new",
|
|
||||||
CREATIVE_DETAIL: (id: string) => `/creatives/${id}`,
|
|
||||||
CREATIVE_EDIT: (id: string) => `/creatives/${id}/edit`,
|
|
||||||
|
|
||||||
PURCHASES: "/purchases",
|
|
||||||
PURCHASE_NEW: "/purchases/new",
|
|
||||||
PURCHASE_DETAIL: (id: string) => `/purchases/${id}`,
|
|
||||||
PURCHASE_EDIT: (id: string) => `/purchases/${id}/edit`,
|
|
||||||
|
|
||||||
ANALYTICS: "/analytics",
|
|
||||||
ANALYTICS_COSTS: "/analytics/costs",
|
|
||||||
ANALYTICS_CHANNELS: "/analytics/channels",
|
|
||||||
ANALYTICS_CREATIVES: "/analytics/creatives",
|
|
||||||
} as const;
|
} as const;
|
||||||
|
|||||||
@@ -14,6 +14,7 @@
|
|||||||
"@dnd-kit/sortable": "^10.0.0",
|
"@dnd-kit/sortable": "^10.0.0",
|
||||||
"@dnd-kit/utilities": "^3.2.2",
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
"@hookform/resolvers": "^5.2.2",
|
"@hookform/resolvers": "^5.2.2",
|
||||||
|
"@radix-ui/react-alert-dialog": "^1.1.15",
|
||||||
"@radix-ui/react-avatar": "^1.1.11",
|
"@radix-ui/react-avatar": "^1.1.11",
|
||||||
"@radix-ui/react-checkbox": "^1.3.3",
|
"@radix-ui/react-checkbox": "^1.3.3",
|
||||||
"@radix-ui/react-collapsible": "^1.1.12",
|
"@radix-ui/react-collapsible": "^1.1.12",
|
||||||
@@ -35,6 +36,7 @@
|
|||||||
"@tanstack/react-table": "^8.21.3",
|
"@tanstack/react-table": "^8.21.3",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
|
"cmdk": "^1.1.1",
|
||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
"lucide-react": "^0.553.0",
|
"lucide-react": "^0.553.0",
|
||||||
"next": "16.0.1",
|
"next": "16.0.1",
|
||||||
|
|||||||
51
pnpm-lock.yaml
generated
51
pnpm-lock.yaml
generated
@@ -23,6 +23,9 @@ importers:
|
|||||||
'@hookform/resolvers':
|
'@hookform/resolvers':
|
||||||
specifier: ^5.2.2
|
specifier: ^5.2.2
|
||||||
version: 5.2.2(react-hook-form@7.66.0(react@19.2.0))
|
version: 5.2.2(react-hook-form@7.66.0(react@19.2.0))
|
||||||
|
'@radix-ui/react-alert-dialog':
|
||||||
|
specifier: ^1.1.15
|
||||||
|
version: 1.1.15(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
|
||||||
'@radix-ui/react-avatar':
|
'@radix-ui/react-avatar':
|
||||||
specifier: ^1.1.11
|
specifier: ^1.1.11
|
||||||
version: 1.1.11(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
|
version: 1.1.11(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
|
||||||
@@ -86,6 +89,9 @@ importers:
|
|||||||
clsx:
|
clsx:
|
||||||
specifier: ^2.1.1
|
specifier: ^2.1.1
|
||||||
version: 2.1.1
|
version: 2.1.1
|
||||||
|
cmdk:
|
||||||
|
specifier: ^1.1.1
|
||||||
|
version: 1.1.1(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
|
||||||
date-fns:
|
date-fns:
|
||||||
specifier: ^4.1.0
|
specifier: ^4.1.0
|
||||||
version: 4.1.0
|
version: 4.1.0
|
||||||
@@ -586,6 +592,19 @@ packages:
|
|||||||
'@radix-ui/primitive@1.1.3':
|
'@radix-ui/primitive@1.1.3':
|
||||||
resolution: {integrity: sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==}
|
resolution: {integrity: sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==}
|
||||||
|
|
||||||
|
'@radix-ui/react-alert-dialog@1.1.15':
|
||||||
|
resolution: {integrity: sha512-oTVLkEw5GpdRe29BqJ0LSDFWI3qu0vR1M0mUkOQWDIUnY/QIkLpgDMWuKxP94c2NAC2LGcgVhG1ImF3jkZ5wXw==}
|
||||||
|
peerDependencies:
|
||||||
|
'@types/react': '*'
|
||||||
|
'@types/react-dom': '*'
|
||||||
|
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||||
|
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||||
|
peerDependenciesMeta:
|
||||||
|
'@types/react':
|
||||||
|
optional: true
|
||||||
|
'@types/react-dom':
|
||||||
|
optional: true
|
||||||
|
|
||||||
'@radix-ui/react-arrow@1.1.7':
|
'@radix-ui/react-arrow@1.1.7':
|
||||||
resolution: {integrity: sha512-F+M1tLhO+mlQaOWspE8Wstg+z6PwxwRd8oQ8IXceWz92kfAmalTRf0EjrouQeo7QssEPfCn05B4Ihs1K9WQ/7w==}
|
resolution: {integrity: sha512-F+M1tLhO+mlQaOWspE8Wstg+z6PwxwRd8oQ8IXceWz92kfAmalTRf0EjrouQeo7QssEPfCn05B4Ihs1K9WQ/7w==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
@@ -1571,6 +1590,12 @@ packages:
|
|||||||
resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==}
|
resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==}
|
||||||
engines: {node: '>=6'}
|
engines: {node: '>=6'}
|
||||||
|
|
||||||
|
cmdk@1.1.1:
|
||||||
|
resolution: {integrity: sha512-Vsv7kFaXm+ptHDMZ7izaRsP70GgrW9NBNGswt9OZaVBLlE0SNpDq8eu/VGXyF9r7M0azK3Wy7OlYXsuyYLFzHg==}
|
||||||
|
peerDependencies:
|
||||||
|
react: ^18 || ^19 || ^19.0.0-rc
|
||||||
|
react-dom: ^18 || ^19 || ^19.0.0-rc
|
||||||
|
|
||||||
codepage@1.15.0:
|
codepage@1.15.0:
|
||||||
resolution: {integrity: sha512-3g6NUTPd/YtuuGrhMnOMRjFc+LJw/bnMp3+0r/Wcz3IXUuCosKRJvMphm5+Q+bvTVGcJJuRvVLuYba+WojaFaA==}
|
resolution: {integrity: sha512-3g6NUTPd/YtuuGrhMnOMRjFc+LJw/bnMp3+0r/Wcz3IXUuCosKRJvMphm5+Q+bvTVGcJJuRvVLuYba+WojaFaA==}
|
||||||
engines: {node: '>=0.8'}
|
engines: {node: '>=0.8'}
|
||||||
@@ -3326,6 +3351,20 @@ snapshots:
|
|||||||
|
|
||||||
'@radix-ui/primitive@1.1.3': {}
|
'@radix-ui/primitive@1.1.3': {}
|
||||||
|
|
||||||
|
'@radix-ui/react-alert-dialog@1.1.15(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)':
|
||||||
|
dependencies:
|
||||||
|
'@radix-ui/primitive': 1.1.3
|
||||||
|
'@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.2)(react@19.2.0)
|
||||||
|
'@radix-ui/react-context': 1.1.2(@types/react@19.2.2)(react@19.2.0)
|
||||||
|
'@radix-ui/react-dialog': 1.1.15(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
|
||||||
|
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
|
||||||
|
'@radix-ui/react-slot': 1.2.3(@types/react@19.2.2)(react@19.2.0)
|
||||||
|
react: 19.2.0
|
||||||
|
react-dom: 19.2.0(react@19.2.0)
|
||||||
|
optionalDependencies:
|
||||||
|
'@types/react': 19.2.2
|
||||||
|
'@types/react-dom': 19.2.2(@types/react@19.2.2)
|
||||||
|
|
||||||
'@radix-ui/react-arrow@1.1.7(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)':
|
'@radix-ui/react-arrow@1.1.7(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
|
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
|
||||||
@@ -4315,6 +4354,18 @@ snapshots:
|
|||||||
|
|
||||||
clsx@2.1.1: {}
|
clsx@2.1.1: {}
|
||||||
|
|
||||||
|
cmdk@1.1.1(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0):
|
||||||
|
dependencies:
|
||||||
|
'@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.2)(react@19.2.0)
|
||||||
|
'@radix-ui/react-dialog': 1.1.15(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
|
||||||
|
'@radix-ui/react-id': 1.1.1(@types/react@19.2.2)(react@19.2.0)
|
||||||
|
'@radix-ui/react-primitive': 2.1.4(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
|
||||||
|
react: 19.2.0
|
||||||
|
react-dom: 19.2.0(react@19.2.0)
|
||||||
|
transitivePeerDependencies:
|
||||||
|
- '@types/react'
|
||||||
|
- '@types/react-dom'
|
||||||
|
|
||||||
codepage@1.15.0: {}
|
codepage@1.15.0: {}
|
||||||
|
|
||||||
color-convert@2.0.1:
|
color-convert@2.0.1:
|
||||||
|
|||||||
Reference in New Issue
Block a user