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

0
.env Normal file
View File

46
Dockerfile Normal file
View File

@@ -0,0 +1,46 @@
# Stage 1: Build frontend
FROM node:22-alpine AS frontend-builder
WORKDIR /app/frontend
COPY frontend/package.json frontend/yarn.lock* ./
RUN yarn install --frozen-lockfile || yarn install
COPY frontend/ ./
RUN yarn build
# Stage 2: Build backend
FROM node:22-alpine AS backend-builder
WORKDIR /app/backend
COPY backend/package.json backend/yarn.lock* ./
RUN yarn install --frozen-lockfile || yarn install
COPY backend/ ./
RUN yarn db:generate && yarn build
# Stage 3: Production
FROM node:22-alpine AS production
WORKDIR /app
# Install production dependencies only
COPY backend/package.json backend/yarn.lock* ./
RUN yarn install --production --frozen-lockfile || yarn install --production
# Copy Prisma schema and generate client
COPY backend/prisma ./prisma
RUN npx prisma generate
# Copy built backend
COPY --from=backend-builder /app/backend/dist ./dist
# Copy built frontend
COPY --from=frontend-builder /app/frontend/dist ./frontend/dist
# Create data directory for SQLite
RUN mkdir -p /app/data
ENV NODE_ENV=production
ENV DATABASE_URL="file:/app/data/timetracker.db"
ENV STATIC_ROOT="/app/frontend/dist"
ENV PORT=3000
EXPOSE 3000
# Initialize database and start server
CMD npx prisma db push --skip-generate && node dist/index.js

3
backend/dist/index.d.ts vendored Normal file
View File

@@ -0,0 +1,3 @@
import { PrismaClient } from '@prisma/client';
export declare const prisma: PrismaClient<import("@prisma/client").Prisma.PrismaClientOptions, never, import("@prisma/client/runtime/library").DefaultArgs>;
//# sourceMappingURL=index.d.ts.map

1
backend/dist/index.d.ts.map vendored Normal file
View File

@@ -0,0 +1 @@
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAIA,OAAO,EAAE,YAAY,EAAE,MAAM,gBAAgB,CAAC;AAS9C,eAAO,MAAM,MAAM,gIAAqB,CAAC"}

58
backend/dist/index.js vendored Normal file
View File

@@ -0,0 +1,58 @@
import Fastify from 'fastify';
import cors from '@fastify/cors';
import jwt from '@fastify/jwt';
import fastifyStatic from '@fastify/static';
import { PrismaClient } from '@prisma/client';
import { authRoutes } from './routes/auth.js';
import { logsRoutes } from './routes/logs.js';
import { reportsRoutes } from './routes/reports.js';
import path from 'path';
import { fileURLToPath } from 'url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
export const prisma = new PrismaClient();
const fastify = Fastify({
logger: true,
});
await fastify.register(cors, {
origin: true,
credentials: true,
});
await fastify.register(jwt, {
secret: process.env.JWT_SECRET || 'default-secret-change-me',
});
fastify.decorate('authenticate', async function (request, reply) {
try {
await request.jwtVerify();
}
catch (err) {
reply.status(401).send({ error: 'Unauthorized' });
}
});
await fastify.register(authRoutes, { prefix: '/api/auth' });
await fastify.register(logsRoutes, { prefix: '/api/logs' });
await fastify.register(reportsRoutes, { prefix: '/api/reports' });
// Serve static files in production
if (process.env.NODE_ENV === 'production') {
await fastify.register(fastifyStatic, {
root: path.join(__dirname, '../../frontend/dist'),
prefix: '/',
});
fastify.setNotFoundHandler((request, reply) => {
if (!request.url.startsWith('/api')) {
return reply.sendFile('index.html');
}
reply.status(404).send({ error: 'Not Found' });
});
}
const start = async () => {
try {
const port = parseInt(process.env.PORT || '3000', 10);
await fastify.listen({ port, host: '0.0.0.0' });
console.log(`Server running on http://localhost:${port}`);
}
catch (err) {
fastify.log.error(err);
process.exit(1);
}
};
start();

3
backend/dist/routes/auth.d.ts vendored Normal file
View File

@@ -0,0 +1,3 @@
import { FastifyInstance } from 'fastify';
export declare function authRoutes(fastify: FastifyInstance): Promise<void>;
//# sourceMappingURL=auth.d.ts.map

