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