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

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