Initial commit: Базовая структура сайта
This commit is contained in:
17
frontend/Dockerfile
Executable file
17
frontend/Dockerfile
Executable 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
13
frontend/index.html
Executable 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
28
frontend/package.json
Executable 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
82
frontend/src/App.css
Executable 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
44
frontend/src/App.jsx
Executable 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
|
||||
|
||||
61
frontend/src/components/Layout.css
Executable file
61
frontend/src/components/Layout.css
Executable 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;
|
||||
}
|
||||
|
||||
65
frontend/src/components/Layout.jsx
Executable file
65
frontend/src/components/Layout.jsx
Executable 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
|
||||
|
||||
20
frontend/src/components/ProtectedRoute.jsx
Executable file
20
frontend/src/components/ProtectedRoute.jsx
Executable 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
|
||||
|
||||
66
frontend/src/contexts/AuthContext.jsx
Executable file
66
frontend/src/contexts/AuthContext.jsx
Executable 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
24
frontend/src/index.css
Executable 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
11
frontend/src/main.jsx
Executable 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
106
frontend/src/pages/AdminPanel.css
Executable 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
222
frontend/src/pages/AdminPanel.jsx
Executable 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
|
||||
|
||||
41
frontend/src/pages/Dashboard.css
Executable file
41
frontend/src/pages/Dashboard.css
Executable 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;
|
||||
}
|
||||
|
||||
32
frontend/src/pages/Dashboard.jsx
Executable file
32
frontend/src/pages/Dashboard.jsx
Executable 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
|
||||
|
||||
44
frontend/src/pages/LeaveCalendar.css
Executable file
44
frontend/src/pages/LeaveCalendar.css
Executable 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;
|
||||
}
|
||||
|
||||
231
frontend/src/pages/LeaveCalendar.jsx
Executable file
231
frontend/src/pages/LeaveCalendar.jsx
Executable 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
31
frontend/src/pages/Login.css
Executable 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
72
frontend/src/pages/Login.jsx
Executable 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
52
frontend/src/pages/Metrics.css
Executable 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
142
frontend/src/pages/Metrics.jsx
Executable 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
21
frontend/src/pages/Summary.css
Executable 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
142
frontend/src/pages/Summary.jsx
Executable 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
92
frontend/src/pages/Timeline.css
Executable 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
236
frontend/src/pages/Timeline.jsx
Executable 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
16
frontend/vite.config.js
Executable 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
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user