Initial commit: Базовая структура сайта

This commit is contained in:
2026-02-11 12:06:30 +05:00
parent b41f161e8f
commit d9a2ad7f15
62 changed files with 3901 additions and 0 deletions

17
frontend/Dockerfile Executable file
View File

@@ -0,0 +1,17 @@
FROM node:18-alpine
WORKDIR /app
# Копирование package.json и установка зависимостей
COPY package.json .
RUN npm install
# Копирование исходного кода
COPY . .
# Открытие порта
EXPOSE 3000
# Команда запуска
CMD ["npm", "run", "dev", "--", "--host", "0.0.0.0"]

13
frontend/index.html Executable file
View File

@@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>ManicTime Dashboard</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>

28
frontend/package.json Executable file
View File

@@ -0,0 +1,28 @@
{
"name": "manictime-dashboard-frontend",
"version": "1.0.0",
"private": true,
"dependencies": {
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-router-dom": "^6.20.0",
"axios": "^1.6.2",
"chart.js": "^4.4.0",
"react-chartjs-2": "^5.2.0",
"react-big-calendar": "^1.8.5",
"moment": "^2.29.4",
"date-fns": "^2.30.0"
},
"devDependencies": {
"@types/react": "^18.2.43",
"@types/react-dom": "^18.2.17",
"@vitejs/plugin-react": "^4.2.1",
"vite": "^5.0.8"
},
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
}
}

82
frontend/src/App.css Executable file
View File

@@ -0,0 +1,82 @@
.app {
min-height: 100vh;
}
.container {
max-width: 1200px;
margin: 0 auto;
padding: 20px;
}
.card {
background: white;
border-radius: 8px;
padding: 20px;
margin-bottom: 20px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.btn {
padding: 10px 20px;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
transition: background-color 0.3s;
}
.btn-primary {
background-color: #007bff;
color: white;
}
.btn-primary:hover {
background-color: #0056b3;
}
.btn-secondary {
background-color: #6c757d;
color: white;
}
.btn-secondary:hover {
background-color: #545b62;
}
.form-group {
margin-bottom: 15px;
}
.form-group label {
display: block;
margin-bottom: 5px;
font-weight: 500;
}
.form-group input,
.form-group select {
width: 100%;
padding: 8px 12px;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 14px;
}
.form-group input:focus,
.form-group select:focus {
outline: none;
border-color: #007bff;
}
.error {
color: #dc3545;
font-size: 14px;
margin-top: 5px;
}
.success {
color: #28a745;
font-size: 14px;
margin-top: 5px;
}

44
frontend/src/App.jsx Executable file
View File

@@ -0,0 +1,44 @@
import React from 'react'
import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-dom'
import { AuthProvider } from './contexts/AuthContext'
import Login from './pages/Login'
import Dashboard from './pages/Dashboard'
import Summary from './pages/Summary'
import Timeline from './pages/Timeline'
import Metrics from './pages/Metrics'
import LeaveCalendar from './pages/LeaveCalendar'
import AdminPanel from './pages/AdminPanel'
import ProtectedRoute from './components/ProtectedRoute'
import Layout from './components/Layout'
import './App.css'
function App() {
return (
<AuthProvider>
<Router>
<Routes>
<Route path="/login" element={<Login />} />
<Route
path="/"
element={
<ProtectedRoute>
<Layout />
</ProtectedRoute>
}
>
<Route index element={<Dashboard />} />
<Route path="summary" element={<Summary />} />
<Route path="timeline" element={<Timeline />} />
<Route path="metrics" element={<Metrics />} />
<Route path="leave" element={<LeaveCalendar />} />
<Route path="admin" element={<AdminPanel />} />
</Route>
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
</Router>
</AuthProvider>
)
}
export default App

View File

@@ -0,0 +1,61 @@
.layout {
display: flex;
flex-direction: column;
min-height: 100vh;
}
.navbar {
background-color: #2c3e50;
color: white;
padding: 15px 20px;
display: flex;
align-items: center;
justify-content: space-between;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.navbar-brand h2 {
margin: 0;
font-size: 24px;
}
.navbar-menu {
display: flex;
gap: 20px;
flex: 1;
margin-left: 40px;
}
.nav-link {
color: white;
text-decoration: none;
padding: 8px 16px;
border-radius: 4px;
transition: background-color 0.3s;
}
.nav-link:hover {
background-color: rgba(255, 255, 255, 0.1);
}
.nav-link.active {
background-color: #3498db;
}
.navbar-actions {
display: flex;
align-items: center;
gap: 15px;
}
.user-info {
font-size: 14px;
opacity: 0.9;
}
.main-content {
flex: 1;
padding: 20px;
background-color: #f5f5f5;
}

View File

@@ -0,0 +1,65 @@
import React from 'react'
import { Outlet, Link, useLocation } from 'react-router-dom'
import { useAuth } from '../contexts/AuthContext'
import './Layout.css'
const Layout = () => {
const { logout, user } = useAuth()
const location = useLocation()
const isActive = (path) => location.pathname === path
return (
<div className="layout">
<nav className="navbar">
<div className="navbar-brand">
<h2>ManicTime Dashboard</h2>
</div>
<div className="navbar-menu">
<Link
to="/summary"
className={`nav-link ${isActive('/summary') ? 'active' : ''}`}
>
Сводка
</Link>
<Link
to="/timeline"
className={`nav-link ${isActive('/timeline') ? 'active' : ''}`}
>
Хронология
</Link>
<Link
to="/metrics"
className={`nav-link ${isActive('/metrics') ? 'active' : ''}`}
>
Метрика
</Link>
<Link
to="/leave"
className={`nav-link ${isActive('/leave') ? 'active' : ''}`}
>
Отпуска/Больничные
</Link>
<Link
to="/admin"
className={`nav-link ${isActive('/admin') ? 'active' : ''}`}
>
Администрирование
</Link>
</div>
<div className="navbar-actions">
<span className="user-info">{user?.login || 'Пользователь'}</span>
<button onClick={logout} className="btn btn-secondary">
Выход
</button>
</div>
</nav>
<main className="main-content">
<Outlet />
</main>
</div>
)
}
export default Layout

View File

@@ -0,0 +1,20 @@
import React from 'react'
import { Navigate, Outlet } from 'react-router-dom'
import { useAuth } from '../contexts/AuthContext'
const ProtectedRoute = ({ children }) => {
const { isAuthenticated, loading } = useAuth()
if (loading) {
return <div>Загрузка...</div>
}
if (!isAuthenticated) {
return <Navigate to="/login" replace />
}
return children || <Outlet />
}
export default ProtectedRoute

View File

@@ -0,0 +1,66 @@
import React, { createContext, useState, useContext, useEffect } from 'react'
import axios from 'axios'
const AuthContext = createContext(null)
export const useAuth = () => {
const context = useContext(AuthContext)
if (!context) {
throw new Error('useAuth must be used within AuthProvider')
}
return context
}
export const AuthProvider = ({ children }) => {
const [user, setUser] = useState(null)
const [loading, setLoading] = useState(true)
const [token, setToken] = useState(localStorage.getItem('token'))
useEffect(() => {
if (token) {
axios.defaults.headers.common['Authorization'] = `Bearer ${token}`
// Можно добавить запрос для получения информации о пользователе
} else {
delete axios.defaults.headers.common['Authorization']
}
setLoading(false)
}, [token])
const login = async (login, password) => {
try {
const response = await axios.post('/api/v1/auth/token', {
login,
password
})
const newToken = response.data.access_token
setToken(newToken)
localStorage.setItem('token', newToken)
axios.defaults.headers.common['Authorization'] = `Bearer ${newToken}`
return { success: true }
} catch (error) {
return {
success: false,
error: error.response?.data?.detail || 'Ошибка входа'
}
}
}
const logout = () => {
setToken(null)
setUser(null)
localStorage.removeItem('token')
delete axios.defaults.headers.common['Authorization']
}
const value = {
user,
token,
login,
logout,
loading,
isAuthenticated: !!token
}
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>
}

24
frontend/src/index.css Executable file
View File

@@ -0,0 +1,24 @@
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
background-color: #f5f5f5;
}
code {
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
monospace;
}
#root {
min-height: 100vh;
}

