feat: раздел «Документы» в суперадмин-панели + документ «Состояние разработки»
Some checks failed
CI / Lint & Format (push) Has been cancelled
CI / Backend Tests (push) Has been cancelled
CI / Build All Apps (push) Has been cancelled

- 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:
root
2026-02-20 07:13:35 +00:00
parent 31ff150002
commit 4c3ee4a864
4 changed files with 671 additions and 0 deletions

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View 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>
);
}

View File

@@ -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 },
];