first commit

This commit is contained in:
Vadim Sobinin
2026-02-02 16:14:57 +03:00
commit fc886320e3
48 changed files with 5569 additions and 0 deletions

File diff suppressed because one or more lines are too long

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
View 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
View 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
View 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
View 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"
}
}

View File

@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};

1
frontend/public/vite.svg Normal file
View 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
View 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;

View 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);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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,
};
}

View 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
View 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
View 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
View File

@@ -0,0 +1 @@
/// <reference types="vite/client" />

View 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
View 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"]
}

View 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
View 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

File diff suppressed because it is too large Load Diff