11
frontend/src/main.jsx Executable file
View File

@@ -0,0 +1,11 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App'
import './index.css'
ReactDOM.createRoot(document.getElementById('root')).render(
<React.StrictMode>
<App />
</React.StrictMode>,
)

106
frontend/src/pages/AdminPanel.css Executable file
View File

@@ -0,0 +1,106 @@
.admin-panel {
max-width: 1400px;
margin: 0 auto;
}
.admin-panel h1 {
margin-bottom: 20px;
color: #2c3e50;
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
}
.card-header h2 {
margin: 0;
color: #2c3e50;
}
.admin-table {
width: 100%;
border-collapse: collapse;
}
.admin-table th,
.admin-table td {
padding: 12px;
text-align: left;
border-bottom: 1px solid #e0e0e0;
}
.admin-table th {
background-color: #f8f9fa;
font-weight: 600;
color: #2c3e50;
}
.admin-table tbody tr:hover {
background-color: #f8f9fa;
}
.config-description {
color: #6c757d;
margin-bottom: 20px;
line-height: 1.6;
}
.config-list {
display: flex;
flex-direction: column;
gap: 15px;
}
.config-item {
display: flex;
align-items: center;
gap: 15px;
}
.config-item label {
width: 150px;
font-weight: 500;
color: #2c3e50;
}
.config-item input {
flex: 1;
}
.modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.5);
display: flex;
justify-content: center;
align-items: center;
z-index: 1000;
}
.modal-content {
background: white;
padding: 30px;
border-radius: 8px;
width: 90%;
max-width: 500px;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
}
.modal-content h2 {
margin-bottom: 20px;
color: #2c3e50;
}
.modal-actions {
display: flex;
gap: 10px;
justify-content: flex-end;
margin-top: 20px;
}

