feat: раздел «Документы» в суперадмин-панели + документ «Состояние разработки»
- Sidebar: пункт «Документы» с иконкой FileText - /documents — листинг документов (с поиском, пустое состояние) - /documents/[id] — динамическая страница документа (HTML + экспорт DOCX/XLSX) - /documents/dev-status — отчёт о состоянии разработки FitCRM (спринты, TODO, журнал) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,146 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useParams, useRouter } from 'next/navigation';
|
||||
import { ArrowLeft, FileDown, Table2, FileText } from 'lucide-react';
|
||||
import { api, ApiError } from '@/lib/api';
|
||||
|
||||
interface DocumentData {
|
||||
id: string;
|
||||
slug: string;
|
||||
title: string;
|
||||
description: string;
|
||||
type: 'table' | 'report' | 'mixed';
|
||||
content: string; // HTML content
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export default function DocumentDetailPage() {
|
||||
const params = useParams();
|
||||
const router = useRouter();
|
||||
const id = params.id as string;
|
||||
|
||||
const [doc, setDoc] = useState<DocumentData | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
api
|
||||
.get<DocumentData>(`/admin/documents/${id}`)
|
||||
.then((data) => setDoc(data))
|
||||
.catch((err) => {
|
||||
if (err instanceof ApiError && err.status === 404) {
|
||||
setError('Документ не найден');
|
||||
} else {
|
||||
setError(err instanceof Error ? err.message : 'Ошибка загрузки');
|
||||
}
|
||||
})
|
||||
.finally(() => setLoading(false));
|
||||
}, [id]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !doc) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-64 gap-4">
|
||||
<FileText className="h-12 w-12 text-muted" />
|
||||
<p className="text-sm text-error">{error || 'Документ не найден'}</p>
|
||||
<button
|
||||
onClick={() => router.push('/documents')}
|
||||
className="text-sm text-primary hover:underline"
|
||||
>
|
||||
Вернуться к списку
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const handleExport = async (format: 'docx' | 'xlsx') => {
|
||||
try {
|
||||
const response = await fetch(
|
||||
`${process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000/v1'}/admin/documents/${id}/export?format=${format}`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${localStorage.getItem('accessToken') || ''}`,
|
||||
},
|
||||
},
|
||||
);
|
||||
if (!response.ok) throw new Error('Export failed');
|
||||
const blob = await response.blob();
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `${doc.slug}.${format}`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
} catch {
|
||||
alert('Экспорт пока недоступен');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<button
|
||||
onClick={() => router.push('/documents')}
|
||||
className="flex items-center gap-2 text-sm text-muted hover:text-gray-900 mb-6 transition-colors"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
Все документы
|
||||
</button>
|
||||
|
||||
<div className="bg-card rounded-xl shadow-sm border border-border overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="px-6 py-5 border-b border-border flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-xl font-bold text-gray-900">{doc.title}</h1>
|
||||
<p className="text-sm text-muted mt-1">{doc.description}</p>
|
||||
<p className="text-xs text-muted mt-2">
|
||||
{new Date(doc.createdAt).toLocaleDateString('ru-RU', {
|
||||
day: 'numeric',
|
||||
month: 'long',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
{(doc.type === 'report' || doc.type === 'mixed') && (
|
||||
<button
|
||||
onClick={() => void handleExport('docx')}
|
||||
className="flex items-center gap-2 border border-border bg-card text-gray-700 px-4 py-2 rounded-xl text-sm font-medium hover:bg-background transition-colors"
|
||||
>
|
||||
<FileDown className="h-4 w-4" />
|
||||
DOCX
|
||||
</button>
|
||||
)}
|
||||
{(doc.type === 'table' || doc.type === 'mixed') && (
|
||||
<button
|
||||
onClick={() => void handleExport('xlsx')}
|
||||
className="flex items-center gap-2 border border-border bg-card text-gray-700 px-4 py-2 rounded-xl text-sm font-medium hover:bg-background transition-colors"
|
||||
>
|
||||
<Table2 className="h-4 w-4" />
|
||||
XLSX
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div
|
||||
className="px-6 py-6 prose prose-sm max-w-none
|
||||
prose-headings:text-gray-900 prose-p:text-gray-700
|
||||
prose-table:border-collapse prose-th:bg-gray-50 prose-th:px-4 prose-th:py-2 prose-th:text-left prose-th:text-sm prose-th:font-medium prose-th:text-muted prose-th:border prose-th:border-border
|
||||
prose-td:px-4 prose-td:py-2 prose-td:text-sm prose-td:border prose-td:border-border"
|
||||
dangerouslySetInnerHTML={{ __html: doc.content }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,368 @@
|
||||
'use client';
|
||||
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { ArrowLeft, FileDown, GitBranch, CheckCircle2, Clock, AlertCircle } from 'lucide-react';
|
||||
import { cn } from '@/lib/cn';
|
||||
|
||||
const LAST_UPDATE = '20 февраля 2026';
|
||||
|
||||
interface Sprint {
|
||||
id: number;
|
||||
name: string;
|
||||
subtitle: string;
|
||||
percent: number;
|
||||
color: string;
|
||||
tasks: { title: string; done: boolean }[];
|
||||
}
|
||||
|
||||
const sprints: Sprint[] = [
|
||||
{
|
||||
id: 1, name: 'Sprint 1', subtitle: 'Фундамент + Multi-tenancy', percent: 95, color: '#3B82F6',
|
||||
tasks: [
|
||||
{ title: 'Monorepo (Turborepo + pnpm)', done: true },
|
||||
{ title: 'Prisma-схема (25+ таблиц)', done: true },
|
||||
{ title: 'Auth (JWT, 6 ролей, API-ключи, 2FA/TOTP)', done: true },
|
||||
{ title: 'TenantStrategy + RLS-политики (30+ таблиц)', done: true },
|
||||
{ title: 'Модульная система (club_modules + ModuleGuard)', done: true },
|
||||
{ title: 'ProvisioningService (RLS)', done: true },
|
||||
{ title: 'Docker Compose (PostgreSQL + Redis)', done: true },
|
||||
{ title: 'CI pipeline (GitHub Actions)', done: false },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 2, name: 'Sprint 2', subtitle: 'Клиенты + Главная', percent: 90, color: '#8B5CF6',
|
||||
tasks: [
|
||||
{ title: 'CRUD клиентов (список, карточка, поиск, фильтры)', done: true },
|
||||
{ title: 'Mobile: авторизация (login, JWT storage)', done: true },
|
||||
{ title: 'Mobile: главная, клиенты с API', done: true },
|
||||
{ title: 'Web: рецепция (запись на ВПТ, поиск)', done: true },
|
||||
{ title: 'API-клиент (OpenAPI → TS)', done: false },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 3, name: 'Sprint 3', subtitle: 'Расписание + Воронка', percent: 85, color: '#EC4899',
|
||||
tasks: [
|
||||
{ title: 'Расписание (CRUD + отмена/перенос/повтор)', done: true },
|
||||
{ title: 'Воронка (этапы NEW→ASSIGNED→CONDUCTED→REGULAR)', done: true },
|
||||
{ title: 'Mobile: расписание (календарь, создание)', done: true },
|
||||
{ title: 'Mobile: воронка (action screen)', done: true },
|
||||
{ title: 'Push-уведомления (FCM + APNs)', done: false },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 4, name: 'Sprint 4', subtitle: 'Статистика + Продажи = MVP', percent: 80, color: '#F59E0B',
|
||||
tasks: [
|
||||
{ title: 'Статистика, конверсия воронки', done: true },
|
||||
{ title: 'Рейтинг тренеров', done: true },
|
||||
{ title: 'Продажи, реализация, долги', done: true },
|
||||
{ title: 'Mobile: статистика, рейтинг, продажи', done: true },
|
||||
{ title: 'Сквозное тестирование MVP', done: false },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 5, name: 'Sprint 5', subtitle: 'Координатор + SIP + Спящие', percent: 75, color: '#10B981',
|
||||
tasks: [
|
||||
{ title: 'Координатор: распределение, статистика', done: true },
|
||||
{ title: 'SIP-клиент (UI экраны)', done: true },
|
||||
{ title: 'SIP: WebRTC интеграция', done: false },
|
||||
{ title: 'Спящие клиенты', done: true },
|
||||
{ title: 'График работы (синхронизация 1С)', done: false },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 6, name: 'Sprint 6', subtitle: 'Веб-отчёты + Панель клуба', percent: 70, color: '#06B6D4',
|
||||
tasks: [
|
||||
{ title: 'Web: 3 отчёта (текущее состояние, статистика, аналитика)', done: true },
|
||||
{ title: 'Панель админа клуба: сотрудники', done: true },
|
||||
{ title: 'Панель админа клуба: департаменты, залы', done: true },
|
||||
{ title: 'Панель админа клуба: каталог услуг', done: true },
|
||||
{ title: 'Панель админа клуба: интеграции, настройки', done: false },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 7, name: 'Sprint 7', subtitle: 'Суперадмин + Webhooks + Beta', percent: 65, color: '#EF4444',
|
||||
tasks: [
|
||||
{ title: 'Панель суперадмина (дашборд, клубы, лицензии, мониторинг)', done: true },
|
||||
{ title: 'Webhooks (12 типов событий, HMAC-SHA256, retry)', done: true },
|
||||
{ title: 'Интеграция 1С (полная)', done: false },
|
||||
{ title: 'Мониторинг + аудит лог', done: true },
|
||||
{ title: 'Beta-тестирование', done: false },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 8, name: 'Sprint 8', subtitle: 'Полировка + Release', percent: 45, color: '#6366F1',
|
||||
tasks: [
|
||||
{ title: 'E2E тесты (Playwright) — 144 теста', done: true },
|
||||
{ title: 'Фикс dashboard 400 + cursor-пагинация', done: true },
|
||||
{ title: 'Фикс audit_logs 500 (SQL колонки)', done: true },
|
||||
{ title: 'Оптимизация производительности', done: false },
|
||||
{ title: 'Аудит безопасности', done: false },
|
||||
{ title: 'App Store + Google Play', done: false },
|
||||
{ title: 'Пилотное внедрение', done: false },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
interface TodoItem {
|
||||
title: string;
|
||||
priority: 'high' | 'medium' | 'low';
|
||||
sprint: string;
|
||||
}
|
||||
|
||||
const todoItems: TodoItem[] = [
|
||||
{ title: 'CI/CD pipeline (GitHub Actions)', priority: 'high', sprint: 'Sprint 1' },
|
||||
{ title: 'Push-уведомления — FCM + APNs интеграция', priority: 'high', sprint: 'Sprint 3' },
|
||||
{ title: 'SIP WebRTC — интеграция с SIP-сервером', priority: 'high', sprint: 'Sprint 5' },
|
||||
{ title: 'Синхронизация графика с 1С', priority: 'medium', sprint: 'Sprint 5' },
|
||||
{ title: 'Панель клуба: интеграции и настройки', priority: 'medium', sprint: 'Sprint 6' },
|
||||
{ title: 'Полная интеграция 1С (REST/SOAP)', priority: 'high', sprint: 'Sprint 7' },
|
||||
{ title: 'Beta-тестирование с пилотным клубом', priority: 'medium', sprint: 'Sprint 7' },
|
||||
{ title: 'Оптимизация производительности', priority: 'medium', sprint: 'Sprint 8' },
|
||||
{ title: 'Аудит безопасности', priority: 'high', sprint: 'Sprint 8' },
|
||||
{ title: 'Публикация в App Store / Google Play', priority: 'low', sprint: 'Sprint 8' },
|
||||
{ title: 'OpenAPI → TS клиент генерация', priority: 'low', sprint: 'Sprint 2' },
|
||||
{ title: 'Сквозное тестирование MVP-потока', priority: 'medium', sprint: 'Sprint 4' },
|
||||
];
|
||||
|
||||
const doneLog = [
|
||||
{ date: '20 февраля 2026', items: [
|
||||
'E2E тесты: 144 теста проходят (Playwright)',
|
||||
'Фикс dashboard 400: limit=500 → cursor-based пагинация (fetchAll)',
|
||||
'Фикс audit_logs 500: SQL-запрос использовал несуществующие колонки',
|
||||
'Фикс auth: ON CONFLICT для refresh_tokens',
|
||||
'GET /v1/auth/profile эндпоинт',
|
||||
'Лендинг перенесён на myfitcrm.ru',
|
||||
'web-admin перенесён на app.myfitcrm.ru',
|
||||
'SSL-сертификат расширен (app.myfitcrm.ru)',
|
||||
'Раздел «Разработка» и «Документы» в суперадмин-панели',
|
||||
'E2E тесты admin: pagination limits, RBAC, audit-logs, search',
|
||||
'API root redirect на /api/docs',
|
||||
]},
|
||||
{ date: '19 февраля 2026', items: [
|
||||
'Лендинг (Next.js): Hero, Features, Roles, Pricing, FAQ, CTA',
|
||||
'Nginx: маршрутизация myfitcrm.ru, api., app., platform.',
|
||||
'PM2 ecosystem: все 6 сервисов',
|
||||
]},
|
||||
{ date: '18 февраля 2026', items: [
|
||||
'Mobile: экран уведомлений, PDF отчёты',
|
||||
'Expo SDK 54, AuthGuard веб-панелей',
|
||||
'CRUD-диалоги веб-панелей (клубы, лицензии)',
|
||||
]},
|
||||
{ date: '17 февраля 2026', items: [
|
||||
'BullMQ очереди для уведомлений и webhooks',
|
||||
'2FA/TOTP для суперадмина',
|
||||
'Audit log (журнал действий)',
|
||||
'Rate limiting (1000 req/min)',
|
||||
'310 unit-тестов',
|
||||
]},
|
||||
{ date: '15 февраля 2026', items: [
|
||||
'Mobile Sprint 5: Expo SDK 54, Clients/Home экраны с API',
|
||||
'Barrel exports для компонентов',
|
||||
'Funnel action screen (воронка)',
|
||||
]},
|
||||
{ date: '13 февраля 2026', items: [
|
||||
'Backend: auth, funnel, schedule, stats, clients сервисы',
|
||||
'Mobile: auth, funnel экраны',
|
||||
'Prisma schema: 25 таблиц, seed data',
|
||||
]},
|
||||
];
|
||||
|
||||
const priorityColors = {
|
||||
high: { bg: 'bg-red-50', text: 'text-red-700', border: 'border-red-200', label: 'Высокий' },
|
||||
medium: { bg: 'bg-orange-50', text: 'text-orange-700', border: 'border-orange-200', label: 'Средний' },
|
||||
low: { bg: 'bg-blue-50', text: 'text-blue-700', border: 'border-blue-200', label: 'Низкий' },
|
||||
};
|
||||
|
||||
export default function DevStatusDocument() {
|
||||
const router = useRouter();
|
||||
const totalPercent = Math.round(sprints.reduce((s, sp) => s + sp.percent, 0) / sprints.length);
|
||||
const totalTasks = sprints.reduce((s, sp) => s + sp.tasks.length, 0);
|
||||
const doneTasks = sprints.reduce((s, sp) => s + sp.tasks.filter((t) => t.done).length, 0);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<button
|
||||
onClick={() => router.push('/documents')}
|
||||
className="flex items-center gap-2 text-sm text-muted hover:text-gray-900 mb-6 transition-colors"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
Все документы
|
||||
</button>
|
||||
|
||||
<div className="bg-card rounded-xl shadow-sm border border-border overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="px-8 py-6 border-b border-border">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="bg-purple-50 p-2.5 rounded-lg">
|
||||
<GitBranch className="h-6 w-6 text-purple-600" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-xl font-bold text-gray-900">
|
||||
Отчёт о состоянии разработки FitCRM
|
||||
</h1>
|
||||
<p className="text-sm text-muted mt-1">
|
||||
Дата: {LAST_UPDATE} | 8 спринтов | {totalTasks} задач
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => window.print()}
|
||||
className="flex items-center gap-2 border border-border bg-card text-gray-700 px-4 py-2 rounded-xl text-sm font-medium hover:bg-background transition-colors print:hidden"
|
||||
>
|
||||
<FileDown className="h-4 w-4" />
|
||||
Печать / PDF
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="px-8 py-6 space-y-8">
|
||||
{/* Summary */}
|
||||
<div className="grid grid-cols-4 gap-4">
|
||||
<div className="bg-gradient-to-br from-primary/10 to-orange-50 rounded-xl p-4 text-center">
|
||||
<p className="text-3xl font-bold text-primary">{totalPercent}%</p>
|
||||
<p className="text-xs text-muted mt-1">Общий прогресс</p>
|
||||
</div>
|
||||
<div className="bg-green-50 rounded-xl p-4 text-center">
|
||||
<p className="text-3xl font-bold text-green-700">{doneTasks}</p>
|
||||
<p className="text-xs text-muted mt-1">Задач выполнено</p>
|
||||
</div>
|
||||
<div className="bg-orange-50 rounded-xl p-4 text-center">
|
||||
<p className="text-3xl font-bold text-orange-700">{totalTasks - doneTasks}</p>
|
||||
<p className="text-xs text-muted mt-1">Задач осталось</p>
|
||||
</div>
|
||||
<div className="bg-blue-50 rounded-xl p-4 text-center">
|
||||
<p className="text-3xl font-bold text-blue-700">144</p>
|
||||
<p className="text-xs text-muted mt-1">E2E тестов</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Sprint table */}
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-gray-900 mb-4">Прогресс по спринтам</h2>
|
||||
<table className="w-full text-sm border-collapse">
|
||||
<thead>
|
||||
<tr className="bg-gray-50">
|
||||
<th className="text-left px-4 py-3 font-medium text-muted border border-border">Спринт</th>
|
||||
<th className="text-left px-4 py-3 font-medium text-muted border border-border">Содержание</th>
|
||||
<th className="text-center px-4 py-3 font-medium text-muted border border-border w-28">Прогресс</th>
|
||||
<th className="text-center px-4 py-3 font-medium text-muted border border-border w-20">Задачи</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{sprints.map((sp) => {
|
||||
const done = sp.tasks.filter((t) => t.done).length;
|
||||
return (
|
||||
<tr key={sp.id} className="hover:bg-gray-50/50">
|
||||
<td className="px-4 py-3 border border-border font-medium text-gray-900">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-3 h-3 rounded-sm shrink-0" style={{ backgroundColor: sp.color }} />
|
||||
{sp.name}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-3 border border-border text-gray-600">{sp.subtitle}</td>
|
||||
<td className="px-4 py-3 border border-border">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex-1 bg-gray-100 rounded-full h-2">
|
||||
<div className="h-2 rounded-full" style={{ width: `${sp.percent}%`, backgroundColor: sp.color }} />
|
||||
</div>
|
||||
<span className="text-xs font-bold" style={{ color: sp.color }}>{sp.percent}%</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-3 border border-border text-center text-gray-600">
|
||||
{done}/{sp.tasks.length}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Detailed tasks */}
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-gray-900 mb-4">Детализация задач</h2>
|
||||
<div className="space-y-4">
|
||||
{sprints.map((sp) => (
|
||||
<div key={sp.id}>
|
||||
<h3 className="text-sm font-semibold text-gray-700 mb-2 flex items-center gap-2">
|
||||
<div className="w-3 h-3 rounded-sm" style={{ backgroundColor: sp.color }} />
|
||||
{sp.name}: {sp.subtitle}
|
||||
</h3>
|
||||
<div className="grid grid-cols-2 gap-x-6 gap-y-1 ml-5">
|
||||
{sp.tasks.map((t, i) => (
|
||||
<div key={i} className="flex items-center gap-2 text-sm py-0.5">
|
||||
{t.done ? (
|
||||
<CheckCircle2 className="h-4 w-4 text-green-600 shrink-0" />
|
||||
) : (
|
||||
<Clock className="h-4 w-4 text-gray-400 shrink-0" />
|
||||
)}
|
||||
<span className={t.done ? 'text-gray-500' : 'text-gray-900'}>{t.title}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* TODO */}
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-gray-900 mb-4">TODO — оставшиеся задачи</h2>
|
||||
<table className="w-full text-sm border-collapse">
|
||||
<thead>
|
||||
<tr className="bg-gray-50">
|
||||
<th className="text-left px-4 py-2 font-medium text-muted border border-border">Задача</th>
|
||||
<th className="text-center px-4 py-2 font-medium text-muted border border-border w-28">Приоритет</th>
|
||||
<th className="text-center px-4 py-2 font-medium text-muted border border-border w-24">Спринт</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{todoItems.map((item, i) => {
|
||||
const p = priorityColors[item.priority];
|
||||
return (
|
||||
<tr key={i} className="hover:bg-gray-50/50">
|
||||
<td className="px-4 py-2 border border-border text-gray-900 flex items-center gap-2">
|
||||
<AlertCircle className="h-4 w-4 text-gray-400 shrink-0" />
|
||||
{item.title}
|
||||
</td>
|
||||
<td className="px-4 py-2 border border-border text-center">
|
||||
<span className={cn('text-[10px] font-semibold px-2 py-0.5 rounded-full border', p.bg, p.text, p.border)}>
|
||||
{p.label}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-2 border border-border text-center text-gray-600">{item.sprint}</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Done log */}
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-gray-900 mb-4">Журнал выполненных работ</h2>
|
||||
<div className="space-y-4">
|
||||
{doneLog.map((entry) => (
|
||||
<div key={entry.date}>
|
||||
<h3 className="text-sm font-semibold text-gray-700 flex items-center gap-2 mb-2">
|
||||
<div className="h-2 w-2 rounded-full bg-green-500" />
|
||||
{entry.date}
|
||||
</h3>
|
||||
<ul className="ml-4 space-y-1">
|
||||
{entry.items.map((item, i) => (
|
||||
<li key={i} className="flex items-start gap-2 text-sm text-gray-600">
|
||||
<CheckCircle2 className="h-4 w-4 text-green-500 shrink-0 mt-0.5" />
|
||||
{item}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
155
apps/web-platform-admin/src/app/(dashboard)/documents/page.tsx
Normal file
155
apps/web-platform-admin/src/app/(dashboard)/documents/page.tsx
Normal file
@@ -0,0 +1,155 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { FileText, Download, Table2, FileDown, Clock, Search } from 'lucide-react';
|
||||
import { cn } from '@/lib/cn';
|
||||
import { api } from '@/lib/api';
|
||||
|
||||
interface DocumentMeta {
|
||||
id: string;
|
||||
slug: string;
|
||||
title: string;
|
||||
description: string;
|
||||
type: 'table' | 'report' | 'mixed';
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
const typeLabels: Record<string, { label: string; color: string; bg: string }> = {
|
||||
table: { label: 'Таблица', color: 'text-blue-700', bg: 'bg-blue-50' },
|
||||
report: { label: 'Отчёт', color: 'text-purple-700', bg: 'bg-purple-50' },
|
||||
mixed: { label: 'Смешанный', color: 'text-teal-700', bg: 'bg-teal-50' },
|
||||
};
|
||||
|
||||
export default function DocumentsPage() {
|
||||
const [docs, setDocs] = useState<DocumentMeta[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [search, setSearch] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
api
|
||||
.get<{ data: DocumentMeta[] }>('/admin/documents')
|
||||
.then((res) => setDocs(res.data))
|
||||
.catch(() => {
|
||||
// No backend endpoint yet — show empty state
|
||||
setDocs([]);
|
||||
})
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
const filtered = search
|
||||
? docs.filter(
|
||||
(d) =>
|
||||
d.title.toLowerCase().includes(search.toLowerCase()) ||
|
||||
d.description.toLowerCase().includes(search.toLowerCase()),
|
||||
)
|
||||
: docs;
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="bg-orange-50 p-2.5 rounded-lg">
|
||||
<FileText className="h-6 w-6 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">Документы</h1>
|
||||
<p className="text-sm text-muted">
|
||||
Сгенерированные отчёты и документы
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{docs.length > 0 && (
|
||||
<div className="mb-6">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Поиск документов..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="w-full pl-10 pr-4 py-2.5 border border-border rounded-xl text-sm focus:outline-none focus:ring-2 focus:ring-primary/30 focus:border-primary"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{filtered.length === 0 ? (
|
||||
<div className="bg-card rounded-xl p-12 shadow-sm border border-border text-center">
|
||||
<FileText className="h-12 w-12 text-muted mx-auto mb-4" />
|
||||
<h2 className="text-lg font-semibold text-gray-900 mb-2">
|
||||
{docs.length === 0 ? 'Нет документов' : 'Ничего не найдено'}
|
||||
</h2>
|
||||
<p className="text-sm text-muted max-w-md mx-auto">
|
||||
{docs.length === 0
|
||||
? 'Документы создаются автоматически при выполнении задач через Telegram. Попросите ассистента сформировать отчёт или аналитику.'
|
||||
: 'Попробуйте другой поисковый запрос.'}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{filtered.map((doc) => {
|
||||
const typeInfo = typeLabels[doc.type] ?? typeLabels['report']!;
|
||||
return (
|
||||
<Link
|
||||
key={doc.id}
|
||||
href={`/documents/${doc.slug}`}
|
||||
className="block bg-card rounded-xl p-5 shadow-sm border border-border hover:border-primary/30 hover:shadow-md transition-all"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="bg-gray-50 p-2.5 rounded-lg shrink-0">
|
||||
{doc.type === 'table' ? (
|
||||
<Table2 className="h-5 w-5 text-blue-600" />
|
||||
) : (
|
||||
<FileDown className="h-5 w-5 text-purple-600" />
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-gray-900">
|
||||
{doc.title}
|
||||
</h3>
|
||||
<p className="text-xs text-muted mt-1">
|
||||
{doc.description}
|
||||
</p>
|
||||
<div className="flex items-center gap-3 mt-2">
|
||||
<span
|
||||
className={cn(
|
||||
'text-[10px] font-semibold px-2 py-0.5 rounded-full',
|
||||
typeInfo.bg,
|
||||
typeInfo.color,
|
||||
)}
|
||||
>
|
||||
{typeInfo.label}
|
||||
</span>
|
||||
<span className="flex items-center gap-1 text-[11px] text-muted">
|
||||
<Clock className="h-3 w-3" />
|
||||
{new Date(doc.createdAt).toLocaleDateString('ru-RU', {
|
||||
day: 'numeric',
|
||||
month: 'long',
|
||||
year: 'numeric',
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Download className="h-4 w-4 text-muted shrink-0 mt-1" />
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
ScrollText,
|
||||
Shield,
|
||||
GitBranch,
|
||||
FileText,
|
||||
} from 'lucide-react';
|
||||
import { cn } from '@/lib/cn';
|
||||
|
||||
@@ -19,6 +20,7 @@ const menuItems = [
|
||||
{ href: '/licenses', label: 'Лицензии', icon: KeyRound },
|
||||
{ href: '/monitoring', label: 'Мониторинг', icon: Activity },
|
||||
{ href: '/audit', label: 'Журнал действий', icon: ScrollText },
|
||||
{ href: '/documents', label: 'Документы', icon: FileText },
|
||||
{ href: '/development', label: 'Разработка', icon: GitBranch },
|
||||
];
|
||||
|
||||
|
||||
Reference in New Issue
Block a user