diff --git a/frontend/Dockerfile b/frontend/Dockerfile
new file mode 100644
index 0000000..000d01a
--- /dev/null
+++ b/frontend/Dockerfile
@@ -0,0 +1,9 @@
+FROM node:20-alpine AS build
+WORKDIR /app
+COPY package.json package-lock.json* ./
+RUN npm install
+COPY . .
+RUN npm run build
+
+FROM nginx:alpine
+COPY --from=build /app/dist /usr/share/nginx/html
diff --git a/frontend/index.html b/frontend/index.html
new file mode 100644
index 0000000..bb9f5b3
--- /dev/null
+++ b/frontend/index.html
@@ -0,0 +1,12 @@
+
+
+
+
+
+ Meeting Protocol Service
+
+
+
+
+
+
diff --git a/frontend/package.json b/frontend/package.json
new file mode 100644
index 0000000..21fff0e
--- /dev/null
+++ b/frontend/package.json
@@ -0,0 +1,28 @@
+{
+ "name": "meeting-protocol-frontend",
+ "private": true,
+ "version": "0.1.0",
+ "type": "module",
+ "scripts": {
+ "dev": "vite",
+ "build": "tsc && vite build",
+ "preview": "vite preview"
+ },
+ "dependencies": {
+ "@tanstack/react-query": "^5.62.0",
+ "axios": "^1.7.9",
+ "react": "^18.3.1",
+ "react-dom": "^18.3.1",
+ "react-router-dom": "^6.28.0"
+ },
+ "devDependencies": {
+ "@types/react": "^18.3.12",
+ "@types/react-dom": "^18.3.1",
+ "@vitejs/plugin-react": "^4.3.4",
+ "autoprefixer": "^10.4.20",
+ "postcss": "^8.4.49",
+ "tailwindcss": "^3.4.16",
+ "typescript": "^5.7.2",
+ "vite": "^6.0.3"
+ }
+}
diff --git a/frontend/postcss.config.js b/frontend/postcss.config.js
new file mode 100644
index 0000000..2e7af2b
--- /dev/null
+++ b/frontend/postcss.config.js
@@ -0,0 +1,6 @@
+export default {
+ plugins: {
+ tailwindcss: {},
+ autoprefixer: {},
+ },
+}
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
new file mode 100644
index 0000000..04df0e6
--- /dev/null
+++ b/frontend/src/App.tsx
@@ -0,0 +1,32 @@
+import { BrowserRouter, Route, Routes } from 'react-router-dom'
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
+import Layout from './components/Layout'
+import ProtectedRoute from './components/ProtectedRoute'
+import LoginPage from './pages/LoginPage'
+import RecordingsPage from './pages/RecordingsPage'
+import ProtocolsPage from './pages/ProtocolsPage'
+import ProtocolDetailPage from './pages/ProtocolDetailPage'
+import TemplatesPage from './pages/TemplatesPage'
+import SettingsPage from './pages/SettingsPage'
+
+const queryClient = new QueryClient()
+
+export default function App() {
+ return (
+
+
+
+ } />
+ }>
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+
+
+
+
+ )
+}
diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts
new file mode 100644
index 0000000..43d89e6
--- /dev/null
+++ b/frontend/src/api/client.ts
@@ -0,0 +1,24 @@
+import axios from 'axios'
+
+const api = axios.create({ baseURL: '/api' })
+
+api.interceptors.request.use((config) => {
+ const token = localStorage.getItem('token')
+ if (token) {
+ config.headers.Authorization = `Bearer ${token}`
+ }
+ return config
+})
+
+api.interceptors.response.use(
+ (response) => response,
+ (error) => {
+ if (error.response?.status === 401) {
+ localStorage.removeItem('token')
+ window.location.href = '/login'
+ }
+ return Promise.reject(error)
+ },
+)
+
+export default api
diff --git a/frontend/src/components/Layout.tsx b/frontend/src/components/Layout.tsx
new file mode 100644
index 0000000..ccbf810
--- /dev/null
+++ b/frontend/src/components/Layout.tsx
@@ -0,0 +1,37 @@
+import { Link, Outlet } from 'react-router-dom'
+import { useQuery } from '@tanstack/react-query'
+import { useAuth } from '../hooks/useAuth'
+import api from '../api/client'
+import type { StorageInfo } from '../types'
+
+export default function Layout() {
+ const { user, logout } = useAuth()
+ const { data: storage } = useQuery({
+ queryKey: ['storage'],
+ queryFn: () => api.get('/settings/storage').then((r) => r.data),
+ })
+
+ return (
+
+
+
+
+
+
+ )
+}
diff --git a/frontend/src/components/ProtectedRoute.tsx b/frontend/src/components/ProtectedRoute.tsx
new file mode 100644
index 0000000..704a630
--- /dev/null
+++ b/frontend/src/components/ProtectedRoute.tsx
@@ -0,0 +1,10 @@
+import { Navigate } from 'react-router-dom'
+import { useAuth } from '../hooks/useAuth'
+
+export default function ProtectedRoute({ children }: { children: React.ReactNode }) {
+ const { user, isLoading } = useAuth()
+
+ if (isLoading) return Загрузка...
+ if (!user) return
+ return <>{children}>
+}
diff --git a/frontend/src/hooks/useAuth.ts b/frontend/src/hooks/useAuth.ts
new file mode 100644
index 0000000..0b43e06
--- /dev/null
+++ b/frontend/src/hooks/useAuth.ts
@@ -0,0 +1,31 @@
+import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
+import api from '../api/client'
+import type { User } from '../types'
+
+export function useAuth() {
+ const queryClient = useQueryClient()
+
+ const { data: user, isLoading } = useQuery({
+ queryKey: ['me'],
+ queryFn: () => api.get('/auth/me').then((r) => r.data),
+ retry: false,
+ enabled: !!localStorage.getItem('token'),
+ })
+
+ const loginMutation = useMutation({
+ mutationFn: (data: { username: string; password: string }) =>
+ api.post('/auth/login', data).then((r) => r.data),
+ onSuccess: (data) => {
+ localStorage.setItem('token', data.access_token)
+ queryClient.invalidateQueries({ queryKey: ['me'] })
+ },
+ })
+
+ const logout = () => {
+ localStorage.removeItem('token')
+ queryClient.clear()
+ window.location.href = '/login'
+ }
+
+ return { user, isLoading, login: loginMutation.mutateAsync, loginError: loginMutation.error, logout }
+}
diff --git a/frontend/src/hooks/useProtocols.ts b/frontend/src/hooks/useProtocols.ts
new file mode 100644
index 0000000..90afe34
--- /dev/null
+++ b/frontend/src/hooks/useProtocols.ts
@@ -0,0 +1,34 @@
+import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
+import api from '../api/client'
+import type { Protocol } from '../types'
+
+export function useProtocols() {
+ const queryClient = useQueryClient()
+
+ const { data, isLoading } = useQuery<{ items: Protocol[]; total: number }>({
+ queryKey: ['protocols'],
+ queryFn: () => api.get('/protocols').then((r) => r.data),
+ refetchInterval: 5000,
+ })
+
+ const regenerateMutation = useMutation({
+ mutationFn: ({ id, templateId }: { id: string; templateId?: string }) =>
+ api.post(`/protocols/${id}/regenerate`, { template_id: templateId }),
+ onSuccess: () => queryClient.invalidateQueries({ queryKey: ['protocols'] }),
+ })
+
+ return {
+ protocols: data?.items ?? [],
+ total: data?.total ?? 0,
+ isLoading,
+ regenerate: regenerateMutation.mutateAsync,
+ }
+}
+
+export function useProtocol(id: string) {
+ return useQuery({
+ queryKey: ['protocol', id],
+ queryFn: () => api.get(`/protocols/${id}`).then((r) => r.data),
+ refetchInterval: 3000,
+ })
+}
diff --git a/frontend/src/hooks/useRecordings.ts b/frontend/src/hooks/useRecordings.ts
new file mode 100644
index 0000000..0c14bd1
--- /dev/null
+++ b/frontend/src/hooks/useRecordings.ts
@@ -0,0 +1,37 @@
+import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
+import api from '../api/client'
+import type { Recording } from '../types'
+
+export function useRecordings() {
+ const queryClient = useQueryClient()
+
+ const { data, isLoading } = useQuery<{ items: Recording[]; total: number }>({
+ queryKey: ['recordings'],
+ queryFn: () => api.get('/recordings').then((r) => r.data),
+ refetchInterval: 5000,
+ })
+
+ const uploadMutation = useMutation({
+ mutationFn: ({ file, title }: { file: File; title: string }) => {
+ const form = new FormData()
+ form.append('file', file)
+ form.append('title', title)
+ return api.post('/recordings/upload', form)
+ },
+ onSuccess: () => queryClient.invalidateQueries({ queryKey: ['recordings'] }),
+ })
+
+ const deleteMutation = useMutation({
+ mutationFn: (id: string) => api.delete(`/recordings/${id}`),
+ onSuccess: () => queryClient.invalidateQueries({ queryKey: ['recordings'] }),
+ })
+
+ return {
+ recordings: data?.items ?? [],
+ total: data?.total ?? 0,
+ isLoading,
+ upload: uploadMutation.mutateAsync,
+ isUploading: uploadMutation.isPending,
+ deleteRecording: deleteMutation.mutateAsync,
+ }
+}
diff --git a/frontend/src/hooks/useTemplates.ts b/frontend/src/hooks/useTemplates.ts
new file mode 100644
index 0000000..edc3054
--- /dev/null
+++ b/frontend/src/hooks/useTemplates.ts
@@ -0,0 +1,36 @@
+import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
+import api from '../api/client'
+import type { PromptTemplate } from '../types'
+
+export function useTemplates() {
+ const queryClient = useQueryClient()
+
+ const { data, isLoading } = useQuery({
+ queryKey: ['templates'],
+ queryFn: () => api.get('/templates').then((r) => r.data),
+ })
+
+ const createMutation = useMutation({
+ mutationFn: (data: Partial) => api.post('/templates', data),
+ onSuccess: () => queryClient.invalidateQueries({ queryKey: ['templates'] }),
+ })
+
+ const updateMutation = useMutation({
+ mutationFn: ({ id, ...data }: Partial & { id: string }) =>
+ api.put(`/templates/${id}`, data),
+ onSuccess: () => queryClient.invalidateQueries({ queryKey: ['templates'] }),
+ })
+
+ const deleteMutation = useMutation({
+ mutationFn: (id: string) => api.delete(`/templates/${id}`),
+ onSuccess: () => queryClient.invalidateQueries({ queryKey: ['templates'] }),
+ })
+
+ return {
+ templates: data ?? [],
+ isLoading,
+ createTemplate: createMutation.mutateAsync,
+ updateTemplate: updateMutation.mutateAsync,
+ deleteTemplate: deleteMutation.mutateAsync,
+ }
+}
diff --git a/frontend/src/index.css b/frontend/src/index.css
new file mode 100644
index 0000000..b5c61c9
--- /dev/null
+++ b/frontend/src/index.css
@@ -0,0 +1,3 @@
+@tailwind base;
+@tailwind components;
+@tailwind utilities;
diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx
new file mode 100644
index 0000000..964aeb4
--- /dev/null
+++ b/frontend/src/main.tsx
@@ -0,0 +1,10 @@
+import React from 'react'
+import ReactDOM from 'react-dom/client'
+import App from './App'
+import './index.css'
+
+ReactDOM.createRoot(document.getElementById('root')!).render(
+
+
+ ,
+)
diff --git a/frontend/src/pages/LoginPage.tsx b/frontend/src/pages/LoginPage.tsx
new file mode 100644
index 0000000..8fa7212
--- /dev/null
+++ b/frontend/src/pages/LoginPage.tsx
@@ -0,0 +1,50 @@
+import { FormEvent, useState } from 'react'
+import { useNavigate } from 'react-router-dom'
+import { useAuth } from '../hooks/useAuth'
+
+export default function LoginPage() {
+ const [username, setUsername] = useState('')
+ const [password, setPassword] = useState('')
+ const [error, setError] = useState('')
+ const { login } = useAuth()
+ const navigate = useNavigate()
+
+ const handleSubmit = async (e: FormEvent) => {
+ e.preventDefault()
+ setError('')
+ try {
+ await login({ username, password })
+ navigate('/')
+ } catch {
+ setError('Неверный логин или пароль')
+ }
+ }
+
+ return (
+
+ )
+}
diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts
new file mode 100644
index 0000000..17d8569
--- /dev/null
+++ b/frontend/src/types/index.ts
@@ -0,0 +1,70 @@
+export interface User {
+ id: string
+ username: string
+ email: string
+ role: 'admin' | 'user'
+ is_active: boolean
+ created_at: string
+}
+
+export interface Recording {
+ id: string
+ user_id: string
+ title: string
+ file_size: number
+ duration: number | null
+ format: string
+ source: 'web' | 'telegram' | 'lensa'
+ status: 'uploaded' | 'processing' | 'done' | 'error'
+ created_at: string
+}
+
+export interface Transcription {
+ id: string
+ recording_id: string
+ text: string
+ segments: { start: number; end: number; text: string }[]
+ language: string | null
+ whisper_model: string
+ processing_time: number | null
+ created_at: string
+}
+
+export interface Protocol {
+ id: string
+ transcription_id: string
+ template_id: string
+ content: Record
+ raw_text: string
+ llm_model: string
+ status: 'generating' | 'done' | 'error'
+ created_at: string
+ updated_at: string
+}
+
+export interface PromptTemplate {
+ id: string
+ name: string
+ description: string
+ type: 'client_survey' | 'client_intro' | 'internal' | 'custom'
+ system_prompt: string
+ user_prompt: string
+ output_schema: Record
+ is_default: boolean
+ created_at: string
+}
+
+export interface ExportFile {
+ id: string
+ protocol_id: string
+ format: 'docx' | 'pdf' | 'md'
+ file_size: number
+ created_at: string
+}
+
+export interface StorageInfo {
+ uploads_bytes: number
+ exports_bytes: number
+ total_bytes: number
+ total_gb: number
+}
diff --git a/frontend/tailwind.config.js b/frontend/tailwind.config.js
new file mode 100644
index 0000000..a72b2f1
--- /dev/null
+++ b/frontend/tailwind.config.js
@@ -0,0 +1,6 @@
+/** @type {import('tailwindcss').Config} */
+export default {
+ content: ['./index.html', './src/**/*.{js,ts,jsx,tsx}'],
+ theme: { extend: {} },
+ plugins: [],
+}
diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json
new file mode 100644
index 0000000..20fb0a0
--- /dev/null
+++ b/frontend/tsconfig.json
@@ -0,0 +1,21 @@
+{
+ "compilerOptions": {
+ "target": "ES2020",
+ "useDefineForClassFields": true,
+ "lib": ["ES2020", "DOM", "DOM.Iterable"],
+ "module": "ESNext",
+ "skipLibCheck": true,
+ "moduleResolution": "bundler",
+ "allowImportingTsExtensions": true,
+ "isolatedModules": true,
+ "moduleDetection": "force",
+ "noEmit": true,
+ "jsx": "react-jsx",
+ "strict": true,
+ "noUnusedLocals": true,
+ "noUnusedParameters": true,
+ "noFallthroughCasesInSwitch": true,
+ "forceConsistentCasingInFileNames": true
+ },
+ "include": ["src"]
+}
diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts
new file mode 100644
index 0000000..69163ad
--- /dev/null
+++ b/frontend/vite.config.ts
@@ -0,0 +1,11 @@
+import { defineConfig } from 'vite'
+import react from '@vitejs/plugin-react'
+
+export default defineConfig({
+ plugins: [react()],
+ server: {
+ proxy: {
+ '/api': 'http://localhost:8000',
+ },
+ },
+})