222
frontend/src/pages/AdminPanel.jsx Executable file
View File

@@ -0,0 +1,222 @@
import React, { useState, useEffect } from 'react'
import axios from 'axios'
import './AdminPanel.css'
const AdminPanel = () => {
const [users, setUsers] = useState([])
const [config, setConfig] = useState([])
const [loading, setLoading] = useState(false)
const [error, setError] = useState('')
const [success, setSuccess] = useState('')
const [showUserModal, setShowUserModal] = useState(false)
const [newUser, setNewUser] = useState({
login: '',
password: '',
role_id: 2
})
const [roles, setRoles] = useState([
{ id: 1, name: 'Admin' },
{ id: 2, name: 'User' }
])
useEffect(() => {
fetchUsers()
fetchConfig()
}, [])
const fetchUsers = async () => {
try {
const response = await axios.get('/api/v1/admin/users')
setUsers(response.data)
} catch (err) {
setError(err.response?.data?.detail || 'Ошибка загрузки пользователей')
}
}
const fetchConfig = async () => {
try {
const response = await axios.get('/api/v1/admin/config')
setConfig(response.data)
} catch (err) {
setError(err.response?.data?.detail || 'Ошибка загрузки конфигурации')
}
}
const handleCreateUser = async () => {
if (!newUser.login || !newUser.password) {
setError('Заполните все поля')
return
}
setLoading(true)
setError('')
try {
await axios.post('/api/v1/admin/users', newUser)
setSuccess('Пользователь успешно создан')
setShowUserModal(false)
setNewUser({ login: '', password: '', role_id: 2 })
fetchUsers()
} catch (err) {
setError(err.response?.data?.detail || 'Ошибка создания пользователя')
} finally {
setLoading(false)
}
}
const handleUpdateConfig = async (key, value) => {
setLoading(true)
setError('')
try {
await axios.put('/api/v1/admin/config', { key, value })
setSuccess('Конфигурация обновлена')
fetchConfig()
} catch (err) {
setError(err.response?.data?.detail || 'Ошибка обновления конфигурации')
} finally {
setLoading(false)
}
}
return (
<div className="admin-panel">
<h1>Панель администратора</h1>
{error && (
<div className="error card" onClick={() => setError('')}>
{error}
</div>
)}
{success && (
<div className="success card" onClick={() => setSuccess('')}>
{success}
</div>
)}
<div className="card">
<div className="card-header">
<h2>Пользователи</h2>
<button
className="btn btn-primary"
onClick={() => setShowUserModal(true)}
>
Создать пользователя
</button>
</div>
<table className="admin-table">
<thead>
<tr>
<th>ID</th>
<th>Логин</th>
<th>Роль</th>
<th>Статус</th>
<th>Создан</th>
</tr>
</thead>
<tbody>
{users.map((user) => (
<tr key={user.id}>
<td>{user.id}</td>
<td>{user.login}</td>
<td>{user.role_name}</td>
<td>{user.is_active ? 'Активен' : 'Неактивен'}</td>
<td>
{new Date(user.created_at).toLocaleDateString('ru-RU')}
</td>
</tr>
))}
</tbody>
</table>
</div>
<div className="card">
<h2>Конфигурация подключения к ManicTime</h2>
<p className="config-description">
Здесь можно изменить нечувствительные параметры подключения к базе
данных ManicTime. Пароль и пользователь настраиваются только через
переменные окружения на сервере.
</p>
<div className="config-list">
{config.map((item) => (
<div key={item.key} className="config-item">
<label>{item.key.replace('manictime_', '').toUpperCase()}</label>
<input
type="text"
value={item.value || ''}
onChange={(e) =>
handleUpdateConfig(item.key, e.target.value)
}
placeholder="Не задано"
/>
</div>
))}
</div>
</div>
{showUserModal && (
<div className="modal-overlay" onClick={() => setShowUserModal(false)}>
<div className="modal-content" onClick={(e) => e.stopPropagation()}>
<h2>Создать пользователя</h2>
<div className="form-group">
<label>Логин</label>
<input
type="text"
value={newUser.login}
onChange={(e) =>
setNewUser({ ...newUser, login: e.target.value })
}
required
/>
</div>
<div className="form-group">
<label>Пароль</label>
<input
type="password"
value={newUser.password}
onChange={(e) =>
setNewUser({ ...newUser, password: e.target.value })
}
required
/>
</div>
<div className="form-group">
<label>Роль</label>
<select
value={newUser.role_id}
onChange={(e) =>
setNewUser({
...newUser,
role_id: parseInt(e.target.value)
})
}
>
{roles.map((role) => (
<option key={role.id} value={role.id}>
{role.name}
</option>
))}
</select>
</div>
<div className="modal-actions">
<button
className="btn btn-secondary"
onClick={() => setShowUserModal(false)}
>
Отмена
</button>
<button
className="btn btn-primary"
onClick={handleCreateUser}
disabled={loading}
>
{loading ? 'Создание...' : 'Создать'}
</button>
</div>
</div>
</div>
)}
</div>
)
}
export default AdminPanel

