first commit
This commit is contained in:
46
Dockerfile
Normal file
46
Dockerfile
Normal 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
3
backend/dist/index.d.ts
vendored
Normal 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
1
backend/dist/index.d.ts.map
vendored
Normal 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
58
backend/dist/index.js
vendored
Normal 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
3
backend/dist/routes/auth.d.ts
vendored
Normal 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
1
backend/dist/routes/auth.d.ts.map
vendored
Normal 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
50
backend/dist/routes/auth.js
vendored
Normal 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
3
backend/dist/routes/logs.d.ts
vendored
Normal 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
1
backend/dist/routes/logs.d.ts.map
vendored
Normal 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
142
backend/dist/routes/logs.js
vendored
Normal 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
3
backend/dist/routes/reports.d.ts
vendored
Normal 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
1
backend/dist/routes/reports.d.ts.map
vendored
Normal 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
196
backend/dist/routes/reports.js
vendored
Normal 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
30
backend/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
29
backend/prisma/schema.prisma
Normal file
29
backend/prisma/schema.prisma
Normal 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
69
backend/src/index.ts
Normal 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();
|
||||
63
backend/src/routes/auth.ts
Normal file
63
backend/src/routes/auth.ts
Normal 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
173
backend/src/routes/logs.ts
Normal 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 };
|
||||
});
|
||||
}
|
||||
244
backend/src/routes/reports.ts
Normal file
244
backend/src/routes/reports.ts
Normal 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
19
backend/tsconfig.json
Normal 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
2179
backend/yarn.lock
Normal file
File diff suppressed because it is too large
Load Diff
28
compose.yaml
Normal file
28
compose.yaml
Normal 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: {}
|
||||
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