first commit
This commit is contained in:
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();
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user