View File

@@ -0,0 +1,41 @@
.dashboard {
max-width: 1200px;
margin: 0 auto;
}
.dashboard h1 {
margin-bottom: 30px;
color: #2c3e50;
}
.dashboard-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: 20px;
}
.dashboard-card {
background: white;
padding: 30px;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
text-decoration: none;
color: inherit;
transition: transform 0.3s, box-shadow 0.3s;
}
.dashboard-card:hover {
transform: translateY(-5px);
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.15);
}
.dashboard-card h3 {
color: #3498db;
margin-bottom: 10px;
}
.dashboard-card p {
color: #7f8c8d;
line-height: 1.6;
}

View File

@@ -0,0 +1,32 @@
import React from 'react'
import { Link } from 'react-router-dom'
import './Dashboard.css'
const Dashboard = () => {
return (
<div className="dashboard">
<h1>Главная панель</h1>
<div className="dashboard-grid">
<Link to="/summary" className="dashboard-card">
<h3>Сводка</h3>
<p>Агрегированная гистограмма активности по категориям</p>
</Link>
<Link to="/timeline" className="dashboard-card">
<h3>Хронология</h3>
<p>Индивидуальные линейки активности пользователей</p>
</Link>
<Link to="/metrics" className="dashboard-card">
<h3>Метрика</h3>
<p>Числовые показатели эффективности в динамике</p>
</Link>
<Link to="/leave" className="dashboard-card">
<h3>Отпуска/Больничные</h3>
<p>Календарь отсутствий сотрудников</p>
</Link>
</div>
</div>
)
}
export default Dashboard

View File

@@ -0,0 +1,44 @@
.leave-calendar {
max-width: 1400px;
margin: 0 auto;
}
.leave-calendar h1 {
margin-bottom: 20px;
color: #2c3e50;
}
.modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.5);
display: flex;
justify-content: center;
align-items: center;
z-index: 1000;
}
.modal-content {
background: white;
padding: 30px;
border-radius: 8px;
width: 90%;
max-width: 500px;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
}
.modal-content h2 {
margin-bottom: 20px;
color: #2c3e50;
}
.modal-actions {
display: flex;
gap: 10px;
justify-content: flex-end;
margin-top: 20px;
}

View File

