diff --git a/frontend/src/components/FileUpload.tsx b/frontend/src/components/FileUpload.tsx new file mode 100644 index 0000000..ffdf8dc --- /dev/null +++ b/frontend/src/components/FileUpload.tsx @@ -0,0 +1,39 @@ +import { useRef, useState } from 'react' + +interface Props { + onUpload: (file: File, title: string) => Promise + isUploading: boolean +} + +export default function FileUpload({ onUpload, isUploading }: Props) { + const [title, setTitle] = useState('') + const fileRef = useRef(null) + + const handleSubmit = async () => { + const file = fileRef.current?.files?.[0] + if (!file) return + await onUpload(file, title || file.name) + setTitle('') + if (fileRef.current) fileRef.current.value = '' + } + + return ( +
+ + setTitle(e.target.value)} + className="border rounded px-3 py-1.5 text-sm" + /> + +
+ ) +} diff --git a/frontend/src/components/StatusBadge.tsx b/frontend/src/components/StatusBadge.tsx new file mode 100644 index 0000000..ffeac03 --- /dev/null +++ b/frontend/src/components/StatusBadge.tsx @@ -0,0 +1,23 @@ +const STATUS_STYLES: Record = { + uploaded: 'bg-gray-100 text-gray-700', + processing: 'bg-yellow-100 text-yellow-800', + done: 'bg-green-100 text-green-800', + error: 'bg-red-100 text-red-800', + generating: 'bg-blue-100 text-blue-800', +} + +const STATUS_LABELS: Record = { + uploaded: 'Загружено', + processing: 'Обработка...', + done: 'Готово', + error: 'Ошибка', + generating: 'Генерация...', +} + +export default function StatusBadge({ status }: { status: string }) { + return ( + + {STATUS_LABELS[status] ?? status} + + ) +} diff --git a/frontend/src/pages/ProtocolDetailPage.tsx b/frontend/src/pages/ProtocolDetailPage.tsx new file mode 100644 index 0000000..caab087 --- /dev/null +++ b/frontend/src/pages/ProtocolDetailPage.tsx @@ -0,0 +1,96 @@ +import { useParams } from 'react-router-dom' +import { useProtocol } from '../hooks/useProtocols' +import { useTemplates } from '../hooks/useTemplates' +import StatusBadge from '../components/StatusBadge' +import api from '../api/client' + +const SECTION_LABELS: Record = { + date_participants_topic: 'Дата, участники, тема встречи', + process_description: 'Описание обследуемого процесса', + current_state: 'Текущее состояние (as-is)', + problems: 'Выявленные проблемы / узкие места', + agreements: 'Договорённости и решения', + open_questions: 'Открытые вопросы', + tasks: 'Задачи с ответственными и сроками', + next_steps: 'Следующие шаги', + client_goals: 'Верхнеуровневые цели клиента', + project_parameters: 'Параметры будущего проекта', + budget_constraints: 'Бюджетные ограничения', + time_constraints: 'Ограничения по срокам', + decision_makers: 'ЛПР', + beneficiaries: 'Бенефициары', + influencers: 'Лица, влияющие на принятие решения', + discussed_topics: 'Обсуждённые вопросы', + decisions: 'Принятые решения', +} + +function renderValue(value: unknown): string { + if (Array.isArray(value)) { + return value + .map((item) => + typeof item === 'object' ? Object.entries(item as Record).map(([k, v]) => `${k}: ${v}`).join(', ') : String(item), + ) + .join('\n') + } + return String(value ?? '') +} + +async function exportProtocol(protocolId: string, format: string) { + const resp = await api.post(`/exports/protocol/${protocolId}`, { format }, { responseType: 'json' }) + const exportId = resp.data.id + window.open(`/api/exports/${exportId}/download`, '_blank') +} + +export default function ProtocolDetailPage() { + const { id } = useParams<{ id: string }>() + const { data: protocol, isLoading } = useProtocol(id!) + const { templates: _templates } = useTemplates() + + if (isLoading || !protocol) return

Загрузка...

+ + return ( +
+
+

Протокол

+ +
+ +
+ {(['docx', 'pdf', 'md'] as const).map((fmt) => ( + + ))} +
+ + {protocol.status === 'done' && protocol.content && ( +
+ {Object.entries(protocol.content).map(([key, value]) => ( +
+

+ {SECTION_LABELS[key] ?? key} +

+

{renderValue(value)}

+
+ ))} +
+ )} + + {protocol.status === 'generating' && ( +
+

Протокол генерируется, подождите...

+
+ )} + + {protocol.status === 'error' && ( +
+

Ошибка при генерации протокола

+
+ )} +
+ ) +} diff --git a/frontend/src/pages/ProtocolsPage.tsx b/frontend/src/pages/ProtocolsPage.tsx new file mode 100644 index 0000000..e7b35fc --- /dev/null +++ b/frontend/src/pages/ProtocolsPage.tsx @@ -0,0 +1,45 @@ +import { Link } from 'react-router-dom' +import StatusBadge from '../components/StatusBadge' +import { useProtocols } from '../hooks/useProtocols' + +export default function ProtocolsPage() { + const { protocols, isLoading } = useProtocols() + + return ( +
+

Протоколы

+ {isLoading ? ( +

Загрузка...

+ ) : ( +
+ + + + + + + + + + + + {protocols.map((p) => ( + + + + + + + + ))} + +
IDМодельСтатусДатаДействия
{p.id.slice(0, 8)}{p.llm_model || '—'}{new Date(p.created_at).toLocaleDateString('ru')} + + Открыть + +
+
+ )} +
+ ) +} diff --git a/frontend/src/pages/RecordingsPage.tsx b/frontend/src/pages/RecordingsPage.tsx new file mode 100644 index 0000000..27fdaa5 --- /dev/null +++ b/frontend/src/pages/RecordingsPage.tsx @@ -0,0 +1,70 @@ +import FileUpload from '../components/FileUpload' +import StatusBadge from '../components/StatusBadge' +import { useRecordings } from '../hooks/useRecordings' + +function formatSize(bytes: number) { + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)} КБ` + return `${(bytes / 1024 / 1024).toFixed(1)} МБ` +} + +function formatDuration(seconds: number | null) { + if (!seconds) return '—' + const h = Math.floor(seconds / 3600) + const m = Math.floor((seconds % 3600) / 60) + const s = Math.floor(seconds % 60) + return `${h.toString().padStart(2, '0')}:${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}` +} + +const SOURCE_LABELS: Record = { web: '🌐 Web', telegram: '💬 Telegram', lensa: '💬 Lensa' } + +export default function RecordingsPage() { + const { recordings, isLoading, upload, isUploading, deleteRecording } = useRecordings() + + return ( +
+
+

Записи

+
+
+ +
+ {isLoading ? ( +

Загрузка...

+ ) : ( +
+ + + + + + + + + + + + + {recordings.map((rec) => ( + + + + + + + + + ))} + +
НазваниеИсточникРазмерДлительностьСтатусДействия
{rec.title}{SOURCE_LABELS[rec.source] ?? rec.source}{formatSize(rec.file_size)}{formatDuration(rec.duration)} + +
+
+ )} +
+ ) +} diff --git a/frontend/src/pages/SettingsPage.tsx b/frontend/src/pages/SettingsPage.tsx new file mode 100644 index 0000000..3a7ddf9 --- /dev/null +++ b/frontend/src/pages/SettingsPage.tsx @@ -0,0 +1,71 @@ +import { useState } from 'react' +import { useQuery } from '@tanstack/react-query' +import { useAuth } from '../hooks/useAuth' +import api from '../api/client' +import type { StorageInfo } from '../types' + +export default function SettingsPage() { + const { user } = useAuth() + const [retentionDays, setRetentionDays] = useState(90) + const [cleanupStarted, setCleanupStarted] = useState(false) + + const { data: storage } = useQuery({ + queryKey: ['storage'], + queryFn: () => api.get('/settings/storage').then((r) => r.data), + }) + + const handleCleanup = async () => { + await api.post('/settings/cleanup', null, { params: { retention_days: retentionDays } }) + setCleanupStarted(true) + setTimeout(() => setCleanupStarted(false), 5000) + } + + return ( +
+

Настройки

+ +
+
+

Профиль

+

Пользователь: {user?.username}

+

Email: {user?.email}

+

Роль: {user?.role}

+
+ +
+

Хранилище

+ {storage && ( +
+

Записи: {(storage.uploads_bytes / 1024 / 1024 / 1024).toFixed(2)} ГБ

+

Экспорт: {(storage.exports_bytes / 1024 / 1024 / 1024).toFixed(2)} ГБ

+

Итого: {storage.total_gb} ГБ

+
+ )} +
+ + {user?.role === 'admin' && ( +
+

Очистка данных

+
+ + setRetentionDays(Number(e.target.value))} + className="border rounded px-2 py-1 w-20 text-sm" + /> + дней + +
+ {cleanupStarted &&

Очистка запущена!

} +
+ )} +
+
+ ) +} diff --git a/frontend/src/pages/TemplatesPage.tsx b/frontend/src/pages/TemplatesPage.tsx new file mode 100644 index 0000000..84aee88 --- /dev/null +++ b/frontend/src/pages/TemplatesPage.tsx @@ -0,0 +1,89 @@ +import { useState } from 'react' +import { useTemplates } from '../hooks/useTemplates' + +export default function TemplatesPage() { + const { templates, isLoading, createTemplate, deleteTemplate } = useTemplates() + const [showForm, setShowForm] = useState(false) + const [form, setForm] = useState({ name: '', description: '', system_prompt: '', user_prompt: '', type: 'custom' as const }) + + const handleCreate = async () => { + await createTemplate(form) + setShowForm(false) + setForm({ name: '', description: '', system_prompt: '', user_prompt: '', type: 'custom' }) + } + + return ( +
+
+

Шаблоны протоколов

+ +
+ + {showForm && ( +
+ setForm({ ...form, name: e.target.value })} + className="w-full border rounded px-3 py-2 text-sm" + /> + setForm({ ...form, description: e.target.value })} + className="w-full border rounded px-3 py-2 text-sm" + /> +