1
backend/dist/routes/auth.d.ts.map vendored Normal file
View File

@@ -0,0 +1 @@
{"version":3,"file":"auth.d.ts","sourceRoot":"","sources":["../../src/routes/auth.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,eAAe,EAAE,MAAM,SAAS,CAAC;AAc1C,wBAAsB,UAAU,CAAC,OAAO,EAAE,eAAe,iBAgDxD"}

50
backend/dist/routes/auth.js vendored Normal file
View File

@@ -0,0 +1,50 @@
import { z } from 'zod';
import { prisma } from '../index.js';
import crypto from 'crypto';
const userSchema = z.object({
username: z.string().min(3).max(50),
password: z.string().min(6).max(100),
});
function hashPassword(password) {
return crypto.createHash('sha256').update(password).digest('hex');
}
export async function authRoutes(fastify) {
fastify.post('/register', async (request, reply) => {
const result = userSchema.safeParse(request.body);
if (!result.success) {
return reply.status(400).send({ error: 'Invalid input', details: result.error.errors });
}
const { username, password } = result.data;
const existing = await prisma.user.findUnique({ where: { username } });
if (existing) {
return reply.status(409).send({ error: 'Username already exists' });
}
const user = await prisma.user.create({
data: {
username,
password: hashPassword(password),
},
});
const token = fastify.jwt.sign({ id: user.id, username: user.username });
return { token, user: { id: user.id, username: user.username } };
});
fastify.post('/login', async (request, reply) => {
const result = userSchema.safeParse(request.body);
if (!result.success) {
return reply.status(400).send({ error: 'Invalid input' });
}
const { username, password } = result.data;
const user = await prisma.user.findUnique({ where: { username } });
if (!user || user.password !== hashPassword(password)) {
return reply.status(401).send({ error: 'Invalid credentials' });
}
const token = fastify.jwt.sign({ id: user.id, username: user.username });
return { token, user: { id: user.id, username: user.username } };
});
fastify.get('/me', {
onRequest: [fastify.authenticate],
}, async (request) => {
const user = request.user;
return { id: user.id, username: user.username };
});
}

3
backend/dist/routes/logs.d.ts vendored Normal file
View File

@@ -0,0 +1,3 @@
import { FastifyInstance } from 'fastify';
export declare function logsRoutes(fastify: FastifyInstance): Promise<void>;
//# sourceMappingURL=logs.d.ts.map

1
backend/dist/routes/logs.d.ts.map vendored Normal file
View File

@@ -0,0 +1 @@
{"version":3,"file":"logs.d.ts","sourceRoot":"","sources":["../../src/routes/logs.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,eAAe,EAAE,MAAM,SAAS,CAAC;AA4C1C,wBAAsB,UAAU,CAAC,OAAO,EAAE,eAAe,iBAgIxD"}

142
backend/dist/routes/logs.js vendored Normal file
View File

@@ -0,0 +1,142 @@
import { z } from 'zod';
import { prisma } from '../index.js';
function parseTimeToMinutes(timeStr) {
const str = timeStr.trim();
// Format: "8,5" or "8.5" (decimal hours)
if (/^\d+[,\.]\d+$/.test(str)) {
const hours = parseFloat(str.replace(',', '.'));
return Math.round(hours * 60);
}
// Format: "8:30" (hours:minutes)
if (/^\d+:\d{1,2}$/.test(str)) {
const [hours, minutes] = str.split(':').map(Number);
return hours * 60 + minutes;
}
// Format: "8" (just hours)
if (/^\d+$/.test(str)) {
return parseInt(str, 10) * 60;
}
throw new Error('Invalid time format');
}
const createLogSchema = z.object({
date: z.string(),
time: z.string(),
description: z.string().min(1).max(1000),
});
const updateLogSchema = z.object({
date: z.string().optional(),
time: z.string().optional(),
description: z.string().min(1).max(1000).optional(),
});
const querySchema = z.object({
startDate: z.string().optional(),
endDate: z.string().optional(),
});
export async function logsRoutes(fastify) {
fastify.addHook('onRequest', fastify.authenticate);
// Get logs (default: current month)
fastify.get('/', async (request) => {
const user = request.user;
const query = querySchema.parse(request.query);
const now = new Date();
const startOfMonth = new Date(now.getFullYear(), now.getMonth(), 1);
const endOfMonth = new Date(now.getFullYear(), now.getMonth() + 1, 0, 23, 59, 59);
const startDate = query.startDate ? new Date(query.startDate) : startOfMonth;
const endDate = query.endDate ? new Date(query.endDate + 'T23:59:59') : endOfMonth;
const logs = await prisma.timeLog.findMany({
where: {
userId: user.id,
date: {
gte: startDate,
lte: endDate,
},
},
orderBy: { date: 'desc' },
});
return logs.map(log => ({
...log,
hours: Math.floor(log.minutes / 60),
mins: log.minutes % 60,
}));
});
// Create log
fastify.post('/', async (request, reply) => {
const user = request.user;
const result = createLogSchema.safeParse(request.body);
if (!result.success) {
return reply.status(400).send({ error: 'Invalid input', details: result.error.errors });
}
const { date, time, description } = result.data;
let minutes;
try {
minutes = parseTimeToMinutes(time);
}
catch {
return reply.status(400).send({ error: 'Invalid time format' });
}
const log = await prisma.timeLog.create({
data: {
date: new Date(date),
minutes,
description,
userId: user.id,
},
});
return {
...log,
hours: Math.floor(log.minutes / 60),
mins: log.minutes % 60,
};
});
// Update log
fastify.put('/:id', async (request, reply) => {
const user = request.user;
const { id } = request.params;
const result = updateLogSchema.safeParse(request.body);
if (!result.success) {
return reply.status(400).send({ error: 'Invalid input', details: result.error.errors });
}
const existing = await prisma.timeLog.findFirst({
where: { id, userId: user.id },
});
if (!existing) {
return reply.status(404).send({ error: 'Log not found' });
}
const { date, time, description } = result.data;
let minutes;
if (time) {
try {
minutes = parseTimeToMinutes(time);
}
catch {
return reply.status(400).send({ error: 'Invalid time format' });
}
}
const log = await prisma.timeLog.update({
where: { id },
data: {
...(date && { date: new Date(date) }),
...(minutes !== undefined && { minutes }),
...(description && { description }),
},
});
return {
...log,
hours: Math.floor(log.minutes / 60),
mins: log.minutes % 60,
};
});
// Delete log
fastify.delete('/:id', async (request, reply) => {
const user = request.user;
const { id } = request.params;
const existing = await prisma.timeLog.findFirst({
where: { id, userId: user.id },
});
if (!existing) {
return reply.status(404).send({ error: 'Log not found' });
}
await prisma.timeLog.delete({ where: { id } });
return { success: true };
});
}

3
backend/dist/routes/reports.d.ts vendored Normal file
View File

@@ -0,0 +1,3 @@
import { FastifyInstance } from 'fastify';
export declare function reportsRoutes(fastify: FastifyInstance): Promise<void>;
//# sourceMappingURL=reports.d.ts.map

1
backend/dist/routes/reports.d.ts.map vendored Normal file
View File

@@ -0,0 +1 @@
{"version":3,"file":"reports.d.ts","sourceRoot":"","sources":["../../src/routes/reports.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,eAAe,EAAE,MAAM,SAAS,CAAC;AA2B1C,wBAAsB,aAAa,CAAC,OAAO,EAAE,eAAe,iBAuD3D"}

196
backend/dist/routes/reports.js vendored Normal file
View File

@@ -0,0 +1,196 @@
import { z } from 'zod';
import { prisma } from '../index.js';
import PdfPrinter from 'pdfmake';
import ExcelJS from 'exceljs';
const reportSchema = z.object({
startDate: z.string(),
endDate: z.string(),
format: z.enum(['pdf', 'excel', 'csv']),
});
function formatDate(date) {
const d = new Date(date);
const day = String(d.getDate()).padStart(2, '0');
const month = String(d.getMonth() + 1).padStart(2, '0');
const year = d.getFullYear();
return `${day}.${month}.${year}`;
}
function formatTime(minutes) {
const hours = Math.floor(minutes / 60);
const mins = minutes % 60;
return `${hours}:${String(mins).padStart(2, '0')}`;
}
export async function reportsRoutes(fastify) {
fastify.addHook('onRequest', fastify.authenticate);
fastify.post('/generate', async (request, reply) => {
const user = request.user;
const result = reportSchema.safeParse(request.body);
if (!result.success) {
return reply.status(400).send({ error: 'Invalid input', details: result.error.errors });
}
const { startDate, endDate, format } = result.data;
const logs = await prisma.timeLog.findMany({
where: {
userId: user.id,
date: {
gte: new Date(startDate),
lte: new Date(endDate + 'T23:59:59'),
},
},
orderBy: { date: 'asc' },
});
// Calculate totals
const uniqueDates = new Set(logs.map(log => formatDate(log.date)));
const totalMinutes = logs.reduce((sum, log) => sum + log.minutes, 0);
const reportData = logs.map(log => ({
date: formatDate(log.date),
description: log.description,
time: formatTime(log.minutes),
}));
if (format === 'csv') {
const csvContent = generateCSV(reportData, uniqueDates.size, totalMinutes);
reply.header('Content-Type', 'text/csv; charset=utf-8');
reply.header('Content-Disposition', `attachment; filename="report_${startDate}_${endDate}.csv"`);
return reply.send(csvContent);
}
if (format === 'excel') {
const buffer = await generateExcel(reportData, uniqueDates.size, totalMinutes);
reply.header('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
reply.header('Content-Disposition', `attachment; filename="report_${startDate}_${endDate}.xlsx"`);
return reply.send(buffer);
}
if (format === 'pdf') {
const buffer = await generatePDF(reportData, uniqueDates.size, totalMinutes, startDate, endDate);
reply.header('Content-Type', 'application/pdf');
reply.header('Content-Disposition', `attachment; filename="report_${startDate}_${endDate}.pdf"`);
return reply.send(buffer);
}
});
}
function generateCSV(data, uniqueDays, totalMinutes) {
const BOM = '\uFEFF';
const lines = [];
lines.push('Дата,Описание,Часы');
for (const row of data) {
const escapedDescription = `"${row.description.replace(/"/g, '""')}"`;
lines.push(`${row.date},${escapedDescription},${row.time}`);
}
lines.push('');
lines.push(`Total,${uniqueDays} дней,${formatTime(totalMinutes)}`);
return BOM + lines.join('\n');
}
async function generateExcel(data, uniqueDays, totalMinutes) {
const workbook = new ExcelJS.Workbook();
const sheet = workbook.addWorksheet('Отчёт');
// Headers
sheet.columns = [
{ header: 'Дата', key: 'date', width: 15 },
{ header: 'Описание', key: 'description', width: 50 },
{ header: 'Часы', key: 'time', width: 10 },
];
// Style headers
sheet.getRow(1).font = { bold: true };
sheet.getRow(1).fill = {
type: 'pattern',
pattern: 'solid',
fgColor: { argb: 'FFE0E0E0' },
};
// Data
for (const row of data) {
sheet.addRow(row);
}
// Empty row
sheet.addRow({});
// Total row
const totalRow = sheet.addRow({
date: 'Total',
description: `${uniqueDays} дней`,
time: formatTime(totalMinutes),
});
// Style total row
totalRow.font = { bold: true };
totalRow.fill = {
type: 'pattern',
pattern: 'solid',
fgColor: { argb: 'FF90EE90' },
};
const arrayBuffer = await workbook.xlsx.writeBuffer();
return Buffer.from(arrayBuffer);
}
async function generatePDF(data, uniqueDays, totalMinutes, startDate, endDate) {
const fonts = {
Roboto: {
normal: 'node_modules/pdfmake/build/vfs_fonts.js',
bold: 'node_modules/pdfmake/build/vfs_fonts.js',
},
};
const printer = new PdfPrinter(fonts);
const tableBody = [
[
{ text: 'Дата', style: 'tableHeader' },
{ text: 'Описание', style: 'tableHeader' },
{ text: 'Часы', style: 'tableHeader' },
],
];
for (const row of data) {
tableBody.push([row.date, row.description, row.time]);
}
// Total row
tableBody.push([
{ text: 'Total', style: 'total' },
{ text: `${uniqueDays} дней`, style: 'total' },
{ text: formatTime(totalMinutes), style: 'total' },
]);
const docDefinition = {
content: [
{ text: 'Отчёт по времени', style: 'header' },
{ text: `Период: ${startDate} - ${endDate}`, style: 'subheader' },
{ text: ' ' },
{
table: {
headerRows: 1,
widths: ['auto', '*', 'auto'],
body: tableBody,
},
layout: {
fillColor: (rowIndex) => {
if (rowIndex === 0)
return '#EEEEEE';
if (rowIndex === tableBody.length - 1)
return '#90EE90';
return null;
},
},
},
],
styles: {
header: {
fontSize: 18,
bold: true,
margin: [0, 0, 0, 10],
},
subheader: {
fontSize: 12,
margin: [0, 0, 0, 10],
},
tableHeader: {
bold: true,
fontSize: 11,
},
total: {
bold: true,
fontSize: 11,
},
},
defaultStyle: {
fontSize: 10,
},
};
return new Promise((resolve, reject) => {
const pdfDoc = printer.createPdfKitDocument(docDefinition);
const chunks = [];
pdfDoc.on('data', (chunk) => chunks.push(chunk));
pdfDoc.on('end', () => resolve(Buffer.concat(chunks)));
pdfDoc.on('error', reject);
pdfDoc.end();
});
}

30
backend/package.json Normal file
View File

@@ -0,0 +1,30 @@
{
"name": "timetracker-backend",
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "tsx watch src/index.ts",
"build": "tsc",
"start": "node dist/index.js",
"db:generate": "prisma generate",
"db:push": "prisma db push",
"db:migrate": "prisma migrate deploy"
},
"dependencies": {
"@fastify/cors": "^10.0.2",
"@fastify/jwt": "^9.0.2",
"@fastify/static": "^8.0.4",
"@prisma/client": "^6.2.1",
"exceljs": "^4.4.0",
"fastify": "^5.2.1",
"pdfmake": "^0.2.15",
"zod": "^3.24.1"
},
"devDependencies": {
"@types/node": "^22.10.7",
"@types/pdfmake": "^0.2.9",
"prisma": "^6.2.1",
"tsx": "^4.19.2",
"typescript": "^5.7.3"
}
}

View File

@@ -0,0 +1,29 @@
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "sqlite"
url = env("DATABASE_URL")
}
model User {
id String @id @default(cuid())
username String @unique
password String
createdAt DateTime @default(now())
logs TimeLog[]
}
model TimeLog {
id String @id @default(cuid())
date DateTime
minutes Int
description String
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([userId, date])
}

69
backend/src/index.ts Normal file
View File

@@ -0,0 +1,69 @@
import Fastify from 'fastify';
import cors from '@fastify/cors';
import jwt from '@fastify/jwt';
import fastifyStatic from '@fastify/static';
import { PrismaClient } from '@prisma/client';
import { authRoutes } from './routes/auth.js';
import { logsRoutes } from './routes/logs.js';
import { reportsRoutes } from './routes/reports.js';
import path from 'path';
import { fileURLToPath } from 'url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
export const prisma = new PrismaClient();
const fastify = Fastify({
logger: true,
});
await fastify.register(cors, {
origin: true,
credentials: true,
});
await fastify.register(jwt, {
secret: process.env.JWT_SECRET || 'default-secret-change-me',
});
fastify.decorate('authenticate', async function (request: any, reply: any) {
try {
await request.jwtVerify();
} catch (err) {
reply.status(401).send({ error: 'Unauthorized' });
}
});
await fastify.register(authRoutes, { prefix: '/api/auth' });
await fastify.register(logsRoutes, { prefix: '/api/logs' });
await fastify.register(reportsRoutes, { prefix: '/api/reports' });
// Serve static files in production
if (process.env.NODE_ENV === 'production') {
const staticRoot = process.env.STATIC_ROOT || path.join(__dirname, '../frontend/dist');
await fastify.register(fastifyStatic, {
root: staticRoot,
prefix: '/',
});
fastify.setNotFoundHandler((request, reply) => {
if (!request.url.startsWith('/api')) {
return reply.sendFile('index.html');
}
reply.status(404).send({ error: 'Not Found' });
});
}
const start = async () => {
try {
const port = parseInt(process.env.PORT || '3000', 10);
await fastify.listen({ port, host: '0.0.0.0' });
console.log(`Server running on http://localhost:${port}`);
} catch (err) {
fastify.log.error(err);
process.exit(1);
}
};
start();

View File

@@ -0,0 +1,63 @@
import { FastifyInstance } from 'fastify';
import { z } from 'zod';
import { prisma } from '../index.js';
import crypto from 'crypto';
const userSchema = z.object({
username: z.string().min(3).max(50),
password: z.string().min(6).max(100),
});
function hashPassword(password: string): string {
return crypto.createHash('sha256').update(password).digest('hex');
}
export async function authRoutes(fastify: FastifyInstance) {
fastify.post('/register', async (request, reply) => {
const result = userSchema.safeParse(request.body);
if (!result.success) {
return reply.status(400).send({ error: 'Invalid input', details: result.error.errors });
}
const { username, password } = result.data;
const existing = await prisma.user.findUnique({ where: { username } });
if (existing) {
return reply.status(409).send({ error: 'Username already exists' });
}
const user = await prisma.user.create({
data: {
username,
password: hashPassword(password),
},
});
const token = fastify.jwt.sign({ id: user.id, username: user.username });
return { token, user: { id: user.id, username: user.username } };
});
fastify.post('/login', async (request, reply) => {
const result = userSchema.safeParse(request.body);
if (!result.success) {
return reply.status(400).send({ error: 'Invalid input' });
}
const { username, password } = result.data;
const user = await prisma.user.findUnique({ where: { username } });
if (!user || user.password !== hashPassword(password)) {
return reply.status(401).send({ error: 'Invalid credentials' });
}
const token = fastify.jwt.sign({ id: user.id, username: user.username });
return { token, user: { id: user.id, username: user.username } };
});
fastify.get('/me', {
onRequest: [(fastify as any).authenticate],
}, async (request) => {
const user = (request as any).user;
return { id: user.id, username: user.username };
});
}

173
backend/src/routes/logs.ts Normal file
View File

@@ -0,0 +1,173 @@
import { FastifyInstance } from 'fastify';
import { z } from 'zod';
import { prisma } from '../index.js';
function parseTimeToMinutes(timeStr: string): number {
const str = timeStr.trim();
// Format: "8,5" or "8.5" (decimal hours)
if (/^\d+[,\.]\d+$/.test(str)) {
const hours = parseFloat(str.replace(',', '.'));
return Math.round(hours * 60);
}
// Format: "8:30" (hours:minutes)
if (/^\d+:\d{1,2}$/.test(str)) {
const [hours, minutes] = str.split(':').map(Number);
return hours * 60 + minutes;
}
// Format: "8" (just hours)
if (/^\d+$/.test(str)) {
return parseInt(str, 10) * 60;
}
throw new Error('Invalid time format');
}
const createLogSchema = z.object({
date: z.string(),
time: z.string(),
description: z.string().min(1).max(1000),
});
const updateLogSchema = z.object({
date: z.string().optional(),
time: z.string().optional(),
description: z.string().min(1).max(1000).optional(),
});
const querySchema = z.object({
startDate: z.string().optional(),
endDate: z.string().optional(),
});
export async function logsRoutes(fastify: FastifyInstance) {
fastify.addHook('onRequest', (fastify as any).authenticate);
// Get logs (default: current month)
fastify.get('/', async (request) => {
const user = (request as any).user;
const query = querySchema.parse(request.query);
const now = new Date();
const startOfMonth = new Date(now.getFullYear(), now.getMonth(), 1);
const endOfMonth = new Date(now.getFullYear(), now.getMonth() + 1, 0, 23, 59, 59);
const startDate = query.startDate ? new Date(query.startDate) : startOfMonth;
const endDate = query.endDate ? new Date(query.endDate + 'T23:59:59') : endOfMonth;
const logs = await prisma.timeLog.findMany({
where: {
userId: user.id,
date: {
gte: startDate,
lte: endDate,
},
},
orderBy: { date: 'desc' },
});
return logs.map(log => ({
...log,
hours: Math.floor(log.minutes / 60),
mins: log.minutes % 60,
}));
});
// Create log
fastify.post('/', async (request, reply) => {
const user = (request as any).user;
const result = createLogSchema.safeParse(request.body);
if (!result.success) {
return reply.status(400).send({ error: 'Invalid input', details: result.error.errors });
}
const { date, time, description } = result.data;
let minutes: number;
try {
minutes = parseTimeToMinutes(time);
} catch {
return reply.status(400).send({ error: 'Invalid time format' });
}
const log = await prisma.timeLog.create({
data: {
date: new Date(date),
minutes,
description,
userId: user.id,
},
});
return {
...log,
hours: Math.floor(log.minutes / 60),
mins: log.minutes % 60,
};
});
// Update log
fastify.put('/:id', async (request, reply) => {
const user = (request as any).user;
const { id } = request.params as { id: string };
const result = updateLogSchema.safeParse(request.body);
if (!result.success) {
return reply.status(400).send({ error: 'Invalid input', details: result.error.errors });
}
const existing = await prisma.timeLog.findFirst({
where: { id, userId: user.id },
});
if (!existing) {
return reply.status(404).send({ error: 'Log not found' });
}
const { date, time, description } = result.data;
let minutes: number | undefined;
if (time) {
try {
minutes = parseTimeToMinutes(time);
} catch {
return reply.status(400).send({ error: 'Invalid time format' });
}
}
const log = await prisma.timeLog.update({
where: { id },
data: {
...(date && { date: new Date(date) }),
...(minutes !== undefined && { minutes }),
...(description && { description }),
},
});
return {
...log,
hours: Math.floor(log.minutes / 60),
mins: log.minutes % 60,
};
});
// Delete log
fastify.delete('/:id', async (request, reply) => {
const user = (request as any).user;
const { id } = request.params as { id: string };
const existing = await prisma.timeLog.findFirst({
where: { id, userId: user.id },
});
if (!existing) {
return reply.status(404).send({ error: 'Log not found' });
}
await prisma.timeLog.delete({ where: { id } });
return { success: true };
});
}

View File

@@ -0,0 +1,244 @@
import { FastifyInstance } from 'fastify';
import { z } from 'zod';
import { prisma } from '../index.js';
import PdfPrinter from 'pdfmake';
import ExcelJS from 'exceljs';
import type { TDocumentDefinitions, Content } from 'pdfmake/interfaces.js';
const reportSchema = z.object({
startDate: z.string(),
endDate: z.string(),
format: z.enum(['pdf', 'excel', 'csv']),
});
function formatDate(date: Date): string {
const d = new Date(date);
const day = String(d.getDate()).padStart(2, '0');
const month = String(d.getMonth() + 1).padStart(2, '0');
const year = d.getFullYear();
return `${day}.${month}.${year}`;
}
function formatTime(minutes: number): string {
const hours = Math.floor(minutes / 60);
const mins = minutes % 60;
return `${hours}:${String(mins).padStart(2, '0')}`;
}
export async function reportsRoutes(fastify: FastifyInstance) {
fastify.addHook('onRequest', (fastify as any).authenticate);
fastify.post('/generate', async (request, reply) => {
const user = (request as any).user;
const result = reportSchema.safeParse(request.body);
if (!result.success) {
return reply.status(400).send({ error: 'Invalid input', details: result.error.errors });
}
const { startDate, endDate, format } = result.data;
const logs = await prisma.timeLog.findMany({
where: {
userId: user.id,
date: {
gte: new Date(startDate),
lte: new Date(endDate + 'T23:59:59'),
},
},
orderBy: { date: 'asc' },
});
// Calculate totals
const uniqueDates = new Set(logs.map(log => formatDate(log.date)));
const totalMinutes = logs.reduce((sum, log) => sum + log.minutes, 0);
const reportData = logs.map(log => ({
date: formatDate(log.date),
description: log.description,
time: formatTime(log.minutes),
}));
if (format === 'csv') {
const csvContent = generateCSV(reportData, uniqueDates.size, totalMinutes);
reply.header('Content-Type', 'text/csv; charset=utf-8');
reply.header('Content-Disposition', `attachment; filename="report_${startDate}_${endDate}.csv"`);
return reply.send(csvContent);
}
if (format === 'excel') {
const buffer = await generateExcel(reportData, uniqueDates.size, totalMinutes);
reply.header('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
reply.header('Content-Disposition', `attachment; filename="report_${startDate}_${endDate}.xlsx"`);
return reply.send(buffer);
}
if (format === 'pdf') {
const buffer = await generatePDF(reportData, uniqueDates.size, totalMinutes, startDate, endDate);
reply.header('Content-Type', 'application/pdf');
reply.header('Content-Disposition', `attachment; filename="report_${startDate}_${endDate}.pdf"`);
return reply.send(buffer);
}
});
}
function generateCSV(
data: { date: string; description: string; time: string }[],
uniqueDays: number,
totalMinutes: number
): string {
const BOM = '\uFEFF';
const lines: string[] = [];
lines.push('Дата,Описание,Часы');
for (const row of data) {
const escapedDescription = `"${row.description.replace(/"/g, '""')}"`;
lines.push(`${row.date},${escapedDescription},${row.time}`);
}
lines.push('');
lines.push(`Total,${uniqueDays} дней,${formatTime(totalMinutes)}`);
return BOM + lines.join('\n');
}
async function generateExcel(
data: { date: string; description: string; time: string }[],
uniqueDays: number,
totalMinutes: number
): Promise<Buffer> {
const workbook = new ExcelJS.Workbook();
const sheet = workbook.addWorksheet('Отчёт');
// Headers
sheet.columns = [
{ header: 'Дата', key: 'date', width: 15 },
{ header: 'Описание', key: 'description', width: 50 },
{ header: 'Часы', key: 'time', width: 10 },
];
// Style headers
sheet.getRow(1).font = { bold: true };
sheet.getRow(1).fill = {
type: 'pattern',
pattern: 'solid',
fgColor: { argb: 'FFE0E0E0' },
};
// Data
for (const row of data) {
sheet.addRow(row);
}
// Empty row
sheet.addRow({});
// Total row
const totalRow = sheet.addRow({
date: 'Total',
description: `${uniqueDays} дней`,
time: formatTime(totalMinutes),
});
// Style total row
totalRow.font = { bold: true };
totalRow.fill = {
type: 'pattern',
pattern: 'solid',
fgColor: { argb: 'FF90EE90' },
};
const arrayBuffer = await workbook.xlsx.writeBuffer();
return Buffer.from(arrayBuffer);
}
async function generatePDF(
data: { date: string; description: string; time: string }[],
uniqueDays: number,
totalMinutes: number,
startDate: string,
endDate: string
): Promise<Buffer> {
const fonts = {
Roboto: {
normal: 'node_modules/pdfmake/build/vfs_fonts.js',
bold: 'node_modules/pdfmake/build/vfs_fonts.js',
},
};
const printer = new PdfPrinter(fonts);
const tableBody: Content[][] = [
[
{ text: 'Дата', style: 'tableHeader' },
{ text: 'Описание', style: 'tableHeader' },
{ text: 'Часы', style: 'tableHeader' },
],
];
for (const row of data) {
tableBody.push([row.date, row.description, row.time]);
}
// Total row
tableBody.push([
{ text: 'Total', style: 'total' },
{ text: `${uniqueDays} дней`, style: 'total' },
{ text: formatTime(totalMinutes), style: 'total' },
]);
const docDefinition: TDocumentDefinitions = {
content: [
{ text: 'Отчёт по времени', style: 'header' },
{ text: `Период: ${startDate} - ${endDate}`, style: 'subheader' },
{ text: ' ' },
{
table: {
headerRows: 1,
widths: ['auto', '*', 'auto'],
body: tableBody,
},
layout: {
fillColor: (rowIndex: number) => {
if (rowIndex === 0) return '#EEEEEE';
if (rowIndex === tableBody.length - 1) return '#90EE90';
return null;
},
},
},
],
styles: {
header: {
fontSize: 18,
bold: true,
margin: [0, 0, 0, 10],
},
subheader: {
fontSize: 12,
margin: [0, 0, 0, 10],
},
tableHeader: {
bold: true,
fontSize: 11,
},
total: {
bold: true,
fontSize: 11,
},
},
defaultStyle: {
fontSize: 10,
},
};
return new Promise((resolve, reject) => {
const pdfDoc = printer.createPdfKitDocument(docDefinition);
const chunks: Buffer[] = [];
pdfDoc.on('data', (chunk: Buffer) => chunks.push(chunk));
pdfDoc.on('end', () => resolve(Buffer.concat(chunks)));
pdfDoc.on('error', reject);
pdfDoc.end();
});
}

19
backend/tsconfig.json Normal file
View File

@@ -0,0 +1,19 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"lib": ["ES2022"],
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"declaration": true,
"declarationMap": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}

2179
backend/yarn.lock Normal file

File diff suppressed because it is too large Load Diff

28
compose.yaml Normal file
View File

@@ -0,0 +1,28 @@
services:
timetracker:
build: .
container_name: timetracker
restart: unless-stopped
ports:
- 34225:3000
environment:
- NODE_ENV=production
- DATABASE_URL=file:/app/data/timetracker.db
- JWT_SECRET=${JWT_SECRET:-change-this-secret-in-production}
- PORT=3000
volumes:
- timetracker-data:/app/data
healthcheck:
test:
- CMD
- wget
- -q
- --spider
- http://localhost:3000/api/auth/me
interval: 30s
timeout: 10s
retries: 3
start_period: 10s
volumes:
timetracker-data: null
networks: {}

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