@@ -0,0 +1,231 @@
import React, { useState, useEffect } from 'react'
import { Calendar, momentLocalizer } from 'react-big-calendar'
import moment from 'moment'
import 'react-big-calendar/lib/css/react-big-calendar.css'
import axios from 'axios'
import './LeaveCalendar.css'
const localizer = momentLocalizer(moment)
const LeaveCalendar = () => {
const [events, setEvents] = useState([])
const [loading, setLoading] = useState(false)
const [error, setError] = useState('')
const [showModal, setShowModal] = useState(false)
const [selectedRange, setSelectedRange] = useState(null)
const [newEvent, setNewEvent] = useState({
user_id: '',
start_date: '',
end_date: '',
leave_type: 'Отпуск'
})
const [users, setUsers] = useState([])
useEffect(() => {
const currentDate = new Date()
const startDate = new Date(currentDate.getFullYear(), 0, 1)
const endDate = new Date(currentDate.getFullYear(), 11, 31)
fetchEvents(startDate, endDate)
fetchUsers()
}, [])
const fetchEvents = async (startDate, endDate) => {
setLoading(true)
setError('')
try {
const response = await axios.get('/api/v1/leave/events', {
params: {
start_date: startDate.toISOString().split('T')[0],
end_date: endDate.toISOString().split('T')[0]
}
})
const formattedEvents = response.data.map((event) => ({
id: event.id,
title: `${event.user_login || `User ${event.user_id}`} - ${event.leave_type}`,
start: new Date(event.start_date),
end: new Date(event.end_date),
resource: event
}))
setEvents(formattedEvents)
} catch (err) {
setError(err.response?.data?.detail || 'Ошибка загрузки данных')
} finally {
setLoading(false)
}
}
const fetchUsers = async () => {
try {
const response = await axios.get('/api/v1/admin/users')
setUsers(response.data)
} catch (err) {
console.error('Ошибка загрузки пользователей:', err)
}
}
const handleSelectSlot = ({ start, end }) => {
setSelectedRange({ start, end })
setNewEvent({
user_id: '',
start_date: start.toISOString().split('T')[0],
end_date: end.toISOString().split('T')[0],
leave_type: 'Отпуск'
})
setShowModal(true)
}
const handleCreateEvent = async () => {
if (!newEvent.user_id || !newEvent.start_date || !newEvent.end_date) {
setError('Заполните все поля')
return
}
try {
await axios.post('/api/v1/leave/events', {
user_id: parseInt(newEvent.user_id),
start_date: newEvent.start_date,
end_date: newEvent.end_date,
leave_type: newEvent.leave_type
})
setShowModal(false)
const startDate = new Date(newEvent.start_date)
const endDate = new Date(newEvent.end_date)
fetchEvents(
new Date(startDate.getFullYear(), 0, 1),
new Date(endDate.getFullYear(), 11, 31)
)
} catch (err) {
setError(err.response?.data?.detail || 'Ошибка создания события')
}
}
const eventStyleGetter = (event) => {
const leaveType = event.resource?.leave_type
let backgroundColor = '#3174ad'
if (leaveType === 'Больничный') {
backgroundColor = '#dc3545'
} else if (leaveType === 'Отпуск') {
backgroundColor = '#28a745'
}
return {
style: {
backgroundColor,
borderRadius: '5px',
opacity: 0.8,
color: 'white',
border: '0px',
display: 'block'
}
}
}
return (
<div className="leave-calendar">
<h1>Отпуска и больничные</h1>
{error && <div className="error card">{error}</div>}
<div className="card" style={{ height: '600px' }}>
<Calendar
localizer={localizer}
events={events}
startAccessor="start"
endAccessor="end"
onSelectSlot={handleSelectSlot}
selectable
eventPropGetter={eventStyleGetter}
culture="ru"
messages={{
next: 'Вперед',
previous: 'Назад',
today: 'Сегодня',
month: 'Месяц',
week: 'Неделя',
day: 'День',
agenda: 'Повестка дня',
date: 'Дата',
time: 'Время',
event: 'Событие',
noEventsInRange: 'Нет событий в выбранном диапазоне'
}}
/>
</div>
{showModal && (
<div className="modal-overlay" onClick={() => setShowModal(false)}>
<div className="modal-content" onClick={(e) => e.stopPropagation()}>
<h2>Добавить событие</h2>
<div className="form-group">
<label>Пользователь</label>
<select
value={newEvent.user_id}
onChange={(e) =>
setNewEvent({ ...newEvent, user_id: e.target.value })
}
required
>
<option value="">Выберите пользователя</option>
{users.map((user) => (
<option key={user.id} value={user.id}>
{user.login}
</option>
))}
</select>
</div>
<div className="form-group">
<label>Начальная дата</label>
<input
type="date"
value={newEvent.start_date}
onChange={(e) =>
setNewEvent({ ...newEvent, start_date: e.target.value })
}
required
/>
</div>
<div className="form-group">
<label>Конечная дата</label>
<input
type="date"
value={newEvent.end_date}
onChange={(e) =>
setNewEvent({ ...newEvent, end_date: e.target.value })
}
required
/>
</div>
<div className="form-group">
<label>Тип</label>
<select
value={newEvent.leave_type}
onChange={(e) =>
setNewEvent({ ...newEvent, leave_type: e.target.value })
}
>
<option value="Отпуск">Отпуск</option>
<option value="Больничный">Больничный</option>
</select>
</div>
<div className="modal-actions">
<button
className="btn btn-secondary"
onClick={() => setShowModal(false)}
>
Отмена
</button>
<button
className="btn btn-primary"
onClick={handleCreateEvent}
>
Создать
</button>
</div>
</div>
</div>
)}
</div>
)
}
export default LeaveCalendar

31
frontend/src/pages/Login.css Executable file
View File

@@ -0,0 +1,31 @@
.login-container {
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
}
.login-card {
background: white;
padding: 40px;
border-radius: 8px;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
width: 100%;
max-width: 400px;
}
.login-card h1 {
text-align: center;
color: #2c3e50;
margin-bottom: 10px;
}
.login-card h2 {
text-align: center;
color: #7f8c8d;
font-size: 18px;
font-weight: normal;
margin-bottom: 30px;
}

72
frontend/src/pages/Login.jsx Executable file
View File

