first commit
This commit is contained in:
1
frontend/dist/assets/index-Dd05-Q9W.css
vendored
Normal file
1
frontend/dist/assets/index-Dd05-Q9W.css
vendored
Normal file
File diff suppressed because one or more lines are too long
49
frontend/dist/assets/index-Ou6fj0Qi.js
vendored
Normal file
49
frontend/dist/assets/index-Ou6fj0Qi.js
vendored
Normal file
File diff suppressed because one or more lines are too long
14
frontend/dist/index.html
vendored
Normal file
14
frontend/dist/index.html
vendored
Normal file
@@ -0,0 +1,14 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>TimeTracker</title>
|
||||
<script type="module" crossorigin src="/assets/index-Ou6fj0Qi.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-Dd05-Q9W.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
</body>
|
||||
</html>
|
||||
1
frontend/dist/vite.svg
vendored
Normal file
1
frontend/dist/vite.svg
vendored
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFBD4F"></stop><stop offset="100%" stop-color="#FF980E"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 1.4 KiB |
13
frontend/index.html
Normal file
13
frontend/index.html
Normal file
@@ -0,0 +1,13 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>TimeTracker</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
27
frontend/package.json
Normal file
27
frontend/package.json
Normal file
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"name": "timetracker-frontend",
|
||||
"private": true,
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tanstack/react-query": "^5.64.2",
|
||||
"date-fns": "^4.1.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^19.0.7",
|
||||
"@types/react-dom": "^19.0.3",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"postcss": "^8.5.1",
|
||||
"tailwindcss": "^3.4.17",
|
||||
"typescript": "^5.7.3",
|
||||
"vite": "^6.0.7"
|
||||
}
|
||||
}
|
||||
6
frontend/postcss.config.js
Normal file
6
frontend/postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
1
frontend/public/vite.svg
Normal file
1
frontend/public/vite.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFBD4F"></stop><stop offset="100%" stop-color="#FF980E"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 1.4 KiB |
23
frontend/src/App.tsx
Normal file
23
frontend/src/App.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import { useAuth } from './hooks/useAuth';
|
||||
import { LoginForm } from './components/LoginForm';
|
||||
import { Dashboard } from './components/Dashboard';
|
||||
|
||||
function App() {
|
||||
const { user, isLoading, isAuthenticated, login, register, logout } = useAuth();
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-50">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!isAuthenticated || !user) {
|
||||
return <LoginForm onLogin={login} onRegister={register} />;
|
||||
}
|
||||
|
||||
return <Dashboard username={user.username} onLogout={logout} />;
|
||||
}
|
||||
|
||||
export default App;
|
||||
69
frontend/src/api/client.ts
Normal file
69
frontend/src/api/client.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
const API_BASE = '/api';
|
||||
|
||||
function getToken(): string | null {
|
||||
return localStorage.getItem('token');
|
||||
}
|
||||
|
||||
export function setToken(token: string): void {
|
||||
localStorage.setItem('token', token);
|
||||
}
|
||||
|
||||
export function clearToken(): void {
|
||||
localStorage.removeItem('token');
|
||||
}
|
||||
|
||||
export async function apiRequest<T>(
|
||||
endpoint: string,
|
||||
options: RequestInit = {}
|
||||
): Promise<T> {
|
||||
const token = getToken();
|
||||
const hasBody = options.body !== undefined;
|
||||
const headers: HeadersInit = {
|
||||
...(hasBody && { 'Content-Type': 'application/json' }),
|
||||
...(token && { Authorization: `Bearer ${token}` }),
|
||||
...options.headers,
|
||||
};
|
||||
|
||||
const response = await fetch(`${API_BASE}${endpoint}`, {
|
||||
...options,
|
||||
headers,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json().catch(() => ({ error: 'Request failed' }));
|
||||
throw new Error(error.error || 'Request failed');
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
export async function downloadReport(
|
||||
startDate: string,
|
||||
endDate: string,
|
||||
format: 'pdf' | 'excel' | 'csv'
|
||||
): Promise<void> {
|
||||
const token = getToken();
|
||||
const response = await fetch(`${API_BASE}/reports/generate`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(token && { Authorization: `Bearer ${token}` }),
|
||||
},
|
||||
body: JSON.stringify({ startDate, endDate, format }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to generate report');
|
||||
}
|
||||
|
||||
const blob = await response.blob();
|
||||
const extension = format === 'excel' ? 'xlsx' : format;
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `report_${startDate}_${endDate}.${extension}`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
window.URL.revokeObjectURL(url);
|
||||
document.body.removeChild(a);
|
||||
}
|
||||
146
frontend/src/components/AddLogModal.tsx
Normal file
146
frontend/src/components/AddLogModal.tsx
Normal file
@@ -0,0 +1,146 @@
|
||||
import { useState } from 'react';
|
||||
import { format } from 'date-fns';
|
||||
|
||||
interface Props {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onSave: (data: { date: string; time: string; description: string }) => Promise<unknown>;
|
||||
}
|
||||
|
||||
export function AddLogModal({ isOpen, onClose, onSave }: Props) {
|
||||
const [date, setDate] = useState(format(new Date(), 'yyyy-MM-dd'));
|
||||
const [time, setTime] = useState('8');
|
||||
const [description, setDescription] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
await onSave({ date, time, description });
|
||||
setTime('8');
|
||||
setDescription('');
|
||||
onClose();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Ошибка');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const setQuickTime = (value: string) => {
|
||||
setTime(value);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||||
<div className="bg-white rounded-lg p-6 w-full max-w-md mx-4">
|
||||
<h2 className="text-xl font-semibold mb-4">Новая запись</h2>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Дата
|
||||
</label>
|
||||
<input
|
||||
type="date"
|
||||
value={date}
|
||||
onChange={(e) => setDate(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Часы
|
||||
</label>
|
||||
<div className="flex gap-2 mb-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setQuickTime('8')}
|
||||
className={`px-3 py-1 rounded-md text-sm ${
|
||||
time === '8'
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
|
||||
}`}
|
||||
>
|
||||
8 ч
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setQuickTime('4')}
|
||||
className={`px-3 py-1 rounded-md text-sm ${
|
||||
time === '4'
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
|
||||
}`}
|
||||
>
|
||||
4 ч
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setQuickTime('1')}
|
||||
className={`px-3 py-1 rounded-md text-sm ${
|
||||
time === '1'
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
|
||||
}`}
|
||||
>
|
||||
1 ч
|
||||
</button>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
value={time}
|
||||
onChange={(e) => setTime(e.target.value)}
|
||||
placeholder="8, 8:30, 8,5"
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
required
|
||||
/>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
Форматы: 8, 8:30, 8,5, 8.5
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Описание
|
||||
</label>
|
||||
<textarea
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 resize-none"
|
||||
rows={3}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && <p className="text-red-500 text-sm">{error}</p>}
|
||||
|
||||
<div className="flex gap-3 pt-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="flex-1 py-2 px-4 border border-gray-300 text-gray-700 rounded-md hover:bg-gray-50"
|
||||
>
|
||||
Отмена
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isLoading}
|
||||
className="flex-1 py-2 px-4 bg-blue-600 text-white rounded-md hover:bg-blue-700 disabled:opacity-50"
|
||||
>
|
||||
{isLoading ? 'Сохранение...' : 'Сохранить'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
133
frontend/src/components/Dashboard.tsx
Normal file
133
frontend/src/components/Dashboard.tsx
Normal file
@@ -0,0 +1,133 @@
|
||||
import { useState } from 'react';
|
||||
import { format, startOfMonth, endOfMonth, addMonths, subMonths } from 'date-fns';
|
||||
import { ru } from 'date-fns/locale';
|
||||
import { useLogs } from '../hooks/useLogs';
|
||||
import { LogList } from './LogList';
|
||||
import { AddLogModal } from './AddLogModal';
|
||||
import { ReportModal } from './ReportModal';
|
||||
|
||||
interface Props {
|
||||
username: string;
|
||||
onLogout: () => void;
|
||||
}
|
||||
|
||||
export function Dashboard({ username, onLogout }: Props) {
|
||||
const [currentDate, setCurrentDate] = useState(new Date());
|
||||
const [isAddModalOpen, setIsAddModalOpen] = useState(false);
|
||||
const [isReportModalOpen, setIsReportModalOpen] = useState(false);
|
||||
|
||||
const startDate = format(startOfMonth(currentDate), 'yyyy-MM-dd');
|
||||
const endDate = format(endOfMonth(currentDate), 'yyyy-MM-dd');
|
||||
|
||||
const { logs, isLoading, createLog, deleteLog } = useLogs(startDate, endDate);
|
||||
|
||||
const totalMinutes = logs.reduce((sum, log) => sum + log.minutes, 0);
|
||||
const totalHours = Math.floor(totalMinutes / 60);
|
||||
const totalMins = totalMinutes % 60;
|
||||
|
||||
const handlePrevMonth = () => setCurrentDate(subMonths(currentDate, 1));
|
||||
const handleNextMonth = () => setCurrentDate(addMonths(currentDate, 1));
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
if (window.confirm('Удалить эту запись?')) {
|
||||
await deleteLog(id);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
{/* Header */}
|
||||
<header className="bg-white shadow-sm">
|
||||
<div className="max-w-4xl mx-auto px-4 py-4 flex items-center justify-between">
|
||||
<h1 className="text-xl font-bold text-gray-800">TimeTracker</h1>
|
||||
<div className="flex items-center gap-4">
|
||||
<span className="text-gray-600 text-sm">{username}</span>
|
||||
<button
|
||||
onClick={onLogout}
|
||||
className="text-gray-500 hover:text-gray-700 text-sm"
|
||||
>
|
||||
Выйти
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="max-w-4xl mx-auto px-4 py-6">
|
||||
{/* Month navigation */}
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<button
|
||||
onClick={handlePrevMonth}
|
||||
className="p-2 hover:bg-gray-100 rounded-lg transition-colors"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5 text-gray-600" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fillRule="evenodd" d="M12.707 5.293a1 1 0 010 1.414L9.414 10l3.293 3.293a1 1 0 01-1.414 1.414l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 0z" clipRule="evenodd" />
|
||||
</svg>
|
||||
</button>
|
||||
<h2 className="text-lg font-semibold text-gray-700 capitalize">
|
||||
{format(currentDate, 'LLLL yyyy', { locale: ru })}
|
||||
</h2>
|
||||
<button
|
||||
onClick={handleNextMonth}
|
||||
className="p-2 hover:bg-gray-100 rounded-lg transition-colors"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5 text-gray-600" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fillRule="evenodd" d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" clipRule="evenodd" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="bg-white rounded-lg shadow-sm border p-4 mb-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-gray-500">Всего за месяц</p>
|
||||
<p className="text-2xl font-bold text-blue-600">
|
||||
{totalHours}:{String(totalMins).padStart(2, '0')}
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="text-sm text-gray-500">Записей</p>
|
||||
<p className="text-2xl font-bold text-gray-700">{logs.length}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Logs */}
|
||||
<LogList logs={logs} onDelete={handleDelete} isLoading={isLoading} />
|
||||
</main>
|
||||
|
||||
{/* Floating buttons */}
|
||||
<div className="fixed bottom-6 right-6 flex flex-col gap-3">
|
||||
<button
|
||||
onClick={() => setIsReportModalOpen(true)}
|
||||
className="w-14 h-14 bg-green-600 text-white rounded-full shadow-lg hover:bg-green-700 transition-colors flex items-center justify-center"
|
||||
title="Создать отчёт"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setIsAddModalOpen(true)}
|
||||
className="w-14 h-14 bg-blue-600 text-white rounded-full shadow-lg hover:bg-blue-700 transition-colors flex items-center justify-center"
|
||||
title="Добавить запись"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-8 w-8" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Modals */}
|
||||
<AddLogModal
|
||||
isOpen={isAddModalOpen}
|
||||
onClose={() => setIsAddModalOpen(false)}
|
||||
onSave={createLog}
|
||||
/>
|
||||
<ReportModal
|
||||
isOpen={isReportModalOpen}
|
||||
onClose={() => setIsReportModalOpen(false)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
93
frontend/src/components/LogList.tsx
Normal file
93
frontend/src/components/LogList.tsx
Normal file
@@ -0,0 +1,93 @@
|
||||
import { format, parseISO } from 'date-fns';
|
||||
import { ru } from 'date-fns/locale';
|
||||
import type { TimeLog } from '../hooks/useLogs';
|
||||
|
||||
interface Props {
|
||||
logs: TimeLog[];
|
||||
onDelete: (id: string) => void;
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
function formatTime(hours: number, mins: number): string {
|
||||
if (mins === 0) {
|
||||
return `${hours} ч`;
|
||||
}
|
||||
return `${hours}:${String(mins).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
export function LogList({ logs, onDelete, isLoading }: Props) {
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex justify-center py-8">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (logs.length === 0) {
|
||||
return (
|
||||
<div className="text-center py-8 text-gray-500">
|
||||
Нет записей за этот период
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Group by date
|
||||
const grouped = logs.reduce((acc, log) => {
|
||||
const dateKey = format(parseISO(log.date), 'yyyy-MM-dd');
|
||||
if (!acc[dateKey]) {
|
||||
acc[dateKey] = [];
|
||||
}
|
||||
acc[dateKey].push(log);
|
||||
return acc;
|
||||
}, {} as Record<string, TimeLog[]>);
|
||||
|
||||
const sortedDates = Object.keys(grouped).sort((a, b) => b.localeCompare(a));
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{sortedDates.map((dateKey) => {
|
||||
const dateLogs = grouped[dateKey];
|
||||
const totalMinutes = dateLogs.reduce((sum, log) => sum + log.minutes, 0);
|
||||
const totalHours = Math.floor(totalMinutes / 60);
|
||||
const totalMins = totalMinutes % 60;
|
||||
|
||||
return (
|
||||
<div key={dateKey} className="bg-white rounded-lg shadow-sm border">
|
||||
<div className="px-4 py-3 bg-gray-50 border-b flex justify-between items-center rounded-t-lg">
|
||||
<span className="font-medium text-gray-700">
|
||||
{format(parseISO(dateKey), 'd MMMM yyyy', { locale: ru })}
|
||||
</span>
|
||||
<span className="text-sm text-gray-500">
|
||||
Всего: {formatTime(totalHours, totalMins)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="divide-y">
|
||||
{dateLogs.map((log) => (
|
||||
<div key={log.id} className="px-4 py-3 flex items-start justify-between gap-4">
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-gray-800 break-words">{log.description}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 flex-shrink-0">
|
||||
<span className="text-blue-600 font-medium whitespace-nowrap">
|
||||
{formatTime(log.hours, log.mins)}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => onDelete(log.id)}
|
||||
className="text-gray-400 hover:text-red-500 transition-colors"
|
||||
title="Удалить"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fillRule="evenodd" d="M9 2a1 1 0 00-.894.553L7.382 4H4a1 1 0 000 2v10a2 2 0 002 2h8a2 2 0 002-2V6a1 1 0 100-2h-3.382l-.724-1.447A1 1 0 0011 2H9zM7 8a1 1 0 012 0v6a1 1 0 11-2 0V8zm5-1a1 1 0 00-1 1v6a1 1 0 102 0V8a1 1 0 00-1-1z" clipRule="evenodd" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
97
frontend/src/components/LoginForm.tsx
Normal file
97
frontend/src/components/LoginForm.tsx
Normal file
@@ -0,0 +1,97 @@
|
||||
import { useState } from 'react';
|
||||
|
||||
interface Props {
|
||||
onLogin: (username: string, password: string) => Promise<void>;
|
||||
onRegister: (username: string, password: string) => Promise<void>;
|
||||
}
|
||||
|
||||
export function LoginForm({ onLogin, onRegister }: Props) {
|
||||
const [isRegister, setIsRegister] = useState(false);
|
||||
const [username, setUsername] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
if (isRegister) {
|
||||
await onRegister(username, password);
|
||||
} else {
|
||||
await onLogin(username, password);
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Ошибка');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-50">
|
||||
<div className="max-w-md w-full p-8 bg-white rounded-lg shadow-md">
|
||||
<h1 className="text-2xl font-bold text-center mb-6 text-gray-800">
|
||||
TimeTracker
|
||||
</h1>
|
||||
<h2 className="text-lg text-center mb-6 text-gray-600">
|
||||
{isRegister ? 'Регистрация' : 'Вход'}
|
||||
</h2>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Логин
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
required
|
||||
minLength={3}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Пароль
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
required
|
||||
minLength={6}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<p className="text-red-500 text-sm">{error}</p>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isLoading}
|
||||
className="w-full py-2 px-4 bg-blue-600 text-white rounded-md hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:opacity-50"
|
||||
>
|
||||
{isLoading ? 'Загрузка...' : isRegister ? 'Зарегистрироваться' : 'Войти'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<p className="mt-4 text-center text-sm text-gray-600">
|
||||
{isRegister ? 'Уже есть аккаунт?' : 'Нет аккаунта?'}{' '}
|
||||
<button
|
||||
onClick={() => setIsRegister(!isRegister)}
|
||||
className="text-blue-600 hover:underline"
|
||||
>
|
||||
{isRegister ? 'Войти' : 'Зарегистрироваться'}
|
||||
</button>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
111
frontend/src/components/ReportModal.tsx
Normal file
111
frontend/src/components/ReportModal.tsx
Normal file
@@ -0,0 +1,111 @@
|
||||
import { useState } from 'react';
|
||||
import { format, startOfMonth, endOfMonth } from 'date-fns';
|
||||
import { downloadReport } from '../api/client';
|
||||
|
||||
interface Props {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function ReportModal({ isOpen, onClose }: Props) {
|
||||
const now = new Date();
|
||||
const [startDate, setStartDate] = useState(format(startOfMonth(now), 'yyyy-MM-dd'));
|
||||
const [endDate, setEndDate] = useState(format(endOfMonth(now), 'yyyy-MM-dd'));
|
||||
const [format_, setFormat] = useState<'pdf' | 'excel' | 'csv'>('excel');
|
||||
const [error, setError] = useState('');
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
await downloadReport(startDate, endDate, format_);
|
||||
onClose();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Ошибка генерации отчёта');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||||
<div className="bg-white rounded-lg p-6 w-full max-w-md mx-4">
|
||||
<h2 className="text-xl font-semibold mb-4">Создать отчёт</h2>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Начало периода
|
||||
</label>
|
||||
<input
|
||||
type="date"
|
||||
value={startDate}
|
||||
onChange={(e) => setStartDate(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Конец периода
|
||||
</label>
|
||||
<input
|
||||
type="date"
|
||||
value={endDate}
|
||||
onChange={(e) => setEndDate(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Формат
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
{(['excel', 'pdf', 'csv'] as const).map((f) => (
|
||||
<button
|
||||
key={f}
|
||||
type="button"
|
||||
onClick={() => setFormat(f)}
|
||||
className={`flex-1 py-2 px-3 rounded-md text-sm font-medium ${
|
||||
format_ === f
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
|
||||
}`}
|
||||
>
|
||||
{f.toUpperCase()}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && <p className="text-red-500 text-sm">{error}</p>}
|
||||
|
||||
<div className="flex gap-3 pt-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="flex-1 py-2 px-4 border border-gray-300 text-gray-700 rounded-md hover:bg-gray-50"
|
||||
>
|
||||
Отмена
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isLoading}
|
||||
className="flex-1 py-2 px-4 bg-green-600 text-white rounded-md hover:bg-green-700 disabled:opacity-50"
|
||||
>
|
||||
{isLoading ? 'Генерация...' : 'Скачать'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
77
frontend/src/hooks/useAuth.ts
Normal file
77
frontend/src/hooks/useAuth.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { apiRequest, setToken, clearToken } from '../api/client';
|
||||
|
||||
interface User {
|
||||
id: string;
|
||||
username: string;
|
||||
}
|
||||
|
||||
interface AuthState {
|
||||
user: User | null;
|
||||
isLoading: boolean;
|
||||
isAuthenticated: boolean;
|
||||
}
|
||||
|
||||
export function useAuth() {
|
||||
const [state, setState] = useState<AuthState>({
|
||||
user: null,
|
||||
isLoading: true,
|
||||
isAuthenticated: false,
|
||||
});
|
||||
|
||||
const checkAuth = useCallback(async () => {
|
||||
const token = localStorage.getItem('token');
|
||||
if (!token) {
|
||||
setState({ user: null, isLoading: false, isAuthenticated: false });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const user = await apiRequest<User>('/auth/me');
|
||||
setState({ user, isLoading: false, isAuthenticated: true });
|
||||
} catch {
|
||||
clearToken();
|
||||
setState({ user: null, isLoading: false, isAuthenticated: false });
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
checkAuth();
|
||||
}, [checkAuth]);
|
||||
|
||||
const login = async (username: string, password: string) => {
|
||||
const { token, user } = await apiRequest<{ token: string; user: User }>(
|
||||
'/auth/login',
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ username, password }),
|
||||
}
|
||||
);
|
||||
setToken(token);
|
||||
setState({ user, isLoading: false, isAuthenticated: true });
|
||||
};
|
||||
|
||||
const register = async (username: string, password: string) => {
|
||||
const { token, user } = await apiRequest<{ token: string; user: User }>(
|
||||
'/auth/register',
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ username, password }),
|
||||
}
|
||||
);
|
||||
setToken(token);
|
||||
setState({ user, isLoading: false, isAuthenticated: true });
|
||||
};
|
||||
|
||||
const logout = () => {
|
||||
clearToken();
|
||||
setState({ user: null, isLoading: false, isAuthenticated: false });
|
||||
};
|
||||
|
||||
return {
|
||||
...state,
|
||||
login,
|
||||
register,
|
||||
logout,
|
||||
};
|
||||
}
|
||||
82
frontend/src/hooks/useLogs.ts
Normal file
82
frontend/src/hooks/useLogs.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { apiRequest } from '../api/client';
|
||||
|
||||
export interface TimeLog {
|
||||
id: string;
|
||||
date: string;
|
||||
minutes: number;
|
||||
hours: number;
|
||||
mins: number;
|
||||
description: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
interface CreateLogData {
|
||||
date: string;
|
||||
time: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
interface UpdateLogData {
|
||||
date?: string;
|
||||
time?: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export function useLogs(startDate?: string, endDate?: string) {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: ['logs', startDate, endDate],
|
||||
queryFn: async () => {
|
||||
const params = new URLSearchParams();
|
||||
if (startDate) params.append('startDate', startDate);
|
||||
if (endDate) params.append('endDate', endDate);
|
||||
const queryString = params.toString();
|
||||
return apiRequest<TimeLog[]>(`/logs${queryString ? `?${queryString}` : ''}`);
|
||||
},
|
||||
});
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: (data: CreateLogData) =>
|
||||
apiRequest<TimeLog>('/logs', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
}),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['logs'] });
|
||||
},
|
||||
});
|
||||
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: ({ id, data }: { id: string; data: UpdateLogData }) =>
|
||||
apiRequest<TimeLog>(`/logs/${id}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(data),
|
||||
}),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['logs'] });
|
||||
},
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (id: string) =>
|
||||
apiRequest<{ success: boolean }>(`/logs/${id}`, {
|
||||
method: 'DELETE',
|
||||
}),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['logs'] });
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
logs: query.data || [],
|
||||
isLoading: query.isLoading,
|
||||
error: query.error,
|
||||
createLog: createMutation.mutateAsync,
|
||||
updateLog: updateMutation.mutateAsync,
|
||||
deleteLog: deleteMutation.mutateAsync,
|
||||
isCreating: createMutation.isPending,
|
||||
};
|
||||
}
|
||||
8
frontend/src/index.css
Normal file
8
frontend/src/index.css
Normal file
@@ -0,0 +1,8 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen,
|
||||
Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif;
|
||||
}
|
||||
15
frontend/src/main.tsx
Normal file
15
frontend/src/main.tsx
Normal file
@@ -0,0 +1,15 @@
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import App from './App';
|
||||
import './index.css';
|
||||
|
||||
const queryClient = new QueryClient();
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<App />
|
||||
</QueryClientProvider>
|
||||
</React.StrictMode>
|
||||
);
|
||||
1
frontend/src/vite-env.d.ts
vendored
Normal file
1
frontend/src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
8
frontend/tailwind.config.js
Normal file
8
frontend/tailwind.config.js
Normal file
@@ -0,0 +1,8 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
content: ['./index.html', './src/**/*.{js,ts,jsx,tsx}'],
|
||||
theme: {
|
||||
extend: {},
|
||||
},
|
||||
plugins: [],
|
||||
};
|
||||
20
frontend/tsconfig.json
Normal file
20
frontend/tsconfig.json
Normal file
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
1
frontend/tsconfig.tsbuildinfo
Normal file
1
frontend/tsconfig.tsbuildinfo
Normal file
@@ -0,0 +1 @@
|
||||
{"root":["./src/app.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/api/client.ts","./src/components/addlogmodal.tsx","./src/components/dashboard.tsx","./src/components/loglist.tsx","./src/components/loginform.tsx","./src/components/reportmodal.tsx","./src/hooks/useauth.ts","./src/hooks/uselogs.ts"],"version":"5.9.3"}
|
||||
14
frontend/vite.config.ts
Normal file
14
frontend/vite.config.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:3000',
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
1217
frontend/yarn.lock
Normal file
1217
frontend/yarn.lock
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user