@@ -0,0 +1,72 @@
import React, { useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { useAuth } from '../contexts/AuthContext'
import './Login.css'
const Login = () => {
const [login, setLogin] = useState('')
const [password, setPassword] = useState('')
const [error, setError] = useState('')
const [loading, setLoading] = useState(false)
const { login: authLogin } = useAuth()
const navigate = useNavigate()
const handleSubmit = async (e) => {
e.preventDefault()
setError('')
setLoading(true)
const result = await authLogin(login, password)
setLoading(false)
if (result.success) {
navigate('/')
} else {
setError(result.error)
}
}
return (
<div className="login-container">
<div className="login-card">
<h1>ManicTime Dashboard</h1>
<h2>Вход в систему</h2>
<form onSubmit={handleSubmit}>
<div className="form-group">
<label htmlFor="login">Логин</label>
<input
type="text"
id="login"
value={login}
onChange={(e) => setLogin(e.target.value)}
required
autoFocus
/>
</div>
<div className="form-group">
<label htmlFor="password">Пароль</label>
<input
type="password"
id="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
/>
</div>
{error && <div className="error">{error}</div>}
<button
type="submit"
className="btn btn-primary"
disabled={loading}
style={{ width: '100%', marginTop: '10px' }}
>
{loading ? 'Вход...' : 'Войти'}
</button>
</form>
</div>
</div>
)
}
export default Login

52
frontend/src/pages/Metrics.css Executable file
View File

@@ -0,0 +1,52 @@
.metrics {
max-width: 1400px;
margin: 0 auto;
}
.metrics h1 {
margin-bottom: 20px;
color: #2c3e50;
}
.metrics-controls {
display: flex;
gap: 20px;
align-items: flex-end;
}
.metrics-controls .form-group {
flex: 1;
margin-bottom: 0;
}
.table-container {
overflow-x: auto;
margin-top: 20px;
}
.metrics-table {
width: 100%;
border-collapse: collapse;
}
.metrics-table th,
.metrics-table td {
padding: 12px;
text-align: left;
border-bottom: 1px solid #e0e0e0;
}
.metrics-table th {
background-color: #f8f9fa;
font-weight: 600;
color: #2c3e50;
}
.metrics-table tbody tr:hover {
background-color: #f8f9fa;
}
.user-name {
font-weight: 500;
}

142
frontend/src/pages/Metrics.jsx Executable file
View File

@@ -0,0 +1,142 @@
import React, { useState, useEffect } from 'react'
import axios from 'axios'
import './Metrics.css'
const Metrics = () => {
const [period, setPeriod] = useState('quarter')
const [year, setYear] = useState(new Date().getFullYear())
const [data, setData] = useState(null)
const [loading, setLoading] = useState(false)
const [error, setError] = useState('')
useEffect(() => {
fetchData()
}, [period, year])
const fetchData = async () => {
setLoading(true)
setError('')
try {
const response = await axios.get('/api/v1/metrics/aggregate', {
params: { period, year }
})
setData(response.data)
} catch (err) {
setError(err.response?.data?.detail || 'Ошибка загрузки данных')
} finally {
setLoading(false)
}
}
const getPeriodLabel = (key) => {
if (period === 'week') {
return `Неделя ${key.replace('W', '')}`
} else if (period === 'month') {
const months = [
'Январь',
'Февраль',
'Март',
'Апрель',
'Май',
'Июнь',
'Июль',
'Август',
'Сентябрь',
'Октябрь',
'Ноябрь',
'Декабрь'
]
return months[parseInt(key.replace('M', '')) - 1]
} else if (period === 'quarter') {
return `Квартал ${key.replace('Q', '')}`
} else {
return 'Год'
}
}
const getAllPeriodKeys = () => {
if (!data || !data.data || data.data.length === 0) return []
const allKeys = new Set()
data.data.forEach((row) => {
Object.keys(row.data).forEach((key) => allKeys.add(key))
})
return Array.from(allKeys).sort()
}
return (
<div className="metrics">
<h1>Метрики эффективности</h1>
<div className="card">
<form
onSubmit={(e) => {
e.preventDefault()
fetchData()
}}
className="metrics-controls"
>
<div className="form-group">
<label>Период</label>
<select value={period} onChange={(e) => setPeriod(e.target.value)}>
<option value="week">Неделя</option>
<option value="month">Месяц</option>
<option value="quarter">Квартал</option>
<option value="year">Год</option>
</select>
</div>
<div className="form-group">
<label>Год</label>
<input
type="number"
value={year}
onChange={(e) => setYear(parseInt(e.target.value))}
min="2000"
max="2100"
required
/>
</div>
<button type="submit" className="btn btn-primary" disabled={loading}>
{loading ? 'Загрузка...' : 'Обновить'}
</button>
</form>
</div>
{error && <div className="error card">{error}</div>}
{data && !loading && (
<div className="card">
<h2>
Агрегированные метрики за {year} год ({period === 'week' ? 'по неделям' : period === 'month' ? 'по месяцам' : period === 'quarter' ? 'по кварталам' : 'за год'})
</h2>
<div className="table-container">
<table className="metrics-table">
<thead>
<tr>
<th>Пользователь</th>
{getAllPeriodKeys().map((key) => (
<th key={key}>{getPeriodLabel(key)}</th>
))}
</tr>
</thead>
<tbody>
{data.data.map((row, idx) => (
<tr key={idx}>
<td className="user-name">{row.user}</td>
{getAllPeriodKeys().map((key) => (
<td key={key}>
{row.data[key] ? `${row.data[key]} ч` : '-'}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
</div>
)
}
export default Metrics

21
frontend/src/pages/Summary.css Executable file
View File

@@ -0,0 +1,21 @@
.summary {
max-width: 1200px;
margin: 0 auto;
}
.summary h1 {
margin-bottom: 20px;
color: #2c3e50;
}
.date-range-form {
display: flex;
gap: 20px;
align-items: flex-end;
}
.date-range-form .form-group {
flex: 1;
margin-bottom: 0;
}

142
frontend/src/pages/Summary.jsx Executable file
View File

@@ -0,0 +1,142 @@
import React, { useState, useEffect } from 'react'
import { Bar } from 'react-chartjs-2'
import {
Chart as ChartJS,
CategoryScale,
LinearScale,
BarElement,
Title,
Tooltip,
Legend
} from 'chart.js'
import axios from 'axios'
import './Summary.css'
ChartJS.register(
CategoryScale,
LinearScale,
BarElement,
Title,
Tooltip,
Legend
)
const Summary = () => {
const [startDate, setStartDate] = useState(
new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString().split('T')[0]
)
const [endDate, setEndDate] = useState(
new Date().toISOString().split('T')[0]
)
const [data, setData] = useState(null)
const [loading, setLoading] = useState(false)
const [error, setError] = useState('')
const fetchData = async () => {
setLoading(true)
setError('')
try {
const response = await axios.get('/api/v1/summary/histogram', {
params: { start_date: startDate, end_date: endDate }
})
setData(response.data)
} catch (err) {
setError(err.response?.data?.detail || 'Ошибка загрузки данных')
} finally {
setLoading(false)
}
}
useEffect(() => {
fetchData()
}, [])
const handleSubmit = (e) => {
e.preventDefault()
fetchData()
}
const chartData = data
? {
labels: data.labels,
datasets: data.datasets.map((dataset) => ({
label: dataset.label,
data: dataset.data.map((seconds) => seconds / 3600), // Конвертация в часы
backgroundColor: dataset.color,
borderColor: dataset.color,
borderWidth: 1
}))
}
: null
const chartOptions = {
responsive: true,
plugins: {
legend: {
position: 'top'
},
title: {
display: true,
text: 'Активность по дням (часы)'
},
tooltip: {
callbacks: {
label: function (context) {
return `${context.dataset.label}: ${context.parsed.y.toFixed(2)} ч`
}
}
}
},
scales: {
y: {
beginAtZero: true,
title: {
display: true,
text: 'Часы'
}
}
}
}
return (
<div className="summary">
<h1>Сводка активности</h1>
<div className="card">
<form onSubmit={handleSubmit} className="date-range-form">
<div className="form-group">
<label>Начальная дата</label>
<input
type="date"
value={startDate}
onChange={(e) => setStartDate(e.target.value)}
required
/>
</div>
<div className="form-group">
<label>Конечная дата</label>
<input
type="date"
value={endDate}
onChange={(e) => setEndDate(e.target.value)}
required
/>
</div>
<button type="submit" className="btn btn-primary" disabled={loading}>
{loading ? 'Загрузка...' : 'Обновить'}
</button>
</form>
</div>
{error && <div className="error card">{error}</div>}
{data && !loading && (
<div className="card">
<Bar data={chartData} options={chartOptions} />
</div>
)}
</div>
)
}
export default Summary

92
frontend/src/pages/Timeline.css Executable file
View File

@@ -0,0 +1,92 @@
.timeline {
max-width: 1400px;
margin: 0 auto;
}
.timeline h1 {
margin-bottom: 20px;
color: #2c3e50;
}
.user-checklist {
display: flex;
flex-wrap: wrap;
gap: 15px;
margin-top: 10px;
}
.checkbox-label {
display: flex;
align-items: center;
gap: 8px;
cursor: pointer;
}
.checkbox-label input[type='checkbox'] {
width: auto;
cursor: pointer;
}
.timeline-container {
margin-top: 20px;
}
.timeline-row {
display: flex;
margin-bottom: 15px;
align-items: center;
}
.timeline-label {
width: 200px;
font-weight: 500;
padding-right: 15px;
flex-shrink: 0;
}
.timeline-ruler {
flex: 1;
height: 30px;
background-color: #f0f0f0;
position: relative;
border-radius: 4px;
overflow: hidden;
}
.timeline-segment {
position: absolute;
height: 100%;
border-radius: 2px;
cursor: pointer;
transition: opacity 0.3s;
}
.timeline-segment:hover {
opacity: 0.8;
}
.timeline-legend {
display: flex;
gap: 20px;
margin-top: 20px;
padding-top: 20px;
border-top: 1px solid #e0e0e0;
}
.legend-item {
display: flex;
align-items: center;
gap: 8px;
}
.legend-color {
width: 20px;
height: 20px;
border-radius: 4px;
}
.text-muted {
color: #6c757d;
font-style: italic;
}

236
frontend/src/pages/Timeline.jsx Executable file
View File

@@ -0,0 +1,236 @@
import React, { useState, useEffect } from 'react'
import axios from 'axios'
import './Timeline.css'
const Timeline = () => {
const [selectedDate, setSelectedDate] = useState(
new Date().toISOString().split('T')[0]
)
const [users, setUsers] = useState([])
const [selectedUserIds, setSelectedUserIds] = useState([])
const [activities, setActivities] = useState([])
const [loading, setLoading] = useState(false)
const [error, setError] = useState('')
useEffect(() => {
fetchUsers()
}, [])
useEffect(() => {
if (selectedUserIds.length > 0) {
fetchActivities()
} else {
setActivities([])
}
}, [selectedDate, selectedUserIds])
const fetchUsers = async () => {
try {
// В реальном приложении нужно получить список пользователей из ManicTime
// Пока используем заглушку
const response = await axios.get('/api/v1/timeline/user-activity', {
params: { date: selectedDate, user_ids: [1] }
})
// Получаем пользователей из ответа
if (response.data.activities) {
const uniqueUsers = response.data.activities.map((a) => ({
id: a.user_id,
name: a.display_name
}))
setUsers(uniqueUsers)
}
} catch (err) {
console.error('Ошибка загрузки пользователей:', err)
}
}
const fetchActivities = async () => {
setLoading(true)
setError('')
try {
const response = await axios.get('/api/v1/timeline/user-activity', {
params: {
date: selectedDate,
user_ids: selectedUserIds
}
})
setActivities(response.data.activities || [])
} catch (err) {
setError(err.response?.data?.detail || 'Ошибка загрузки данных')
} finally {
setLoading(false)
}
}
const toggleUser = (userId) => {
setSelectedUserIds((prev) =>
prev.includes(userId)
? prev.filter((id) => id !== userId)
: [...prev, userId]
)
}
const getSegmentColor = (type) => {
switch (type) {
case 'Active':
return '#28a745'
case 'Away':
return '#dc3545'
case 'Session Locked':
case 'Power Off':
return '#ffc107'
case 'Productive':
return '#fd7e14'
default:
return '#6c757d'
}
}
const formatTime = (isoString) => {
return new Date(isoString).toLocaleTimeString('ru-RU', {
hour: '2-digit',
minute: '2-digit'
})
}
const calculatePosition = (start, dayStart, dayEnd) => {
const startTime = new Date(start).getTime()
const dayStartTime = new Date(dayStart).getTime()
const dayEndTime = new Date(dayEnd).getTime()
const totalDuration = dayEndTime - dayStartTime
const position = ((startTime - dayStartTime) / totalDuration) * 100
return Math.max(0, Math.min(100, position))
}
const calculateWidth = (start, end, dayStart, dayEnd) => {
const startTime = new Date(start).getTime()
const endTime = new Date(end).getTime()
const dayStartTime = new Date(dayStart).getTime()
const dayEndTime = new Date(dayEnd).getTime()
const totalDuration = dayEndTime - dayStartTime
const segmentDuration = endTime - startTime
const width = (segmentDuration / totalDuration) * 100
return Math.max(1, Math.min(100, width))
}
const dayStart = new Date(`${selectedDate}T00:00:00`)
const dayEnd = new Date(`${selectedDate}T23:59:59`)
return (
<div className="timeline">
<h1>Хронология активности</h1>
<div className="card">
<div className="form-group">
<label>Дата</label>
<input
type="date"
value={selectedDate}
onChange={(e) => setSelectedDate(e.target.value)}
required
/>
</div>
<div className="form-group">
<label>Пользователи</label>
<div className="user-checklist">
{users.map((user) => (
<label key={user.id} className="checkbox-label">
<input
type="checkbox"
checked={selectedUserIds.includes(user.id)}
onChange={() => toggleUser(user.id)}
/>
{user.name}
</label>
))}
{users.length === 0 && (
<p className="text-muted">
Выберите дату и загрузите данные для отображения пользователей
</p>
)}
</div>
</div>
</div>
{error && <div className="error card">{error}</div>}
{loading && <div className="card">Загрузка...</div>}
{activities.length > 0 && (
<div className="card">
<h2>Активность за {selectedDate}</h2>
<div className="timeline-container">
{activities.map((activity) => (
<div key={activity.user_id} className="timeline-row">
<div className="timeline-label">{activity.display_name}</div>
<div className="timeline-ruler">
{activity.segments.map((segment, idx) => {
const left = calculatePosition(
segment.start,
dayStart.toISOString(),
dayEnd.toISOString()
)
const width = calculateWidth(
segment.start,
segment.end,
dayStart.toISOString(),
dayEnd.toISOString()
)
return (
<div
key={idx}
className="timeline-segment"
style={{
left: `${left}%`,
width: `${width}%`,
backgroundColor: getSegmentColor(segment.type),
title: `${segment.type}: ${formatTime(
segment.start
)} - ${formatTime(segment.end)}`
}}
/>
)
})}
</div>
</div>
))}
</div>
<div className="timeline-legend">
<div className="legend-item">
<span
className="legend-color"
style={{ backgroundColor: '#28a745' }}
/>
Активный
</div>
<div className="legend-item">
<span
className="legend-color"
style={{ backgroundColor: '#dc3545' }}
/>
Неактивный
</div>
<div className="legend-item">
<span
className="legend-color"
style={{ backgroundColor: '#ffc107' }}
/>
Не у ПК
</div>
<div className="legend-item">
<span
className="legend-color"
style={{ backgroundColor: '#fd7e14' }}
/>
Продуктивность
</div>
</div>
</div>
)}
</div>
)
}
export default Timeline

16
frontend/vite.config.js Executable file
View File

@@ -0,0 +1,16 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
server: {
port: 3000,
proxy: {
'/api': {
target: 'http://localhost:8000',
changeOrigin: true
}
}
}
})