initial commit

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
BOHA
2026-03-23 08:46:51 +01:00
commit 4608494a3f
130 changed files with 40361 additions and 0 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,53 @@
import { FastifyInstance } from 'fastify';
import prisma from '../../config/database';
import { requirePermission } from '../../middleware/auth';
import { success, paginated, error } from '../../utils/response';
import { parsePagination, buildPaginationMeta } from '../../utils/pagination';
export default async function auditLogRoutes(fastify: FastifyInstance): Promise<void> {
fastify.get('/', { preHandler: requirePermission('settings.audit') }, async (request, reply) => {
const query = request.query as Record<string, unknown>;
const { page, limit, skip, order, search } = parsePagination(query);
const where: Record<string, unknown> = {};
if (query.action) where.action = String(query.action);
if (query.entity_type) where.entity_type = String(query.entity_type);
if (query.user_id) where.user_id = Number(query.user_id);
if (search) where.description = { contains: search };
if (query.date_from || query.date_to) {
const dateFilter: Record<string, Date> = {};
if (query.date_from) dateFilter.gte = new Date(String(query.date_from));
if (query.date_to) dateFilter.lte = new Date(String(query.date_to) + 'T23:59:59');
where.created_at = dateFilter;
}
const [logs, total] = await Promise.all([
prisma.audit_logs.findMany({ where, skip, take: limit, orderBy: { created_at: order } }),
prisma.audit_logs.count({ where }),
]);
return paginated(reply, logs, buildPaginationMeta(total, page, limit));
});
// POST /api/admin/audit-log/cleanup — delete old audit logs
fastify.post('/cleanup', { preHandler: requirePermission('settings.audit') }, async (request, reply) => {
const body = request.body as Record<string, unknown>;
const days = body.days !== undefined ? Number(body.days) : null;
// days === 0 means "delete all" (from frontend "Vše" option)
if (days === 0 || body.action === 'all') {
const result = await prisma.audit_logs.deleteMany({});
return success(reply, null, 200, `Smazáno ${result.count} záznamů`);
}
if (days && days > 0) {
const cutoff = new Date();
cutoff.setDate(cutoff.getDate() - days);
const result = await prisma.audit_logs.deleteMany({ where: { created_at: { lt: cutoff } } });
return success(reply, null, 200, `Smazáno ${result.count} záznamů starších než ${days} dní`);
}
return error(reply, 'Zadejte počet dní', 400);
});
}

188
src/routes/admin/auth.ts Normal file
View File

@@ -0,0 +1,188 @@
import { FastifyInstance } from 'fastify';
import { login, refreshAccessToken, logout, verifyAccessToken } from '../../services/auth';
import { logAudit } from '../../services/audit';
import { success, error } from '../../utils/response';
import { config } from '../../config/env';
import { LoginRequest, TotpVerifyRequest } from '../../types';
import prisma from '../../config/database';
import crypto from 'crypto';
import { OTPAuth } from '../../utils/totp';
function setRefreshCookie(reply: import('fastify').FastifyReply, token: string, rememberMe: boolean) {
const maxAge = rememberMe
? config.jwt.refreshTokenRememberExpiry
: config.jwt.refreshTokenSessionExpiry;
reply.setCookie('refresh_token', token, {
httpOnly: true,
secure: config.isProduction,
sameSite: 'strict',
path: '/api/admin',
maxAge,
});
}
export default async function authRoutes(fastify: FastifyInstance): Promise<void> {
// POST /api/admin/login
fastify.post<{ Body: LoginRequest }>('/login', async (request, reply) => {
const { username, password, remember_me } = request.body;
if (!username || !password) {
return error(reply, 'Uživatelské jméno a heslo jsou povinné', 400);
}
const result = await login(username, password, !!remember_me, request);
if (result.type === 'error') {
await logAudit({
request,
action: 'login_failed',
entityType: 'user',
description: `Neúspěšný pokus o přihlášení: ${username}`,
});
return error(reply, result.message, result.status);
}
if (result.type === 'totp_required') {
return success(reply, { totp_required: true, login_token: result.loginToken });
}
await logAudit({
request,
authData: result.user,
action: 'login',
entityType: 'user',
entityId: result.user.userId,
description: `Přihlášení uživatele ${result.user.username}`,
});
setRefreshCookie(reply, result.refreshToken, !!remember_me);
return success(reply, {
access_token: result.accessToken,
user: result.user,
});
});
// POST /api/admin/login/totp
fastify.post<{ Body: TotpVerifyRequest }>('/login/totp', async (request, reply) => {
const { login_token, totp_code } = request.body;
if (!login_token || !totp_code) {
return error(reply, 'Login token a TOTP kód jsou povinné', 400);
}
const tokenHash = crypto.createHash('sha256').update(login_token).digest('hex');
const storedToken = await prisma.totp_login_tokens.findFirst({
where: { token_hash: tokenHash },
});
if (!storedToken || new Date(storedToken.expires_at) < new Date()) {
return error(reply, 'Neplatný nebo expirovaný login token', 401);
}
const user = await prisma.users.findUnique({
where: { id: storedToken.user_id },
include: { roles: true },
});
if (!user || !user.totp_secret) {
return error(reply, 'Uživatel nenalezen', 401);
}
const isValid = OTPAuth.verify(user.totp_secret, totp_code);
if (!isValid) {
return error(reply, 'Neplatný TOTP kód', 401);
}
// Delete used login token
await prisma.totp_login_tokens.delete({ where: { id: storedToken.id } });
// Reset failed attempts and update last login (TOTP verified = successful login)
await prisma.users.update({
where: { id: user.id },
data: { failed_login_attempts: 0, locked_until: null, last_login: new Date() },
});
// Create tokens directly — password was already verified before TOTP was requested
const authData = await (await import('../../services/auth')).loadAuthData(user.id);
if (!authData) {
return error(reply, 'Chyba načítání uživatele', 500);
}
// Create tokens manually since password was already verified
const jwt = await import('jsonwebtoken');
const accessToken = jwt.default.sign(
{ sub: user.id, username: user.username, role: user.roles?.name ?? null },
config.jwt.secret,
{ expiresIn: config.jwt.accessTokenExpiry },
);
const refreshTokenRaw = crypto.randomBytes(32).toString('hex');
const refreshTokenHash = crypto.createHash('sha256').update(refreshTokenRaw).digest('hex');
await prisma.refresh_tokens.create({
data: {
user_id: user.id,
token_hash: refreshTokenHash,
expires_at: new Date(Date.now() + config.jwt.refreshTokenSessionExpiry * 1000),
remember_me: false,
ip_address: request.ip,
user_agent: request.headers['user-agent'] ?? null,
},
});
setRefreshCookie(reply, refreshTokenRaw, false);
return success(reply, { access_token: accessToken, user: authData });
});
// POST /api/admin/refresh
fastify.post('/refresh', async (request, reply) => {
const refreshTokenRaw = request.cookies.refresh_token;
if (!refreshTokenRaw) {
return error(reply, 'Refresh token chybí', 401);
}
const result = await refreshAccessToken(refreshTokenRaw, request);
if (result.type === 'error') {
reply.clearCookie('refresh_token', { path: '/api/admin' });
return error(reply, result.message, result.status);
}
// Preserve the original remember_me flag so long-lived sessions stay long-lived after rotation
setRefreshCookie(reply, result.refreshToken, result.rememberMe);
return success(reply, {
access_token: result.accessToken,
user: result.user,
});
});
// POST /api/admin/logout
fastify.post('/logout', async (request, reply) => {
const refreshTokenRaw = request.cookies.refresh_token;
if (refreshTokenRaw) {
await logout(refreshTokenRaw);
}
reply.clearCookie('refresh_token', { path: '/api/admin' });
return success(reply, null, 200, 'Odhlášení úspěšné');
});
// GET /api/admin/session
fastify.get('/session', async (request, reply) => {
const authHeader = request.headers.authorization;
if (!authHeader?.startsWith('Bearer ')) {
return error(reply, 'Vyžadována autentizace', 401);
}
const token = authHeader.slice(7);
const authData = await verifyAccessToken(token);
if (!authData) {
return error(reply, 'Neplatný token', 401);
}
return success(reply, { user: authData });
});
}

View File

@@ -0,0 +1,68 @@
import { FastifyInstance } from 'fastify';
import prisma from '../../config/database';
import { requirePermission } from '../../middleware/auth';
import { logAudit } from '../../services/audit';
import { success, error, parseId } from '../../utils/response';
export default async function bankAccountsRoutes(fastify: FastifyInstance): Promise<void> {
fastify.get('/', { preHandler: requirePermission('offers.settings') }, async (_request, reply) => {
const accounts = await prisma.bank_accounts.findMany({ orderBy: { position: 'asc' } });
return success(reply, accounts);
});
fastify.post('/', { preHandler: requirePermission('offers.settings') }, async (request, reply) => {
const body = request.body as Record<string, unknown>;
const account = await prisma.bank_accounts.create({
data: {
account_name: body.account_name ? String(body.account_name) : null,
bank_name: body.bank_name ? String(body.bank_name) : null,
account_number: body.account_number ? String(body.account_number) : null,
iban: body.iban ? String(body.iban) : null,
bic: body.bic ? String(body.bic) : null,
currency: body.currency ? String(body.currency) : 'CZK',
is_default: body.is_default === true || body.is_default === 1 || body.is_default === '1',
position: body.position ? Number(body.position) : 0,
},
});
await logAudit({ request, authData: request.authData, action: 'create', entityType: 'bank_account', entityId: account.id, description: `Vytvořen bankovní účet ${account.account_name}` });
return success(reply, { id: account.id }, 201, 'Bankovní účet vytvořen');
});
fastify.put<{ Params: { id: string } }>('/:id', { preHandler: requirePermission('offers.settings') }, async (request, reply) => {
const id = parseId(request.params.id, reply);
if (id === null) return;
const body = request.body as Record<string, unknown>;
const existing = await prisma.bank_accounts.findUnique({ where: { id } });
if (!existing) return error(reply, 'Účet nenalezen', 404);
await prisma.bank_accounts.update({
where: { id },
data: {
account_name: body.account_name !== undefined ? (body.account_name ? String(body.account_name) : null) : undefined,
bank_name: body.bank_name !== undefined ? (body.bank_name ? String(body.bank_name) : null) : undefined,
account_number: body.account_number !== undefined ? (body.account_number ? String(body.account_number) : null) : undefined,
iban: body.iban !== undefined ? (body.iban ? String(body.iban) : null) : undefined,
bic: body.bic !== undefined ? (body.bic ? String(body.bic) : null) : undefined,
currency: body.currency !== undefined ? String(body.currency) : undefined,
is_default: body.is_default !== undefined ? (body.is_default === true || body.is_default === 1 || body.is_default === '1') : undefined,
position: body.position !== undefined ? Number(body.position) : undefined,
modified_at: new Date(),
},
});
await logAudit({ request, authData: request.authData, action: 'update', entityType: 'bank_account', entityId: id, description: `Upraven bankovní účet` });
return success(reply, { id }, 200, 'Bankovní účet uložen');
});
fastify.delete<{ Params: { id: string } }>('/:id', { preHandler: requirePermission('offers.settings') }, async (request, reply) => {
const id = parseId(request.params.id, reply);
if (id === null) return;
const existing = await prisma.bank_accounts.findUnique({ where: { id } });
if (!existing) return error(reply, 'Účet nenalezen', 404);
await prisma.bank_accounts.delete({ where: { id } });
await logAudit({ request, authData: request.authData, action: 'delete', entityType: 'bank_account', entityId: id, description: `Smazán bankovní účet` });
return success(reply, null, 200, 'Účet smazán');
});
}

View File

@@ -0,0 +1,179 @@
import { FastifyInstance } from 'fastify';
import prisma from '../../config/database';
import { requireAuth, requirePermission } from '../../middleware/auth';
import { logAudit } from '../../services/audit';
import { success, error } from '../../utils/response';
import multipart from '@fastify/multipart';
/** Encode custom_fields + supplier_field_order into a single JSON blob (matching PHP format) */
function encodeCustomFields(fields: unknown, fieldOrder: unknown): string | null {
const f = Array.isArray(fields) ? fields : [];
const o = Array.isArray(fieldOrder) ? fieldOrder : [];
if (f.length === 0 && o.length === 0) return null;
return JSON.stringify({ fields: f, field_order: o });
}
/** Decode custom_fields JSON blob into separate fields + field_order for frontend */
function decodeCustomFields(raw: string | null): { custom_fields: unknown[]; supplier_field_order: string[] } {
if (!raw) return { custom_fields: [], supplier_field_order: [] };
try {
const parsed = JSON.parse(raw);
// PHP format: { fields: [...], field_order: [...] }
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed) && 'fields' in parsed) {
return { custom_fields: parsed.fields || [], supplier_field_order: parsed.field_order || [] };
}
// Legacy TS format: raw array
if (Array.isArray(parsed)) {
return { custom_fields: parsed, supplier_field_order: [] };
}
return { custom_fields: [], supplier_field_order: [] };
} catch {
return { custom_fields: [], supplier_field_order: [] };
}
}
export default async function companySettingsRoutes(fastify: FastifyInstance): Promise<void> {
await fastify.register(multipart, { limits: { fileSize: 5 * 1024 * 1024 } });
// GET /api/admin/company-settings/logo
fastify.get('/logo', { preHandler: requireAuth }, async (_request, reply) => {
const settings = await prisma.company_settings.findFirst({ select: { logo_data: true } });
if (!settings?.logo_data) return error(reply, 'Logo nenalezeno', 404);
// Detect image type from magic bytes
const buf = settings.logo_data;
let mime = 'image/png';
if (buf[0] === 0xFF && buf[1] === 0xD8) mime = 'image/jpeg';
else if (buf[0] === 0x47 && buf[1] === 0x49) mime = 'image/gif';
return reply.type(mime).send(buf);
});
// POST /api/admin/company-settings/logo
fastify.post('/logo', { preHandler: requirePermission('offers.settings') }, async (request, reply) => {
const file = await request.file();
if (!file) return error(reply, 'Nebyl nahrán žádný soubor', 400);
const allowed = ['image/png', 'image/jpeg', 'image/gif', 'image/webp'];
if (!allowed.includes(file.mimetype)) {
return error(reply, 'Nepodporovaný formát. Povoleno: PNG, JPG, GIF, WebP', 400);
}
const buffer = await file.toBuffer();
const existing = await prisma.company_settings.findFirst();
if (!existing) return error(reply, 'Nastavení nenalezeno', 404);
await prisma.company_settings.update({
where: { id: existing.id },
data: { logo_data: new Uint8Array(buffer), modified_at: new Date() },
});
await logAudit({ request, authData: request.authData, action: 'update', entityType: 'company_settings', entityId: existing.id, description: 'Nahráno logo' });
return success(reply, null, 200, 'Logo nahráno');
});
fastify.get('/', { preHandler: requireAuth }, async (_request, reply) => {
let settings = await prisma.company_settings.findFirst({
select: {
id: true,
company_name: true,
street: true,
city: true,
postal_code: true,
country: true,
company_id: true,
vat_id: true,
custom_fields: true,
quotation_prefix: true,
default_currency: true,
default_vat_rate: true,
uuid: true,
modified_at: true,
is_deleted: true,
sync_version: true,
order_type_code: true,
invoice_type_code: true,
require_2fa: true,
},
});
if (!settings) {
settings = await prisma.company_settings.create({
data: {
company_name: '',
quotation_prefix: 'N',
default_currency: 'EUR',
default_vat_rate: 21.0,
},
select: {
id: true,
company_name: true,
street: true,
city: true,
postal_code: true,
country: true,
company_id: true,
vat_id: true,
custom_fields: true,
quotation_prefix: true,
default_currency: true,
default_vat_rate: true,
uuid: true,
modified_at: true,
is_deleted: true,
sync_version: true,
order_type_code: true,
invoice_type_code: true,
require_2fa: true,
},
});
}
// Check if logo exists
const logoCheck = await prisma.company_settings.findFirst({
where: { id: settings.id },
select: { logo_data: true },
});
const has_logo = !!(logoCheck?.logo_data);
const { custom_fields, supplier_field_order } = decodeCustomFields(settings.custom_fields as string | null);
return success(reply, { ...settings, custom_fields, supplier_field_order, has_logo });
});
fastify.put('/', { preHandler: requirePermission('offers.settings') }, async (request, reply) => {
const body = request.body as Record<string, unknown>;
const existing = await prisma.company_settings.findFirst();
if (!existing) return error(reply, 'Nastavení nenalezeno', 404);
const data: Record<string, unknown> = { modified_at: new Date() };
const strFields = ['company_name', 'street', 'city', 'postal_code', 'country', 'company_id', 'vat_id', 'quotation_prefix', 'default_currency', 'order_type_code', 'invoice_type_code'];
for (const f of strFields) {
if (body[f] !== undefined) data[f] = body[f] ? String(body[f]) : null;
}
if (body.default_vat_rate !== undefined) data.default_vat_rate = Number(body.default_vat_rate);
if (body.require_2fa !== undefined) data.require_2fa = body.require_2fa === true || body.require_2fa === 1 || body.require_2fa === '1';
if (body.custom_fields !== undefined || body.supplier_field_order !== undefined) {
let existingFields: unknown[] = [];
let existingOrder: unknown[] = [];
if (existing.custom_fields) {
try {
const parsed = JSON.parse(existing.custom_fields);
existingFields = parsed?.fields || [];
existingOrder = parsed?.field_order || [];
} catch { /* invalid JSON, use defaults */ }
}
data.custom_fields = encodeCustomFields(
body.custom_fields !== undefined ? body.custom_fields : existingFields,
body.supplier_field_order !== undefined ? body.supplier_field_order : existingOrder,
);
}
data.sync_version = (existing.sync_version ?? 0) + 1;
await prisma.company_settings.update({ where: { id: existing.id }, data });
await logAudit({ request, authData: request.authData, action: 'update', entityType: 'company_settings', entityId: existing.id, description: 'Upraveno firemní nastavení' });
return success(reply, { id: existing.id }, 200, 'Nastavení bylo uloženo');
});
}

View File

@@ -0,0 +1,141 @@
import { FastifyInstance } from 'fastify';
import prisma from '../../config/database';
import { requireAuth, requirePermission } from '../../middleware/auth';
import { logAudit } from '../../services/audit';
import { success, error, parseId } from '../../utils/response';
import { parsePagination, buildPaginationMeta } from '../../utils/pagination';
const ALLOWED_SORT_FIELDS = ['id', 'name', 'company_id', 'city', 'country'];
/** Encode custom_fields + customer_field_order into a single JSON blob (matching PHP format) */
function encodeCustomFields(fields: unknown, fieldOrder: unknown): string | null {
const f = Array.isArray(fields) ? fields : [];
const o = Array.isArray(fieldOrder) ? fieldOrder : [];
if (f.length === 0 && o.length === 0) return null;
return JSON.stringify({ fields: f, field_order: o });
}
/** Decode custom_fields JSON blob into separate fields + field_order for frontend */
function decodeCustomFields(raw: string | null): { custom_fields: unknown[]; customer_field_order: string[] } {
if (!raw) return { custom_fields: [], customer_field_order: [] };
try {
const parsed = JSON.parse(raw);
// PHP format: { fields: [...], field_order: [...] }
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed) && 'fields' in parsed) {
return { custom_fields: parsed.fields || [], customer_field_order: parsed.field_order || [] };
}
// Legacy TS format: raw array
if (Array.isArray(parsed)) {
return { custom_fields: parsed, customer_field_order: [] };
}
return { custom_fields: [], customer_field_order: [] };
} catch {
return { custom_fields: [], customer_field_order: [] };
}
}
export default async function customersRoutes(fastify: FastifyInstance): Promise<void> {
fastify.get('/', { preHandler: requireAuth }, async (request, reply) => {
const { page, limit, skip, sort, order, search } = parsePagination(request.query as Record<string, unknown>);
const sortField = ALLOWED_SORT_FIELDS.includes(sort) ? sort : 'name';
const where = search
? { OR: [{ name: { contains: search } }, { company_id: { contains: search } }] }
: {};
const [customers, total] = await Promise.all([
prisma.customers.findMany({
where, skip, take: limit, orderBy: { [sortField]: order },
include: { _count: { select: { quotations: true } } },
}),
prisma.customers.count({ where }),
]);
const enriched = customers.map(c => {
const { custom_fields, customer_field_order } = decodeCustomFields(c.custom_fields);
return { ...c, custom_fields, customer_field_order, quotation_count: c._count?.quotations ?? 0 };
});
return reply.send({ success: true, data: enriched, pagination: buildPaginationMeta(total, page, limit) });
});
fastify.get<{ Params: { id: string } }>('/:id', { preHandler: requireAuth }, async (request, reply) => {
const id = parseId(request.params.id, reply);
if (id === null) return;
const customer = await prisma.customers.findUnique({ where: { id } });
if (!customer) return error(reply, 'Zákazník nenalezen', 404);
const { custom_fields, customer_field_order } = decodeCustomFields(customer.custom_fields);
return success(reply, { ...customer, custom_fields, customer_field_order });
});
fastify.post('/', { preHandler: requirePermission('customers.manage') }, async (request, reply) => {
const body = request.body as Record<string, unknown>;
const name = body.name ? String(body.name).trim() : '';
if (!name) return error(reply, 'Název zákazníka je povinný', 400);
const customer = await prisma.customers.create({
data: {
name,
street: body.street ? String(body.street) : null,
city: body.city ? String(body.city) : null,
postal_code: body.postal_code ? String(body.postal_code) : null,
country: body.country ? String(body.country) : null,
company_id: body.company_id ? String(body.company_id) : null,
vat_id: body.vat_id ? String(body.vat_id) : null,
custom_fields: encodeCustomFields(body.custom_fields, body.customer_field_order),
},
});
await logAudit({ request, authData: request.authData, action: 'create', entityType: 'customer', entityId: customer.id, description: `Vytvořen zákazník ${customer.name}` });
return success(reply, { id: customer.id }, 201, 'Zákazník byl vytvořen');
});
fastify.put<{ Params: { id: string } }>('/:id', { preHandler: requirePermission('customers.manage') }, async (request, reply) => {
const id = parseId(request.params.id, reply);
if (id === null) return;
const body = request.body as Record<string, unknown>;
const existing = await prisma.customers.findUnique({ where: { id } });
if (!existing) return error(reply, 'Zákazník nenalezen', 404);
await prisma.customers.update({
where: { id },
data: {
name: body.name !== undefined ? String(body.name) : undefined,
street: body.street !== undefined ? (body.street ? String(body.street) : null) : undefined,
city: body.city !== undefined ? (body.city ? String(body.city) : null) : undefined,
postal_code: body.postal_code !== undefined ? (body.postal_code ? String(body.postal_code) : null) : undefined,
country: body.country !== undefined ? (body.country ? String(body.country) : null) : undefined,
company_id: body.company_id !== undefined ? (body.company_id ? String(body.company_id) : null) : undefined,
vat_id: body.vat_id !== undefined ? (body.vat_id ? String(body.vat_id) : null) : undefined,
custom_fields: body.custom_fields !== undefined ? encodeCustomFields(body.custom_fields, body.customer_field_order) : undefined,
},
});
await logAudit({ request, authData: request.authData, action: 'update', entityType: 'customer', entityId: id, description: `Upraven zákazník ${existing.name}` });
return success(reply, { id }, 200, 'Zákazník byl uložen');
});
fastify.delete<{ Params: { id: string } }>('/:id', { preHandler: requirePermission('customers.manage') }, async (request, reply) => {
const id = parseId(request.params.id, reply);
if (id === null) return;
const existing = await prisma.customers.findUnique({ where: { id } });
if (!existing) return error(reply, 'Zákazník nenalezen', 404);
// Check for FK references before deleting
const [quotCount, orderCount, invoiceCount, projectCount] = await Promise.all([
prisma.quotations.count({ where: { customer_id: id } }),
prisma.orders.count({ where: { customer_id: id } }),
prisma.invoices.count({ where: { customer_id: id } }),
prisma.projects.count({ where: { customer_id: id } }),
]);
if (quotCount + orderCount + invoiceCount + projectCount > 0) {
return error(reply, 'Zákazníka nelze smazat — existují propojené nabídky, objednávky, faktury nebo projekty', 400);
}
await prisma.customers.delete({ where: { id } });
await logAudit({ request, authData: request.authData, action: 'delete', entityType: 'customer', entityId: id, description: `Smazán zákazník ${existing.name}` });
return success(reply, null, 200, 'Zákazník smazán');
});
}

View File

@@ -0,0 +1,252 @@
import { FastifyInstance } from 'fastify';
import prisma from '../../config/database';
import { requireAuth } from '../../middleware/auth';
import { success } from '../../utils/response';
export default async function dashboardRoutes(fastify: FastifyInstance): Promise<void> {
fastify.get('/', { preHandler: requireAuth }, async (request, reply) => {
const now = new Date();
const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate());
const todayEnd = new Date(now.getFullYear(), now.getMonth(), now.getDate() + 1);
const monthStart = new Date(now.getFullYear(), now.getMonth(), 1);
const monthEnd = new Date(now.getFullYear(), now.getMonth() + 1, 1);
const userId = request.authData!.userId;
const [
usersCount,
activeProjectsCount,
pendingOrdersCount,
unpaidInvoicesCount,
pendingLeaveRequests,
// Attendance
todayAttendance,
onLeaveToday,
// Offers / quotations
openQuotations,
convertedQuotations,
expiredQuotations,
quotationsThisMonth,
// Invoices
issuedInvoicesThisMonth,
// My shift
myShiftToday,
// Recent activity
recentActivity,
// Active projects list
activeProjectsList,
] = await Promise.all([
// Existing counts
prisma.users.count({ where: { is_active: true } }),
prisma.projects.count({ where: { status: 'aktivni' } }),
prisma.orders.count({ where: { status: 'prijata' } }),
prisma.invoices.count({ where: { status: 'issued' } }),
prisma.leave_requests.count({ where: { status: 'pending' } }),
// Attendance: today's WORK records with user info
prisma.attendance.findMany({
where: {
shift_date: { gte: todayStart, lt: todayEnd },
OR: [{ leave_type: null }, { leave_type: 'work' }],
},
include: { users: { select: { id: true, first_name: true, last_name: true } } },
orderBy: { arrival_time: 'asc' },
}),
// Users on leave today (attendance records with leave type)
prisma.attendance.findMany({
where: {
shift_date: { gte: todayStart, lt: todayEnd },
leave_type: { in: ['vacation', 'sick', 'holiday', 'unpaid'] },
},
include: { users: { select: { id: true, first_name: true, last_name: true } } },
}),
// Quotation stats
prisma.quotations.count({ where: { status: 'active' } }),
prisma.quotations.count({ where: { status: 'converted' } }),
prisma.quotations.count({ where: { status: 'expired' } }),
prisma.quotations.count({
where: { created_at: { gte: monthStart, lt: monthEnd } },
}),
// Invoice stats — this month's invoices
prisma.invoices.findMany({
where: {
issue_date: { gte: monthStart, lt: monthEnd },
},
include: { invoice_items: true },
}),
// My active (ongoing) shift — any unclosed shift, not just today
prisma.attendance.findFirst({
where: {
user_id: userId,
arrival_time: { not: null },
departure_time: null,
},
orderBy: { created_at: 'desc' },
}),
// Recent audit log activity (last 10)
prisma.audit_logs.findMany({
orderBy: { created_at: 'desc' },
take: 10,
select: {
id: true,
action: true,
entity_type: true,
description: true,
username: true,
created_at: true,
},
}),
// Active projects with customer
prisma.projects.findMany({
where: { status: 'aktivni' },
include: { customers: { select: { name: true } } },
orderBy: { created_at: 'desc' },
}),
]);
// Build attendance users list — deduplicate by user_id, keep latest record per user
// Match PHP status logic: in = working, away = on break, out = departed
const userAttendanceMap = new Map<number, typeof todayAttendance[0]>();
for (const a of todayAttendance) {
const existing = userAttendanceMap.get(a.users.id);
if (!existing || (a.arrival_time && existing.arrival_time && a.arrival_time > existing.arrival_time)) {
userAttendanceMap.set(a.users.id, a);
}
}
let presentCount = 0;
const attendanceUsers: Array<{
user_id: number; name: string; initials: string;
status: string; arrived_at: string | null; leave_type?: string;
}> = [];
// Work records — deduplicate by user, determine status
for (const a of userAttendanceMap.values()) {
const user = a.users;
const firstInitial = user.first_name?.charAt(0) ?? '';
const lastInitial = user.last_name?.charAt(0) ?? '';
let status: string = 'out';
if (a.arrival_time) {
if (a.departure_time) {
status = 'out';
} else if (a.break_start && !a.break_end) {
status = 'away';
} else {
status = 'in';
presentCount++;
}
}
attendanceUsers.push({
user_id: user.id,
name: `${user.first_name} ${user.last_name}`,
initials: `${firstInitial}${lastInitial}`.toUpperCase(),
status,
arrived_at: a.arrival_time ? a.arrival_time.toISOString() : null,
});
}
// Leave records — add users on leave with status 'leave' + leave_type (matching PHP)
const leaveUserIds = new Set<number>();
for (const a of onLeaveToday) {
if (leaveUserIds.has(a.users.id)) continue; // deduplicate
leaveUserIds.add(a.users.id);
const user = a.users;
const firstInitial = user.first_name?.charAt(0) ?? '';
const lastInitial = user.last_name?.charAt(0) ?? '';
attendanceUsers.push({
user_id: user.id,
name: `${user.first_name} ${user.last_name}`,
initials: `${firstInitial}${lastInitial}`.toUpperCase(),
status: 'leave',
arrived_at: null,
leave_type: (a.leave_type as string) || 'vacation',
});
}
// Compute invoice revenue this month grouped by currency
const revenueByCurrency: Record<string, number> = {};
for (const inv of issuedInvoicesThisMonth) {
const currency = inv.currency ?? 'CZK';
let total = 0;
for (const item of inv.invoice_items) {
const qty = item.quantity ? Number(item.quantity) : 0;
const price = item.unit_price ? Number(item.unit_price) : 0;
total += qty * price;
}
revenueByCurrency[currency] = (revenueByCurrency[currency] ?? 0) + total;
}
const revenueThisMonth = Object.entries(revenueByCurrency).map(([currency, amount]) => ({
amount: Math.round(amount * 100) / 100,
currency,
}));
const revenueCzk = revenueByCurrency['CZK'] != null
? Math.round(revenueByCurrency['CZK'] * 100) / 100
: null;
return success(reply, {
// Existing counts
users_count: usersCount,
active_projects: activeProjectsCount,
pending_orders: pendingOrdersCount,
unpaid_invoices: unpaidInvoicesCount,
pending_leave_requests: pendingLeaveRequests,
// Attendance data
attendance: {
present_today: presentCount,
total_active: usersCount,
on_leave: leaveUserIds.size,
users: attendanceUsers,
},
// Offers/quotations stats
offers: {
open_count: openQuotations,
converted_count: convertedQuotations,
expired_count: expiredQuotations,
created_this_month: quotationsThisMonth,
},
// Invoice revenue
invoices: {
revenue_this_month: revenueThisMonth,
unpaid_count: unpaidInvoicesCount,
revenue_czk: revenueCzk,
},
// Leave pending
leave_pending: { count: pendingLeaveRequests },
// Current user's shift status
my_shift: {
has_ongoing: myShiftToday !== null,
},
// Recent audit log activity
recent_activity: recentActivity.map((log) => ({
id: log.id,
action: log.action,
entity_type: log.entity_type ?? '',
description: log.description ?? '',
username: log.username ?? null,
created_at: log.created_at ? log.created_at.toISOString() : '',
})),
// Active projects list
projects: {
active_projects: activeProjectsList.map((p) => ({
id: p.id,
name: p.name ?? '',
customer_name: p.customers?.name ?? null,
})),
},
});
});
}

View File

@@ -0,0 +1,266 @@
import { FastifyInstance } from 'fastify';
import prisma from '../../config/database';
import { requirePermission } from '../../middleware/auth';
function formatDate(date: Date | string | null | undefined): string {
if (!date) return '';
const d = new Date(date);
return `${d.getDate()}.${d.getMonth() + 1}.${d.getFullYear()}`;
}
function formatNumber(n: number): string {
return n.toLocaleString('cs-CZ', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
}
function escapeHtml(str: string | null | undefined): string {
if (!str) return '';
return str.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
const LABELS: Record<string, Record<string, string>> = {
cs: {
invoice: 'Faktura',
invoice_number: 'Číslo faktury',
issue_date: 'Datum vystavení',
due_date: 'Datum splatnosti',
tax_date: 'Datum zdanitelného plnění',
payment_method: 'Způsob platby',
variable_symbol: 'Variabilní symbol',
constant_symbol: 'Konstantní symbol',
bank: 'Banka',
iban: 'IBAN',
swift: 'SWIFT',
account: 'Číslo účtu',
supplier: 'Dodavatel',
customer: 'Odběratel',
ico: 'IČO',
dic: 'DIČ',
description: 'Popis',
qty: 'Množství',
unit: 'Jednotka',
unit_price: 'Cena/ks',
vat: 'DPH %',
total: 'Celkem',
subtotal: 'Základ',
vat_total: 'DPH',
grand_total: 'Celkem k úhradě',
paid_date: 'Datum úhrady',
issued_by: 'Vystavil',
notes: 'Poznámky',
order_number: 'Objednávka',
currency: 'Měna',
},
en: {
invoice: 'Invoice',
invoice_number: 'Invoice number',
issue_date: 'Issue date',
due_date: 'Due date',
tax_date: 'Tax date',
payment_method: 'Payment method',
variable_symbol: 'Variable symbol',
constant_symbol: 'Constant symbol',
bank: 'Bank',
iban: 'IBAN',
swift: 'SWIFT',
account: 'Account number',
supplier: 'Supplier',
customer: 'Customer',
ico: 'Company ID',
dic: 'VAT ID',
description: 'Description',
qty: 'Qty',
unit: 'Unit',
unit_price: 'Unit price',
vat: 'VAT %',
total: 'Total',
subtotal: 'Subtotal',
vat_total: 'VAT',
grand_total: 'Total due',
paid_date: 'Paid date',
issued_by: 'Issued by',
notes: 'Notes',
order_number: 'Order',
currency: 'Currency',
},
};
export default async function invoicesPdfRoutes(fastify: FastifyInstance): Promise<void> {
fastify.get<{ Params: { id: string } }>('/:id', { preHandler: requirePermission('invoices.view') }, async (request, reply) => {
const id = parseInt(request.params.id, 10);
const query = request.query as Record<string, string>;
const lang = query.lang === 'en' ? 'en' : 'cs';
const L = LABELS[lang];
const invoice = await prisma.invoices.findUnique({
where: { id },
include: {
customers: true,
invoice_items: { orderBy: { position: 'asc' } },
orders: { select: { order_number: true } },
},
});
if (!invoice) {
return reply.status(404).type('text/html').send('<html><body><h1>Faktura nenalezena</h1></body></html>');
}
const settings = await prisma.company_settings.findFirst();
// Compute totals
const items = invoice.invoice_items.map(item => {
const qty = Number(item.quantity) || 0;
const price = Number(item.unit_price) || 0;
const vatRate = Number(item.vat_rate) || Number(invoice.vat_rate) || 21;
const lineTotal = qty * price;
const lineVat = invoice.apply_vat ? lineTotal * (vatRate / 100) : 0;
return { ...item, qty, price, vatRate, lineTotal, lineVat };
});
const subtotal = items.reduce((s, i) => s + i.lineTotal, 0);
const vatTotal = items.reduce((s, i) => s + i.lineVat, 0);
const grandTotal = subtotal + vatTotal;
// Logo as base64
let logoHtml = '';
if (settings?.logo_data) {
const buf = Buffer.from(settings.logo_data);
let mime = 'image/png';
if (buf[0] === 0xFF && buf[1] === 0xD8) mime = 'image/jpeg';
else if (buf[0] === 0x47 && buf[1] === 0x49) mime = 'image/gif';
const b64 = buf.toString('base64');
logoHtml = `<img src="data:${mime};base64,${b64}" style="max-height:60px;max-width:200px;" />`;
}
const cust = invoice.customers;
const html = `<!DOCTYPE html>
<html lang="${lang}">
<head>
<meta charset="utf-8">
<title>${L.invoice} ${escapeHtml(invoice.invoice_number)}</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: 'Segoe UI', Arial, sans-serif; font-size: 12px; color: #333; padding: 20px; }
@page { size: A4; margin: 15mm; }
@media print { body { padding: 0; } .no-print { display: none; } }
.header { display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 20px; border-bottom: 2px solid #2563eb; padding-bottom: 15px; }
.header-left { flex: 1; }
.header-right { text-align: right; }
.company-name { font-size: 18px; font-weight: 700; color: #1e40af; }
.invoice-title { font-size: 22px; font-weight: 700; color: #1e40af; margin-bottom: 5px; }
.invoice-number { font-size: 14px; color: #666; }
.parties { display: flex; gap: 40px; margin: 20px 0; }
.party { flex: 1; padding: 12px; background: #f8fafc; border-radius: 6px; border: 1px solid #e2e8f0; }
.party-title { font-weight: 700; font-size: 11px; text-transform: uppercase; color: #64748b; margin-bottom: 8px; letter-spacing: 0.5px; }
.party-name { font-weight: 700; font-size: 14px; margin-bottom: 4px; }
.meta-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; margin: 20px 0; }
.meta-item { display: flex; gap: 8px; }
.meta-label { font-weight: 600; color: #64748b; min-width: 160px; }
.meta-value { color: #1e293b; }
table { width: 100%; border-collapse: collapse; margin: 20px 0; }
thead th { background: #1e40af; color: white; padding: 8px 10px; text-align: left; font-size: 11px; text-transform: uppercase; letter-spacing: 0.3px; }
thead th:last-child, thead th.num { text-align: right; }
tbody td { padding: 8px 10px; border-bottom: 1px solid #e2e8f0; }
tbody td.num { text-align: right; font-variant-numeric: tabular-nums; }
tbody tr:nth-child(even) { background: #f8fafc; }
.totals { margin-left: auto; width: 280px; margin-top: 10px; }
.totals-row { display: flex; justify-content: space-between; padding: 6px 0; border-bottom: 1px solid #e2e8f0; }
.totals-row.grand { font-weight: 700; font-size: 16px; color: #1e40af; border-top: 2px solid #1e40af; border-bottom: none; padding-top: 10px; }
.notes { margin-top: 20px; padding: 12px; background: #fffbeb; border: 1px solid #fde68a; border-radius: 6px; }
.notes-title { font-weight: 700; margin-bottom: 4px; }
.footer { margin-top: 30px; font-size: 10px; color: #94a3b8; text-align: center; border-top: 1px solid #e2e8f0; padding-top: 10px; }
</style>
</head>
<body>
<div class="header">
<div class="header-left">
${logoHtml}
<div class="company-name">${escapeHtml(settings?.company_name)}</div>
<div>${escapeHtml(settings?.street)}</div>
<div>${escapeHtml(settings?.city)} ${escapeHtml(settings?.postal_code)}</div>
${settings?.company_id ? `<div>${L.ico}: ${escapeHtml(settings.company_id)}</div>` : ''}
${settings?.vat_id ? `<div>${L.dic}: ${escapeHtml(settings.vat_id)}</div>` : ''}
</div>
<div class="header-right">
<div class="invoice-title">${L.invoice}</div>
<div class="invoice-number">${escapeHtml(invoice.invoice_number)}</div>
</div>
</div>
<div class="parties">
<div class="party">
<div class="party-title">${L.supplier}</div>
<div class="party-name">${escapeHtml(settings?.company_name)}</div>
<div>${escapeHtml(settings?.street)}</div>
<div>${escapeHtml(settings?.city)} ${escapeHtml(settings?.postal_code)}</div>
${settings?.company_id ? `<div>${L.ico}: ${escapeHtml(settings.company_id)}</div>` : ''}
${settings?.vat_id ? `<div>${L.dic}: ${escapeHtml(settings.vat_id)}</div>` : ''}
</div>
<div class="party">
<div class="party-title">${L.customer}</div>
<div class="party-name">${escapeHtml(cust?.name)}</div>
<div>${escapeHtml(cust?.street)}</div>
<div>${escapeHtml(cust?.city)} ${escapeHtml(cust?.postal_code)}</div>
${cust?.company_id ? `<div>${L.ico}: ${escapeHtml(cust.company_id)}</div>` : ''}
${cust?.vat_id ? `<div>${L.dic}: ${escapeHtml(cust.vat_id)}</div>` : ''}
</div>
</div>
<div class="meta-grid">
<div class="meta-item"><span class="meta-label">${L.invoice_number}:</span><span class="meta-value">${escapeHtml(invoice.invoice_number)}</span></div>
<div class="meta-item"><span class="meta-label">${L.issue_date}:</span><span class="meta-value">${formatDate(invoice.issue_date)}</span></div>
<div class="meta-item"><span class="meta-label">${L.due_date}:</span><span class="meta-value">${formatDate(invoice.due_date)}</span></div>
<div class="meta-item"><span class="meta-label">${L.tax_date}:</span><span class="meta-value">${formatDate(invoice.tax_date)}</span></div>
${invoice.payment_method ? `<div class="meta-item"><span class="meta-label">${L.payment_method}:</span><span class="meta-value">${escapeHtml(invoice.payment_method)}</span></div>` : ''}
<div class="meta-item"><span class="meta-label">${L.variable_symbol}:</span><span class="meta-value">${escapeHtml(invoice.invoice_number)}</span></div>
${invoice.constant_symbol ? `<div class="meta-item"><span class="meta-label">${L.constant_symbol}:</span><span class="meta-value">${escapeHtml(invoice.constant_symbol)}</span></div>` : ''}
${invoice.bank_name ? `<div class="meta-item"><span class="meta-label">${L.bank}:</span><span class="meta-value">${escapeHtml(invoice.bank_name)}</span></div>` : ''}
${invoice.bank_iban ? `<div class="meta-item"><span class="meta-label">${L.iban}:</span><span class="meta-value">${escapeHtml(invoice.bank_iban)}</span></div>` : ''}
${invoice.bank_swift ? `<div class="meta-item"><span class="meta-label">${L.swift}:</span><span class="meta-value">${escapeHtml(invoice.bank_swift)}</span></div>` : ''}
${invoice.bank_account ? `<div class="meta-item"><span class="meta-label">${L.account}:</span><span class="meta-value">${escapeHtml(invoice.bank_account)}</span></div>` : ''}
<div class="meta-item"><span class="meta-label">${L.currency}:</span><span class="meta-value">${escapeHtml(invoice.currency)}</span></div>
${invoice.orders?.order_number ? `<div class="meta-item"><span class="meta-label">${L.order_number}:</span><span class="meta-value">${escapeHtml(invoice.orders.order_number)}</span></div>` : ''}
${invoice.issued_by ? `<div class="meta-item"><span class="meta-label">${L.issued_by}:</span><span class="meta-value">${escapeHtml(invoice.issued_by)}</span></div>` : ''}
${invoice.paid_date ? `<div class="meta-item"><span class="meta-label">${L.paid_date}:</span><span class="meta-value">${formatDate(invoice.paid_date)}</span></div>` : ''}
</div>
<table>
<thead>
<tr>
<th style="width:40px;">#</th>
<th>${L.description}</th>
<th class="num" style="width:70px;">${L.qty}</th>
<th style="width:60px;">${L.unit}</th>
<th class="num" style="width:100px;">${L.unit_price}</th>
${invoice.apply_vat ? `<th class="num" style="width:60px;">${L.vat}</th>` : ''}
<th class="num" style="width:110px;">${L.total}</th>
</tr>
</thead>
<tbody>
${items.map((item, i) => `
<tr>
<td>${i + 1}</td>
<td>${escapeHtml(item.description)}</td>
<td class="num">${formatNumber(item.qty)}</td>
<td>${escapeHtml(item.unit)}</td>
<td class="num">${formatNumber(item.price)}</td>
${invoice.apply_vat ? `<td class="num">${item.vatRate}%</td>` : ''}
<td class="num">${formatNumber(item.lineTotal)}</td>
</tr>
`).join('')}
</tbody>
</table>
<div class="totals">
<div class="totals-row"><span>${L.subtotal}:</span><span>${formatNumber(subtotal)} ${invoice.currency || 'CZK'}</span></div>
${invoice.apply_vat ? `<div class="totals-row"><span>${L.vat_total}:</span><span>${formatNumber(vatTotal)} ${invoice.currency || 'CZK'}</span></div>` : ''}
<div class="totals-row grand"><span>${L.grand_total}:</span><span>${formatNumber(grandTotal)} ${invoice.currency || 'CZK'}</span></div>
</div>
${invoice.notes ? `<div class="notes"><div class="notes-title">${L.notes}:</div><div>${escapeHtml(invoice.notes)}</div></div>` : ''}
</body>
</html>`;
return reply.type('text/html').send(html);
});
}

View File

@@ -0,0 +1,373 @@
import { FastifyInstance } from 'fastify';
import prisma from '../../config/database';
import { requirePermission } from '../../middleware/auth';
import { logAudit } from '../../services/audit';
import { success, error, parseId } from '../../utils/response';
import { parsePagination, buildPaginationMeta } from '../../utils/pagination';
import { getNextNumber } from '../../utils/sequence';
// Status transition rules matching PHP
const VALID_TRANSITIONS: Record<string, string[]> = {
issued: ['paid'],
overdue: ['paid'],
paid: [],
};
const ALLOWED_SORT_FIELDS = ['id', 'invoice_number', 'status', 'issue_date', 'due_date', 'currency'];
interface InvoiceItemInput { description?: string; quantity?: number; unit?: string; unit_price?: number; vat_rate?: number; position?: number }
function computeInvoiceTotals(items: Array<{ quantity: unknown; unit_price: unknown; vat_rate: unknown }>, applyVat: boolean | null, defaultVatRate: unknown) {
const subtotal = items.reduce((s, i) => s + (Number(i.quantity) || 0) * (Number(i.unit_price) || 0), 0);
const vatAmount = applyVat
? items.reduce((s, i) => {
const base = (Number(i.quantity) || 0) * (Number(i.unit_price) || 0);
return s + base * ((Number(i.vat_rate) || Number(defaultVatRate) || 21) / 100);
}, 0)
: 0;
return {
subtotal: Math.round(subtotal * 100) / 100,
vat_amount: Math.round(vatAmount * 100) / 100,
total: Math.round((subtotal + vatAmount) * 100) / 100,
};
}
export default async function invoicesRoutes(fastify: FastifyInstance): Promise<void> {
// Auto-update overdue invoices on GET requests only (matches PHP behavior)
fastify.addHook('onRequest', async (request) => {
if (request.method !== 'GET') return;
try {
await prisma.invoices.updateMany({
where: { status: 'issued', due_date: { lt: new Date() } },
data: { status: 'overdue' },
});
} catch { /* silent */ }
});
// GET /api/admin/invoices
fastify.get('/', { preHandler: requirePermission('invoices.view') }, async (request, reply) => {
const query = request.query as Record<string, unknown>;
const { page, limit, skip, order, search } = parsePagination(query);
const where: Record<string, unknown> = {};
if (query.status) where.status = String(query.status);
if (query.customer_id) where.customer_id = Number(query.customer_id);
if (search) {
where.OR = [
{ invoice_number: { contains: search } },
{ customers: { name: { contains: search } } },
{ customers: { company_id: { contains: search } } },
];
}
const sortField = ALLOWED_SORT_FIELDS.includes(String(query.sort || '')) ? String(query.sort) : 'id';
const orderBy: Record<string, string> = { [sortField]: order };
const [invoices, total] = await Promise.all([
prisma.invoices.findMany({
where,
skip,
take: limit,
orderBy,
include: {
customers: { select: { id: true, name: true } },
invoice_items: true,
orders: { select: { id: true, order_number: true } },
},
}),
prisma.invoices.count({ where }),
]);
const enriched = invoices.map(inv => {
const totals = computeInvoiceTotals(inv.invoice_items, inv.apply_vat, inv.vat_rate);
const { invoice_items, ...rest } = inv;
return {
...rest,
items: invoice_items,
customer_name: inv.customers?.name || null,
order_number: inv.orders?.order_number || null,
...totals,
};
});
return reply.send({ success: true, data: enriched, pagination: buildPaginationMeta(total, page, limit) });
});
// GET /api/admin/invoices/next-number
fastify.get('/next-number', { preHandler: requirePermission('invoices.create') }, async (_request, reply) => {
// Match PHP: prefix = YY + invoice_type_code from company_settings
const settings = await prisma.company_settings.findFirst({ select: { invoice_type_code: true } });
const typeCode = settings?.invoice_type_code || '81';
const year = new Date().getFullYear();
const yy = String(year).slice(-2);
const prefix = `${yy}${typeCode}`;
// Atomic numbering via number_sequences table
const nextNum = await getNextNumber('invoice', year);
const number = `${prefix}${String(nextNum).padStart(4, '0')}`;
return success(reply, { number, next_number: number });
});
// GET /api/admin/invoices/stats
fastify.get('/stats', { preHandler: requirePermission('invoices.view') }, async (request, reply) => {
const query = request.query as Record<string, unknown>;
const now = new Date();
const year = Number(query.year) || now.getFullYear();
const month = Number(query.month) || (now.getMonth() + 1);
const monthStart = new Date(year, month - 1, 1);
const monthEnd = new Date(year, month, 0, 23, 59, 59);
const allInvoices = await prisma.invoices.findMany({
include: { invoice_items: true },
});
// Helper: compute invoice total WITH VAT (matching PHP)
const invoiceTotalWithVat = (inv: typeof allInvoices[0]) => {
const sub = inv.invoice_items.reduce((s, i) => s + (Number(i.quantity) || 0) * (Number(i.unit_price) || 0), 0);
const vat = inv.apply_vat
? inv.invoice_items.reduce((s, i) => {
const base = (Number(i.quantity) || 0) * (Number(i.unit_price) || 0);
return s + base * ((Number(i.vat_rate) || Number(inv.vat_rate) || 21) / 100);
}, 0)
: 0;
return sub + vat;
};
// Helper: aggregate by currency → CurrencyAmount[]
const aggregateByCurrency = (invoices: typeof allInvoices) => {
const map: Record<string, number> = {};
for (const inv of invoices) {
const cur = inv.currency || 'CZK';
map[cur] = (map[cur] || 0) + invoiceTotalWithVat(inv);
}
return Object.entries(map).filter(([, v]) => v > 0).map(([currency, amount]) => ({ amount: Math.round(amount * 100) / 100, currency }));
};
const sumCzk = (invoices: typeof allInvoices) => {
let total = 0;
for (const inv of invoices) {
total += invoiceTotalWithVat(inv); // Simplified: no real FX conversion
}
return Math.round(total * 100) / 100;
};
const monthInvoices = allInvoices.filter(inv => {
const issueDate = inv.issue_date ? new Date(inv.issue_date) : null;
return issueDate && issueDate >= monthStart && issueDate <= monthEnd;
});
const paidInvoices = monthInvoices.filter(i => i.status === 'paid');
const awaitingInvoices = allInvoices.filter(i => i.status === 'issued');
const overdueInvoices = allInvoices.filter(i => i.status === 'overdue');
// VAT by currency
const vatMap: Record<string, number> = {};
for (const inv of monthInvoices) {
if (!inv.apply_vat) continue;
const cur = inv.currency || 'CZK';
for (const item of inv.invoice_items) {
const base = (Number(item.quantity) || 0) * (Number(item.unit_price) || 0);
const vat = base * ((Number(item.vat_rate) || Number(inv.vat_rate) || 21) / 100);
vatMap[cur] = (vatMap[cur] || 0) + vat;
}
}
const vatAmounts = Object.entries(vatMap).filter(([, v]) => v > 0).map(([currency, amount]) => ({ amount: Math.round(amount * 100) / 100, currency }));
let vatCzk = 0;
for (const [, v] of Object.entries(vatMap)) vatCzk += v;
return success(reply, {
paid_month: aggregateByCurrency(paidInvoices),
paid_month_czk: sumCzk(paidInvoices),
paid_month_count: paidInvoices.length,
awaiting: aggregateByCurrency(awaitingInvoices),
awaiting_czk: sumCzk(awaitingInvoices),
awaiting_count: awaitingInvoices.length,
overdue: aggregateByCurrency(overdueInvoices),
overdue_czk: sumCzk(overdueInvoices),
overdue_count: overdueInvoices.length,
vat_month: vatAmounts,
vat_month_czk: Math.round(vatCzk * 100) / 100,
month,
year,
});
});
// GET /api/admin/invoices/order-data/:id
fastify.get<{ Params: { id: string } }>('/order-data/:id', { preHandler: requirePermission('invoices.create') }, async (request, reply) => {
const orderId = parseId(request.params.id, reply);
if (orderId === null) return;
const order = await prisma.orders.findUnique({
where: { id: orderId },
include: {
customers: true,
order_items: { orderBy: { position: 'asc' } },
},
});
if (!order) return error(reply, 'Objednávka nenalezena', 404);
const { order_items, customers, ...rest } = order;
return success(reply, { ...rest, items: order_items, customer_name: customers?.name || null });
});
// GET /api/admin/invoices/:id
fastify.get<{ Params: { id: string } }>('/:id', { preHandler: requirePermission('invoices.view') }, async (request, reply) => {
const id = parseId(request.params.id, reply);
if (id === null) return;
const invoice = await prisma.invoices.findUnique({
where: { id },
include: {
customers: true,
invoice_items: { orderBy: { position: 'asc' } },
orders: { select: { id: true, order_number: true } },
},
});
if (!invoice) return error(reply, 'Faktura nenalezena', 404);
const { invoice_items, ...rest } = invoice;
return success(reply, {
...rest,
items: invoice_items,
customer: invoice.customers,
customer_name: invoice.customers?.name || null,
order_number: invoice.orders?.order_number || null,
valid_transitions: VALID_TRANSITIONS[invoice.status as string] || [],
});
});
// POST /api/admin/invoices
fastify.post('/', { preHandler: requirePermission('invoices.create') }, async (request, reply) => {
const body = request.body as Record<string, unknown>;
const invoice = await prisma.invoices.create({
data: {
invoice_number: body.invoice_number ? String(body.invoice_number) : null,
order_id: body.order_id ? Number(body.order_id) : null,
customer_id: body.customer_id ? Number(body.customer_id) : null,
status: body.status ? String(body.status) : 'issued',
currency: body.currency ? String(body.currency) : 'CZK',
vat_rate: body.vat_rate ? Number(body.vat_rate) : 21.0,
apply_vat: body.apply_vat !== false,
payment_method: body.payment_method ? String(body.payment_method) : null,
constant_symbol: body.constant_symbol ? String(body.constant_symbol) : null,
bank_name: body.bank_name ? String(body.bank_name) : null,
bank_swift: body.bank_swift ? String(body.bank_swift) : null,
bank_iban: body.bank_iban ? String(body.bank_iban) : null,
bank_account: body.bank_account ? String(body.bank_account) : null,
issue_date: body.issue_date ? new Date(String(body.issue_date)) : null,
due_date: body.due_date ? new Date(String(body.due_date)) : null,
tax_date: body.tax_date ? new Date(String(body.tax_date)) : null,
issued_by: body.issued_by ? String(body.issued_by) : null,
notes: body.notes ? String(body.notes) : null,
internal_notes: body.internal_notes ? String(body.internal_notes) : null,
},
});
if (Array.isArray(body.items)) {
await prisma.invoice_items.createMany({
data: (body.items as InvoiceItemInput[]).map((item, i) => ({
invoice_id: invoice.id,
description: item.description ?? null,
quantity: item.quantity ?? 1,
unit: item.unit ?? null,
unit_price: item.unit_price ?? 0,
vat_rate: item.vat_rate ?? 21.0,
position: item.position ?? i,
})),
});
}
await logAudit({ request, authData: request.authData, action: 'create', entityType: 'invoice', entityId: invoice.id, description: `Vytvořena faktura ${invoice.invoice_number}` });
// Return both invoice_id and id for frontend compatibility
return success(reply, { id: invoice.id, invoice_id: invoice.id, invoice_number: invoice.invoice_number }, 201, 'Faktura byla vystavena');
});
// PUT /api/admin/invoices/:id
fastify.put<{ Params: { id: string } }>('/:id', { preHandler: requirePermission('invoices.edit') }, async (request, reply) => {
const id = parseId(request.params.id, reply);
if (id === null) return;
const body = request.body as Record<string, unknown>;
const existing = await prisma.invoices.findUnique({ where: { id } });
if (!existing) return error(reply, 'Faktura nenalezena', 404);
const currentStatus = existing.status as string;
// Handle status transition
if (body.status !== undefined && body.status !== currentStatus) {
const newStatus = String(body.status);
const allowed = VALID_TRANSITIONS[currentStatus] || [];
if (!allowed.includes(newStatus)) {
return error(reply, `Neplatný přechod stavu z "${currentStatus}" na "${newStatus}"`, 400);
}
}
const data: Record<string, unknown> = { modified_at: new Date() };
// Only allow full editing in 'issued' state
const isDraft = currentStatus === 'issued';
if (isDraft) {
const strFields = ['currency', 'payment_method', 'constant_symbol', 'bank_name', 'bank_swift', 'bank_iban', 'bank_account', 'issued_by'];
for (const f of strFields) {
if (body[f] !== undefined) data[f] = body[f] ? String(body[f]) : null;
}
if (body.customer_id !== undefined) data.customer_id = body.customer_id ? Number(body.customer_id) : null;
if (body.vat_rate !== undefined) data.vat_rate = Number(body.vat_rate);
if (body.apply_vat !== undefined) data.apply_vat = body.apply_vat === true || body.apply_vat === 1 || body.apply_vat === '1';
if (body.issue_date !== undefined) data.issue_date = body.issue_date ? new Date(String(body.issue_date)) : null;
if (body.due_date !== undefined) data.due_date = body.due_date ? new Date(String(body.due_date)) : null;
if (body.tax_date !== undefined) data.tax_date = body.tax_date ? new Date(String(body.tax_date)) : null;
}
// Notes editable in issued/overdue
if (currentStatus === 'issued' || currentStatus === 'overdue') {
if (body.notes !== undefined) data.notes = body.notes ? String(body.notes) : null;
if (body.internal_notes !== undefined) data.internal_notes = body.internal_notes ? String(body.internal_notes) : null;
}
// Status change
if (body.status !== undefined) {
data.status = String(body.status);
// Auto-set paid_date when transitioning to paid
if (String(body.status) === 'paid' && !existing.paid_date) {
data.paid_date = new Date();
}
}
if (body.paid_date !== undefined) data.paid_date = body.paid_date ? new Date(String(body.paid_date)) : null;
await prisma.invoices.update({ where: { id }, data });
// Only allow items update in draft state
if (isDraft && Array.isArray(body.items)) {
await prisma.$transaction(async (tx) => {
await tx.invoice_items.deleteMany({ where: { invoice_id: id } });
await tx.invoice_items.createMany({
data: (body.items as InvoiceItemInput[]).map((item, i) => ({
invoice_id: id,
description: item.description ?? null,
quantity: item.quantity ?? 1,
unit: item.unit ?? null,
unit_price: item.unit_price ?? 0,
vat_rate: item.vat_rate ?? 21.0,
position: item.position ?? i,
})),
});
});
}
await logAudit({ request, authData: request.authData, action: 'update', entityType: 'invoice', entityId: id, description: `Upravena faktura ${existing.invoice_number}` });
return success(reply, { id }, 200, 'Faktura byla aktualizována');
});
// DELETE /api/admin/invoices/:id
fastify.delete<{ Params: { id: string } }>('/:id', { preHandler: requirePermission('invoices.delete') }, async (request, reply) => {
const id = parseId(request.params.id, reply);
if (id === null) return;
const existing = await prisma.invoices.findUnique({ where: { id } });
if (!existing) return error(reply, 'Faktura nenalezena', 404);
await prisma.invoices.delete({ where: { id } });
await logAudit({ request, authData: request.authData, action: 'delete', entityType: 'invoice', entityId: id, description: `Smazána faktura ${existing.invoice_number}` });
return success(reply, null, 200, 'Faktura smazána');
});
}

View File

@@ -0,0 +1,238 @@
import { FastifyInstance } from 'fastify';
import { attendance_leave_type, leave_requests_leave_type, leave_requests_status } from '@prisma/client';
import prisma from '../../config/database';
import { requireAuth, requirePermission } from '../../middleware/auth';
import { logAudit } from '../../services/audit';
import { success, error, parseId } from '../../utils/response';
import { parsePagination, buildPaginationMeta } from '../../utils/pagination';
const VALID_LEAVE_TYPES = ['vacation', 'sick', 'unpaid'] as const;
const VALID_REVIEW_STATUSES = ['approved', 'rejected'] as const;
export default async function leaveRequestsRoutes(fastify: FastifyInstance): Promise<void> {
fastify.get('/', { preHandler: requireAuth }, async (request, reply) => {
const query = request.query as Record<string, unknown>;
const { page, limit, skip, order } = parsePagination(query);
const authData = request.authData!;
const isAdmin = authData.permissions.includes('attendance.approve');
const where: Record<string, unknown> = {};
if (!isAdmin) where.user_id = authData.userId;
else if (query.user_id) where.user_id = Number(query.user_id);
if (query.status) where.status = String(query.status);
const [requests, total] = await Promise.all([
prisma.leave_requests.findMany({
where, skip, take: limit, orderBy: { created_at: order },
include: {
users_leave_requests_user_idTousers: { select: { id: true, first_name: true, last_name: true } },
users_leave_requests_reviewer_idTousers: { select: { id: true, first_name: true, last_name: true } },
},
}),
prisma.leave_requests.count({ where }),
]);
return reply.send({ success: true, data: requests, pagination: buildPaginationMeta(total, page, limit) });
});
fastify.post('/', { preHandler: requireAuth }, async (request, reply) => {
const body = request.body as Record<string, unknown>;
const authData = request.authData!;
const leaveType = String(body.leave_type || '');
if (!VALID_LEAVE_TYPES.includes(leaveType as typeof VALID_LEAVE_TYPES[number])) {
return error(reply, 'Neplatný typ nepřítomnosti', 400);
}
if (!body.date_from || !body.date_to) {
return error(reply, 'Datum od a do je povinné', 400);
}
const dateFrom = new Date(String(body.date_from));
const dateTo = new Date(String(body.date_to));
if (isNaN(dateFrom.getTime()) || isNaN(dateTo.getTime())) {
return error(reply, 'Neplatné datum', 400);
}
if (dateTo < dateFrom) {
return error(reply, 'Datum do musí být po datu od', 400);
}
// Compute business days server-side (matching PHP logic)
let businessDays = 0;
const current = new Date(dateFrom);
while (current <= dateTo) {
const day = current.getDay();
if (day !== 0 && day !== 6) businessDays++;
current.setDate(current.getDate() + 1);
}
if (businessDays === 0) {
return error(reply, 'Zvolený rozsah neobsahuje žádné pracovní dny', 400);
}
const leaveRequest = await prisma.leave_requests.create({
data: {
user_id: authData.userId,
leave_type: leaveType as leave_requests_leave_type,
date_from: dateFrom,
date_to: dateTo,
total_hours: businessDays * 8,
total_days: businessDays,
notes: body.notes ? String(body.notes) : null,
status: 'pending',
},
});
await logAudit({ request, authData, action: 'create', entityType: 'leave_request', entityId: leaveRequest.id, description: `Vytvořena žádost o nepřítomnost` });
return success(reply, { id: leaveRequest.id }, 201, 'Žádost byla odeslána ke schválení');
});
// PUT /api/admin/leave-requests/:id (approve/reject)
fastify.put<{ Params: { id: string } }>('/:id', { preHandler: requirePermission('attendance.approve') }, async (request, reply) => {
const id = parseId(request.params.id, reply);
if (id === null) return;
const body = request.body as Record<string, unknown>;
const authData = request.authData!;
const status = String(body.status || '');
if (!VALID_REVIEW_STATUSES.includes(status as typeof VALID_REVIEW_STATUSES[number])) {
return error(reply, 'Neplatný stav', 400);
}
const existing = await prisma.leave_requests.findUnique({ where: { id } });
if (!existing) return error(reply, 'Žádost nenalezena', 404);
if (existing.status !== 'pending') {
return error(reply, 'Lze schválit/zamítnout pouze čekající žádosti', 400);
}
if (status === 'approved') {
// --- APPROVAL: create attendance records + update leave balance (matching PHP) ---
const leaveType = existing.leave_type as string;
const dateFrom = new Date(existing.date_from);
const dateTo = new Date(existing.date_to);
// For vacation: re-check balance at approval time
if (leaveType === 'vacation') {
const year = dateFrom.getFullYear();
const balance = await prisma.leave_balances.findFirst({
where: { user_id: existing.user_id, year },
});
const vacTotal = balance ? Number(balance.vacation_total) : 160;
const vacUsed = balance ? Number(balance.vacation_used) : 0;
const vacRemaining = vacTotal - vacUsed;
const totalHours = Number(existing.total_hours) || 0;
if (totalHours > vacRemaining) {
return error(reply, `Nedostatek dovolené. Zbývá ${vacRemaining}h, požadováno ${totalHours}h.`, 400);
}
}
// Count business days and create attendance records
let totalBusinessDays = 0;
const current = new Date(dateFrom);
const attendanceCreates: Array<{
user_id: number;
shift_date: Date;
leave_type: attendance_leave_type;
leave_hours: number;
notes: string;
}> = [];
while (current <= dateTo) {
const dow = current.getDay();
if (dow !== 0 && dow !== 6) {
totalBusinessDays++;
attendanceCreates.push({
user_id: existing.user_id,
shift_date: new Date(Date.UTC(current.getFullYear(), current.getMonth(), current.getDate(), 12, 0, 0)),
leave_type: leaveType as attendance_leave_type,
leave_hours: 8,
notes: `Schválená žádost #${id}`,
});
}
current.setDate(current.getDate() + 1);
}
const totalHours = totalBusinessDays * 8;
// Run everything in a transaction
await prisma.$transaction(async (tx) => {
// 1. Create attendance records for each business day
if (attendanceCreates.length > 0) {
await tx.attendance.createMany({ data: attendanceCreates });
}
// 2. Update leave balance (vacation/sick only — not unpaid)
if (leaveType === 'vacation' || leaveType === 'sick') {
const year = dateFrom.getFullYear();
const existingBalance = await tx.leave_balances.findFirst({
where: { user_id: existing.user_id, year },
});
if (existingBalance) {
const updateData: Record<string, unknown> = { updated_at: new Date() };
if (leaveType === 'vacation') {
updateData.vacation_used = Number(existingBalance.vacation_used) + totalHours;
} else {
updateData.sick_used = Number(existingBalance.sick_used) + totalHours;
}
await tx.leave_balances.update({ where: { id: existingBalance.id }, data: updateData });
} else {
await tx.leave_balances.create({
data: {
user_id: existing.user_id,
year,
vacation_total: 160,
vacation_used: leaveType === 'vacation' ? totalHours : 0,
sick_used: leaveType === 'sick' ? totalHours : 0,
},
});
}
}
// 3. Update request status
await tx.leave_requests.update({
where: { id },
data: {
status: 'approved' as leave_requests_status,
reviewer_id: authData.userId,
reviewed_at: new Date(),
},
});
});
await logAudit({ request, authData, action: 'update', entityType: 'leave_request', entityId: id, description: `Žádost schválena — vytvořeno ${totalBusinessDays} záznamů (${totalHours}h)` });
return success(reply, { id }, 200, 'Žádost byla schválena');
}
// --- REJECTION: just update status ---
await prisma.leave_requests.update({
where: { id },
data: {
status: 'rejected' as leave_requests_status,
reviewer_id: authData.userId,
reviewer_note: body.reviewer_note ? String(body.reviewer_note) : null,
reviewed_at: new Date(),
},
});
await logAudit({ request, authData, action: 'update', entityType: 'leave_request', entityId: id, description: 'Žádost zamítnuta' });
return success(reply, { id }, 200, 'Žádost byla zamítnuta');
});
fastify.delete<{ Params: { id: string } }>('/:id', { preHandler: requireAuth }, async (request, reply) => {
const id = parseId(request.params.id, reply);
if (id === null) return;
const existing = await prisma.leave_requests.findUnique({ where: { id } });
if (!existing) return error(reply, 'Žádost nenalezena', 404);
if (existing.status !== 'pending') {
return error(reply, 'Lze zrušit pouze čekající žádosti', 400);
}
await prisma.leave_requests.update({ where: { id }, data: { status: 'cancelled' } });
await logAudit({ request, authData: request.authData, action: 'update', entityType: 'leave_request', entityId: id, description: `Žádost zrušena` });
return success(reply, null, 200, 'Žádost zrušena');
});
}

View File

@@ -0,0 +1,721 @@
import { FastifyInstance } from 'fastify';
import prisma from '../../config/database';
import { requirePermission } from '../../middleware/auth';
function formatDate(date: Date | string | null | undefined): string {
if (!date) return '';
const d = new Date(date);
if (isNaN(d.getTime())) return String(date);
return `${String(d.getDate()).padStart(2, '0')}.${String(d.getMonth() + 1).padStart(2, '0')}.${d.getFullYear()}`;
}
/** Format number with comma decimal separator and non-breaking space thousands separator */
function formatNum(n: number, decimals: number): string {
const abs = Math.abs(n);
const fixed = abs.toFixed(decimals);
const [intPart, decPart] = fixed.split('.');
const withSep = intPart.replace(/\B(?=(\d{3})+(?!\d))/g, '\u00A0');
const result = decPart ? `${withSep},${decPart}` : withSep;
return n < 0 ? `-${result}` : result;
}
function formatCurrency(amount: number, currency: string): string {
const n = Number(amount) || 0;
switch (currency) {
case 'EUR': return `${formatNum(n, 2)} \u20AC`;
case 'USD': return `$${Math.abs(n).toFixed(2).replace(/\B(?=(\d{3})+(?!\d))/g, ',')}`;
case 'CZK': return `${formatNum(n, 2)} K\u010D`;
case 'GBP': return `\u00A3${Math.abs(n).toFixed(2).replace(/\B(?=(\d{3})+(?!\d))/g, ',')}`;
default: return `${formatNum(n, 2)} ${currency}`;
}
}
function escapeHtml(str: string | null | undefined): string {
if (!str) return '';
return str.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
/** Sanitize Quill HTML: keep safe tags, remove event handlers, merge adjacent spans */
function cleanQuillHtml(html: string | null | undefined): string {
if (!html) return '';
const allowedTags = '<p><br><strong><em><u><s><ul><ol><li><span><sub><sup><a><h1><h2><h3><h4><blockquote><pre>';
// Simple strip_tags equivalent: remove tags not in allowed list
let s = html;
// Remove dangerous tags with content
s = s.replace(/<(script|iframe|object|embed|style|link|meta|base|form|input|textarea|button|select|svg|math)[^>]*>[\s\S]*?<\/\1>/gi, '');
s = s.replace(/<(script|iframe|object|embed|style|link|meta|base|form|input|textarea|button|select|svg|math)[^>]*\/?>/gi, '');
// Strip event handlers
s = s.replace(/\s+on\w+\s*=\s*("[^"]*"|'[^']*'|[^\s>]*)/gi, '');
s = s.replace(/\s+on\w+\s*=\s*[^\s>]*/gi, '');
// Strip javascript: in href
s = s.replace(/href\s*=\s*["']?\s*javascript\s*:[^"'>\s]*/gi, 'href="#"');
// Replace &nbsp; with regular space (outside of tags)
s = s.replace(/(&nbsp;)/g, ' ');
// Merge adjacent spans with same attributes
let prev = '';
while (prev !== s) {
prev = s;
s = s.replace(/<span([^>]*)>(.*?)<\/span>\s*<span\1>/gs, '<span$1>$2');
}
return s;
}
interface AddressResult { name: string; lines: string[] }
function buildAddressLines(
entity: Record<string, unknown> | null,
isSupplier: boolean,
t: (key: string) => string,
): AddressResult {
if (!entity) return { name: '', lines: [] };
const nameKey = isSupplier ? 'company_name' : 'name';
const name = String(entity[nameKey] || '');
// Parse custom_fields
let cfData: Array<{ name?: string; value?: string; showLabel?: boolean }> = [];
let fieldOrder: string[] | null = null;
const raw = entity.custom_fields;
if (raw) {
const parsed = typeof raw === 'string' ? JSON.parse(raw) : raw;
if (parsed && typeof parsed === 'object') {
if ((parsed as Record<string, unknown>).fields) {
cfData = ((parsed as Record<string, unknown>).fields as typeof cfData) || [];
fieldOrder = ((parsed as Record<string, unknown>).field_order || (parsed as Record<string, unknown>).fieldOrder) as string[] | null;
} else if (Array.isArray(parsed)) {
cfData = parsed;
}
}
}
// Legacy PascalCase key compat
if (Array.isArray(fieldOrder)) {
const legacyMap: Record<string, string> = {
Name: 'name', CompanyName: 'company_name',
Street: 'street', CityPostal: 'city_postal',
Country: 'country', CompanyId: 'company_id', VatId: 'vat_id',
};
fieldOrder = fieldOrder.map(k => legacyMap[k] || k);
}
const fieldMap: Record<string, string> = {};
if (name) fieldMap[nameKey] = name;
if (entity.street) fieldMap.street = String(entity.street);
const cityParts = [entity.city || '', entity.postal_code || ''].filter(Boolean).map(String);
const cityPostal = cityParts.join(' ').trim();
if (cityPostal) fieldMap.city_postal = cityPostal;
if (entity.country) fieldMap.country = String(entity.country);
if (entity.company_id) fieldMap.company_id = `${t('ico')}: ${entity.company_id}`;
if (entity.vat_id) fieldMap.vat_id = `${t('dic')}: ${entity.vat_id}`;
cfData.forEach((cf, i) => {
const cfName = (cf.name || '').trim();
const cfValue = (cf.value || '').trim();
const showLabel = cf.showLabel !== false;
if (cfValue) {
fieldMap[`custom_${i}`] = (showLabel && cfName) ? `${cfName}: ${cfValue}` : cfValue;
}
});
const lines: string[] = [];
if (Array.isArray(fieldOrder) && fieldOrder.length > 0) {
for (const key of fieldOrder) {
if (key === nameKey) continue;
if (fieldMap[key]) lines.push(fieldMap[key]);
}
for (const [key, line] of Object.entries(fieldMap)) {
if (key === nameKey) continue;
if (!fieldOrder.includes(key)) lines.push(line);
}
} else {
for (const [key, line] of Object.entries(fieldMap)) {
if (key === nameKey) continue;
lines.push(line);
}
}
return { name, lines };
}
const TRANSLATIONS: Record<string, Record<string, string>> = {
title: { EN: 'PRICE QUOTATION', CZ: 'CENOV\u00C1 NAB\u00CDDKA' },
scope_title: { EN: 'SCOPE OF THE PROJECT', CZ: 'ROZSAH PROJEKTU' },
valid_until: { EN: 'Valid until', CZ: 'Platnost do' },
customer: { EN: 'Customer', CZ: 'Z\u00E1kazn\u00EDk' },
supplier: { EN: 'Supplier', CZ: 'Dodavatel' },
no: { EN: 'N.', CZ: '\u010C.' },
description: { EN: 'Description', CZ: 'Popis' },
qty: { EN: 'Qty', CZ: 'Mn.' },
unit_price: { EN: 'Unit Price', CZ: 'Jedn. cena' },
included: { EN: 'Included', CZ: 'Zahrnuto' },
total: { EN: 'Total', CZ: 'Celkem' },
subtotal: { EN: 'Subtotal', CZ: 'Mezisou\u010Det' },
vat: { EN: 'VAT', CZ: 'DPH' },
total_to_pay: { EN: 'Total to pay', CZ: 'Celkem k \u00FAhrad\u011B' },
exchange_rate: { EN: 'Exchange rate', CZ: 'Sm\u011Bnn\u00FD kurz' },
ico: { EN: 'ID', CZ: 'I\u010CO' },
dic: { EN: 'VAT ID', CZ: 'DI\u010C' },
page: { EN: 'Page', CZ: 'Strana' },
of: { EN: 'of', CZ: 'z' },
};
export default async function offersPdfRoutes(fastify: FastifyInstance): Promise<void> {
fastify.get<{ Params: { id: string } }>('/:id', { preHandler: requirePermission('offers.view') }, async (request, reply) => {
const id = parseInt(request.params.id, 10);
try {
const quotation = await prisma.quotations.findUnique({
where: { id },
include: {
customers: true,
quotation_items: { orderBy: { position: 'asc' } },
scope_sections: { orderBy: { position: 'asc' } },
},
});
if (!quotation) {
return reply.status(404).type('text/html').send('<html><body><h1>Nab\u00EDdka nenalezena</h1></body></html>');
}
const settings = await prisma.company_settings.findFirst();
const isCzech = (quotation.language ?? 'EN') !== 'EN';
const langKey = isCzech ? 'CZ' : 'EN';
const currency = quotation.currency || 'EUR';
const t = (key: string): string => TRANSLATIONS[key]?.[langKey] || key;
// Logo
let logoImg = '';
if (settings?.logo_data) {
const buf = Buffer.from(settings.logo_data);
let mime = 'image/png';
if (buf[0] === 0xFF && buf[1] === 0xD8) mime = 'image/jpeg';
else if (buf[0] === 0x47 && buf[1] === 0x49) mime = 'image/gif';
else if (buf[0] === 0x52 && buf[1] === 0x49) mime = 'image/webp';
logoImg = `<img src="data:${escapeHtml(mime)};base64,${buf.toString('base64')}" class="logo" />`;
}
// Calculations
const items = quotation.quotation_items;
let subtotal = 0;
for (const item of items) {
if (item.is_included_in_total !== false) {
subtotal += (Number(item.quantity) || 0) * (Number(item.unit_price) || 0);
}
}
const applyVat = !!quotation.apply_vat;
const vatRate = Number(quotation.vat_rate) || 21;
const vatAmount = applyVat ? subtotal * (vatRate / 100) : 0;
const totalToPay = subtotal + vatAmount;
const exchangeRate = Number(quotation.exchange_rate) || 0;
// Scope content check
let hasScopeContent = false;
for (const s of quotation.scope_sections) {
if ((s.content || '').trim() || (s.title || '').trim()) {
hasScopeContent = true;
break;
}
}
// Addresses
const cust = buildAddressLines(quotation.customers as unknown as Record<string, unknown>, false, t);
const supp = buildAddressLines(settings as unknown as Record<string, unknown>, true, t);
const custLinesHtml = cust.lines.map(l => `<div class="address-line">${escapeHtml(l)}</div>`).join('');
const suppLinesHtml = supp.lines.map(l => `<div class="address-line">${escapeHtml(l)}</div>`).join('');
// Indentation CSS for Quill
let indentCSS = '';
for (let n = 1; n <= 9; n++) {
const pad = n * 3;
const liPad = n * 3 + 1.5;
indentCSS += ` .ql-indent-${n} { padding-left: ${pad}em; }\n`;
indentCSS += ` li.ql-indent-${n} { padding-left: ${liPad}em; }\n`;
}
// Items HTML
let itemsHtml = '';
items.forEach((item, i) => {
const lineTotal = (Number(item.quantity) || 0) * (Number(item.unit_price) || 0);
const subDesc = item.item_description || '';
const evenClass = (i % 2 === 1) ? ' class="even"' : '';
itemsHtml += `<tr${evenClass}>
<td class="row-num">${i + 1}</td>
<td class="desc">${escapeHtml(item.description)}${subDesc ? `<div class="item-subdesc">${escapeHtml(subDesc)}</div>` : ''}</td>
<td class="center">${formatNum(Number(item.quantity) || 1, 0)}${(item.unit || '').trim() ? ` / ${escapeHtml((item.unit || '').trim())}` : ''}</td>
<td class="right">${formatCurrency(Number(item.unit_price) || 0, currency)}</td>
<td class="right total-cell">${formatCurrency(lineTotal, currency)}</td>
</tr>`;
});
// Totals HTML
let totalsHtml = '';
if (applyVat) {
totalsHtml += `<div class="detail-rows">
<div class="row">
<span class="label">${escapeHtml(t('subtotal'))}:</span>
<span class="value">${formatCurrency(subtotal, currency)}</span>
</div>
<div class="row">
<span class="label">${escapeHtml(t('vat'))} (${Math.round(vatRate)}%):</span>
<span class="value">${formatCurrency(vatAmount, currency)}</span>
</div>
</div>`;
}
totalsHtml += `<div class="grand">
<span class="label">${escapeHtml(t('total_to_pay'))}</span>
<span class="value">${formatCurrency(totalToPay, currency)}</span>
</div>`;
if (exchangeRate > 0) {
totalsHtml += `<div class="exchange-rate">${escapeHtml(t('exchange_rate'))}: ${formatNum(exchangeRate, 4)}</div>`;
}
// Scope HTML
let scopeHtml = '';
if (hasScopeContent) {
scopeHtml += '<div class="scope-page">';
scopeHtml += `<div class="page-header">
<div class="left">
<div class="page-title">${escapeHtml(t('scope_title'))}</div>`;
if (quotation.scope_title) {
scopeHtml += `<div class="scope-subtitle">${escapeHtml(quotation.scope_title)}</div>`;
}
if (quotation.scope_description) {
scopeHtml += `<div class="scope-description">${escapeHtml(quotation.scope_description)}</div>`;
}
scopeHtml += '</div>';
if (logoImg) {
scopeHtml += `<div class="right"><div class="logo-header">${logoImg}</div></div>`;
}
scopeHtml += `</div>
<hr class="separator" />`;
for (const section of quotation.scope_sections) {
const title = isCzech && (section.title_cz || '').trim() ? section.title_cz : (section.title || '');
const content = (section.content || '').trim();
if (!title && !content) continue;
scopeHtml += '<div class="scope-section">';
if (title) scopeHtml += `<div class="scope-section-title">${escapeHtml(title)}</div>`;
if (content) scopeHtml += `<div class="section-content">${cleanQuillHtml(content)}</div>`;
scopeHtml += '</div>';
}
scopeHtml += '</div>';
}
const quotationNumber = escapeHtml(quotation.quotation_number);
const pageLabel = escapeHtml(t('page'));
const ofLabel = escapeHtml(t('of'));
const html = `<!DOCTYPE html>
<html lang="${isCzech ? 'cs' : 'en'}">
<head>
<meta charset="utf-8" />
<title>${quotationNumber}</title>
<link rel="icon" type="image/png" href="/favicon-96x96.png" sizes="96x96">
<link rel="shortcut icon" href="/favicon.ico">
<style>
/* ---- Base ---- */
@page {
size: A4;
margin: 15mm 15mm 25mm 15mm;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
html, body {
font-family: "Segoe UI", Tahoma, Arial, sans-serif;
font-size: 10pt;
color: #1a1a1a;
width: 180mm;
}
img, table, pre, code { max-width: 100%; }
/* ---- Quill font classes ---- */
.ql-font-arial { font-family: Arial, sans-serif; }
.ql-font-tahoma { font-family: Tahoma, sans-serif; }
.ql-font-verdana { font-family: Verdana, sans-serif; }
.ql-font-georgia { font-family: Georgia, serif; }
.ql-font-times-new-roman { font-family: "Times New Roman", serif; }
.ql-font-courier-new { font-family: "Courier New", monospace; }
.ql-font-trebuchet-ms { font-family: "Trebuchet MS", sans-serif; }
.ql-font-impact { font-family: Impact, sans-serif; }
.ql-font-comic-sans-ms { font-family: "Comic Sans MS", cursive; }
.ql-font-lucida-console { font-family: "Lucida Console", monospace; }
.ql-font-palatino-linotype{ font-family: "Palatino Linotype", serif; }
.ql-font-garamond { font-family: Garamond, serif; }
/* ---- Quill alignment ---- */
.ql-align-center { text-align: center; }
.ql-align-right { text-align: right; }
.ql-align-justify { text-align: justify; }
/* ---- Quill indentation ---- */
${indentCSS}
/* ---- Page header ---- */
.page-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 4mm;
}
.page-header .left { flex: 1; }
.page-header .right { flex-shrink: 0; margin-left: 10mm; }
.logo { max-width: 42mm; max-height: 22mm; object-fit: contain; }
.page-title {
font-size: 18pt;
font-weight: bold;
color: #1a1a1a;
margin: 0;
}
.scope-page .page-title { font-size: 16pt; }
.quotation-number {
font-size: 12pt;
color: #1a1a1a;
margin: 1mm 0;
}
.project-code {
font-size: 10pt;
color: #646464;
}
.valid-until {
font-size: 9pt;
color: #646464;
margin-top: 1mm;
}
.scope-subtitle {
font-size: 11pt;
color: #646464;
margin-top: 1mm;
}
.scope-description {
font-size: 9pt;
color: #646464;
margin-top: 1mm;
}
.separator {
border: none;
border-top: 0.5pt solid #e0e0e0;
margin: 3mm 0 5mm 0;
}
/* ---- Addresses ---- */
.addresses {
display: flex;
justify-content: space-between;
margin-bottom: 8mm;
}
.address-block { width: 48%; }
.address-block.right { text-align: right; }
.address-label {
font-size: 9pt;
font-weight: bold;
color: #646464;
line-height: 1.5;
}
.address-name {
font-size: 9pt;
font-weight: bold;
color: #1a1a1a;
line-height: 1.5;
}
.address-line {
font-size: 9pt;
color: #646464;
line-height: 1.5;
}
/* ---- Items table ---- */
table.items {
width: 100%;
table-layout: fixed;
border-collapse: collapse;
font-size: 9pt;
margin-bottom: 2mm;
}
table.items thead th {
font-size: 8pt;
font-weight: 600;
color: #646464;
padding: 6px 8px;
text-align: left;
letter-spacing: 0.02em;
text-transform: uppercase;
border-bottom: 1pt solid #1a1a1a;
}
table.items thead th.center { text-align: center; }
table.items thead th.right { text-align: right; }
table.items tbody td {
padding: 7px 8px;
border-bottom: 0.5pt solid #e0e0e0;
vertical-align: middle;
word-wrap: break-word;
overflow-wrap: break-word;
color: #1a1a1a;
}
table.items tbody tr:nth-child(even) { background: #f8f9fa; }
table.items tbody td.center { text-align: center; white-space: nowrap; }
table.items tbody td.right { text-align: right; }
table.items tbody td.row-num {
text-align: center;
color: #969696;
font-size: 8pt;
}
table.items tbody td.desc {
font-size: 10pt;
font-weight: 500;
color: #1a1a1a;
}
table.items tbody td.total-cell {
font-weight: 700;
}
.item-subdesc {
font-size: 9pt;
color: #646464;
margin-top: 2px;
font-weight: 400;
}
/* ---- Totals ---- */
.totals-wrapper {
display: flex;
justify-content: flex-end;
break-inside: avoid;
margin-top: 8mm;
}
.totals {
width: 80mm;
}
.totals .detail-rows {
margin-bottom: 3mm;
}
.totals .row {
display: flex;
justify-content: space-between;
align-items: baseline;
font-size: 8.5pt;
color: #646464;
margin-bottom: 2mm;
}
.totals .row:last-child { margin-bottom: 0; }
.totals .row .value {
color: #1a1a1a;
font-size: 8.5pt;
}
.totals .grand {
border-top: 0.5pt solid #e0e0e0;
padding-top: 4mm;
display: flex;
justify-content: space-between;
align-items: baseline;
}
.totals .grand .label {
font-size: 9.5pt;
font-weight: 400;
color: #646464;
align-self: center;
}
.totals .grand .value {
font-size: 14pt;
font-weight: 600;
color: #1a1a1a;
border-bottom: 2.5pt solid #de3a3a;
padding-bottom: 1mm;
}
.totals .exchange-rate {
text-align: right;
font-size: 7.5pt;
color: #969696;
margin-top: 3mm;
}
/* ---- Scope sections ---- */
.scope-page {
page-break-before: always;
}
.scope-section {
width: 100%;
max-width: 100%;
margin-bottom: 3mm;
break-inside: avoid;
}
.scope-section-title {
font-size: 11pt;
font-weight: bold;
color: #1a1a1a;
margin-bottom: 1mm;
}
.section-content {
font-size: 9pt;
color: #1a1a1a;
line-height: 1.5;
word-break: normal;
overflow-wrap: anywhere;
}
.section-content p { margin: 0 0 0.4em 0; }
.section-content ul, .section-content ol { margin: 0 0 0.4em 1.5em; }
.section-content li { margin-bottom: 0.2em; }
/* ---- Repeating page header ---- */
table.page-layout {
width: 100%;
border-collapse: collapse;
}
table.page-layout > thead > tr > td,
table.page-layout > tbody > tr > td {
padding: 0;
border: none;
vertical-align: top;
}
.logo-header {
text-align: right;
padding-bottom: 4mm;
}
.first-content {
margin-top: -26mm;
}
/* ---- Page break helpers ---- */
table.page-layout thead { display: table-header-group; }
table.items tbody tr { break-inside: avoid; }
@media print {
body {
-webkit-print-color-adjust: exact;
print-color-adjust: exact;
}
@page {
@bottom-center {
content: "${pageLabel} " counter(page) " ${ofLabel} " counter(pages);
font-size: 8pt;
color: #969696;
font-family: "Segoe UI", Tahoma, Arial, sans-serif;
}
}
}
/* ---- Screen-only: A4 page preview ---- */
@media screen {
html {
background: #525659;
}
body {
width: 100vw !important;
margin: 0;
padding: 30px 0;
background: transparent;
display: flex;
flex-direction: column;
align-items: center;
gap: 30px;
min-height: 100vh;
overflow-x: hidden;
}
.quotation-page, .scope-page {
width: 210mm;
min-height: 297mm;
padding: 15mm;
background: white;
box-shadow: 0 4px 20px rgba(0,0,0,0.3);
box-sizing: border-box;
border-radius: 2px;
}
table.page-layout,
table.page-layout > thead,
table.page-layout > thead > tr,
table.page-layout > thead > tr > td,
table.page-layout > tbody,
table.page-layout > tbody > tr,
table.page-layout > tbody > tr > td {
display: block;
width: 100%;
}
.first-content {
margin-top: 0 !important;
}
.logo-header {
text-align: right;
padding-bottom: 0;
margin-bottom: -18mm;
}
}
</style>
</head>
<body>
<!-- ============ QUOTATION (logo repeats via thead, full header only on first page) ============ -->
<div class="quotation-page">
<table class="page-layout">
<thead>
<tr><td>
<div class="logo-header">${logoImg}</div>
</td></tr>
</thead>
<tbody>
<tr><td>
<div class="first-content">
<div class="page-header">
<div class="left">
<div class="page-title">${escapeHtml(t('title'))}</div>
<div class="quotation-number">${quotationNumber}</div>
${quotation.project_code ? `<div class="project-code">${escapeHtml(quotation.project_code)}</div>` : ''}
<div class="valid-until">${escapeHtml(t('valid_until'))}: ${escapeHtml(formatDate(quotation.valid_until))}</div>
</div>
</div>
<hr class="separator" />
<div class="addresses">
<div class="address-block left">
<div class="address-label">${escapeHtml(t('customer'))}</div>
<div class="address-name">${escapeHtml(cust.name)}</div>
${custLinesHtml}
</div>
<div class="address-block right">
<div class="address-label">${escapeHtml(t('supplier'))}</div>
<div class="address-name">${escapeHtml(supp.name)}</div>
${suppLinesHtml}
</div>
</div>
<table class="items">
<thead>
<tr>
<th class="center" style="width:5%">${escapeHtml(t('no'))}</th>
<th style="width:44%">${escapeHtml(t('description'))}</th>
<th class="center" style="width:13%">${escapeHtml(t('qty'))}</th>
<th class="right" style="width:18%">${escapeHtml(t('unit_price'))}</th>
<th class="right" style="width:20%">${escapeHtml(t('total'))}</th>
</tr>
</thead>
<tbody>
${itemsHtml}
</tbody>
</table>
<div class="totals-wrapper">
<div class="totals">
${totalsHtml}
</div>
</div>
</div>
</td></tr>
</tbody>
</table>
</div>
${scopeHtml}
</body>
</html>`;
return reply.type('text/html').send(html);
} catch (err) {
request.log.error(err, 'PDF generation failed');
return reply.status(500).type('text/html').send('<html><body><h1>Chyba p\u0159i generov\u00E1n\u00ED PDF</h1></body></html>');
}
});
}

526
src/routes/admin/orders.ts Normal file
View File

@@ -0,0 +1,526 @@
import { FastifyInstance } from 'fastify';
import prisma from '../../config/database';
import { requirePermission } from '../../middleware/auth';
import { logAudit } from '../../services/audit';
import { success, error, parseId } from '../../utils/response';
import { parsePagination, buildPaginationMeta } from '../../utils/pagination';
import multipart from '@fastify/multipart';
// Status transition rules matching PHP
const VALID_TRANSITIONS: Record<string, string[]> = {
prijata: ['v_realizaci', 'stornovana'],
v_realizaci: ['dokoncena', 'stornovana'],
dokoncena: [],
stornovana: [],
};
// Shared number generator matching PHP generateSharedNumber()
// Format: YYtypeCode + 4-digit sequence, shared between orders and projects
async function generateSharedNumber(): Promise<string> {
const settings = await prisma.company_settings.findFirst({ select: { order_type_code: true } });
const typeCode = settings?.order_type_code || '71';
const yy = String(new Date().getFullYear()).slice(-2);
const prefix = `${yy}${typeCode}`;
const prefixLen = prefix.length;
const likePattern = `${prefix}%`;
const result = await prisma.$queryRaw<[{ max_seq: bigint | null }]>`
SELECT COALESCE(MAX(seq), 0) as max_seq FROM (
SELECT CAST(SUBSTRING(order_number, ${prefixLen} + 1) AS UNSIGNED) AS seq
FROM orders WHERE order_number LIKE ${likePattern}
UNION ALL
SELECT CAST(SUBSTRING(project_number, ${prefixLen} + 1) AS UNSIGNED) AS seq
FROM projects WHERE project_number LIKE ${likePattern}
) combined
`;
const nextNum = Number(result[0]?.max_seq ?? 0) + 1;
return `${prefix}${String(nextNum).padStart(4, '0')}`;
}
async function generateOrderNumber(): Promise<string> {
return generateSharedNumber();
}
async function generateProjectNumber(): Promise<string> {
return generateSharedNumber();
}
interface OrderItemInput { description?: string; item_description?: string; quantity?: number; unit?: string; unit_price?: number; is_included_in_total?: boolean; position?: number }
interface OrderSectionInput { title?: string; title_cz?: string; content?: string; position?: number }
export default async function ordersRoutes(fastify: FastifyInstance): Promise<void> {
await fastify.register(multipart, { limits: { fileSize: 10 * 1024 * 1024 } });
// GET /api/admin/orders/next-number
fastify.get('/next-number', { preHandler: requirePermission('orders.create') }, async (_request, reply) => {
const number = await generateOrderNumber();
return success(reply, { number, next_number: number });
});
const ORDER_ALLOWED_SORT_FIELDS = ['id', 'order_number', 'status', 'currency', 'created_at'];
fastify.get('/', { preHandler: requirePermission('orders.view') }, async (request, reply) => {
const query = request.query as Record<string, unknown>;
const { page, limit, skip, sort, order } = parsePagination(query);
const sortField = ORDER_ALLOWED_SORT_FIELDS.includes(sort) ? sort : 'id';
const where: Record<string, unknown> = {};
if (query.status) where.status = String(query.status);
if (query.customer_id) where.customer_id = Number(query.customer_id);
const [orders, total] = await Promise.all([
prisma.orders.findMany({
where, skip, take: limit, orderBy: { [sortField]: order },
include: {
customers: { select: { id: true, name: true } },
order_items: { orderBy: { position: 'asc' } },
order_sections: { orderBy: { position: 'asc' } },
quotations: { select: { quotation_number: true, project_code: true } },
invoices: { select: { id: true, invoice_number: true }, take: 1 },
},
}),
prisma.orders.count({ where }),
]);
const enriched = orders.map(o => {
const subtotal = o.order_items
.filter(i => i.is_included_in_total !== false)
.reduce((s, i) => s + (Number(i.quantity) || 0) * (Number(i.unit_price) || 0), 0);
const vatAmount = o.apply_vat ? subtotal * ((Number(o.vat_rate) || 21) / 100) : 0;
const { order_items, order_sections, ...rest } = o;
const invoice = o.invoices?.[0] || null;
return {
...rest,
items: order_items,
sections: order_sections,
customer_name: o.customers?.name || null,
quotation_number: o.quotations?.quotation_number || null,
project_code: o.quotations?.project_code || null,
invoice_id: invoice?.id || null,
invoice_number: invoice?.invoice_number || null,
subtotal: Math.round(subtotal * 100) / 100,
vat_amount: Math.round(vatAmount * 100) / 100,
total: Math.round((subtotal + vatAmount) * 100) / 100,
};
});
return reply.send({ success: true, data: enriched, pagination: buildPaginationMeta(total, page, limit) });
});
fastify.get<{ Params: { id: string } }>('/:id', { preHandler: requirePermission('orders.view') }, async (request, reply) => {
const id = parseId(request.params.id, reply);
if (id === null) return;
const order = await prisma.orders.findUnique({
where: { id },
include: {
customers: true,
order_items: { orderBy: { position: 'asc' } },
order_sections: { orderBy: { position: 'asc' } },
quotations: { select: { id: true, quotation_number: true, project_code: true } },
projects: { select: { id: true, project_number: true, name: true, status: true } },
invoices: { select: { id: true, invoice_number: true, status: true }, take: 1 },
},
});
if (!order) return error(reply, 'Objednávka nenalezena', 404);
const { order_items, order_sections, ...rest } = order;
const invoice = order.invoices?.[0] || null;
return success(reply, {
...rest,
items: order_items,
sections: order_sections,
customer: order.customers,
customer_name: order.customers?.name || null,
quotation_number: order.quotations?.quotation_number || null,
project_code: order.quotations?.project_code || null,
project: order.projects?.[0] || null,
invoice: invoice,
invoice_id: invoice?.id || null,
invoice_number: invoice?.invoice_number || null,
valid_transitions: VALID_TRANSITIONS[(order.status as string) || ''] || [],
});
});
// GET /api/admin/orders/:id/attachment
fastify.get<{ Params: { id: string } }>('/:id/attachment', { preHandler: requirePermission('orders.view') }, async (request, reply) => {
const id = parseId(request.params.id, reply);
if (id === null) return;
const order = await prisma.orders.findUnique({
where: { id },
select: { attachment_data: true, attachment_name: true },
});
if (!order?.attachment_data) return error(reply, 'Příloha nenalezena', 404);
const filename = order.attachment_name || `order-${id}.pdf`;
return reply
.type('application/pdf')
.header('Content-Disposition', `inline; filename="${filename}"`)
.send(Buffer.from(order.attachment_data));
});
// POST /api/admin/orders — handles both JSON (manual) and multipart (from quotation)
fastify.post('/', { preHandler: requirePermission('orders.create') }, async (request, reply) => {
const isMultipart = request.headers['content-type']?.includes('multipart');
if (isMultipart) {
// === Order from quotation flow ===
const fields: Record<string, string> = {};
let attachmentBuffer: Buffer | null = null;
let attachmentName: string | null = null;
const parts = request.parts();
for await (const part of parts) {
if (part.type === 'field') {
fields[part.fieldname] = String(part.value);
} else if (part.type === 'file' && part.fieldname === 'attachment') {
attachmentBuffer = await part.toBuffer();
attachmentName = part.filename;
}
}
const quotationId = parseInt(fields.quotationId, 10);
const customerOrderNumber = fields.customerOrderNumber || '';
if (!quotationId || isNaN(quotationId)) {
return error(reply, 'Chybí ID nabídky', 400);
}
const quotation = await prisma.quotations.findUnique({
where: { id: quotationId },
include: {
quotation_items: { orderBy: { position: 'asc' } },
scope_sections: { orderBy: { position: 'asc' } },
},
});
if (!quotation) return error(reply, 'Nabídka nenalezena', 404);
if (quotation.order_id) return error(reply, 'Z této nabídky již byla vytvořena objednávka', 400);
const orderNumber = await generateOrderNumber();
const projectNumber = await generateProjectNumber();
const result = await prisma.$transaction(async (tx) => {
// Create the order
const order = await tx.orders.create({
data: {
order_number: orderNumber,
customer_order_number: customerOrderNumber || null,
quotation_id: quotationId,
customer_id: quotation.customer_id,
status: 'prijata',
currency: quotation.currency || 'CZK',
language: quotation.language || 'cs',
vat_rate: quotation.vat_rate ?? 21.0,
apply_vat: quotation.apply_vat ?? true,
exchange_rate: quotation.exchange_rate ?? 1.0,
scope_title: quotation.scope_title,
scope_description: quotation.scope_description,
attachment_data: attachmentBuffer ? new Uint8Array(attachmentBuffer) : null,
attachment_name: attachmentName,
},
});
// Copy quotation_items → order_items
if (quotation.quotation_items.length > 0) {
await tx.order_items.createMany({
data: quotation.quotation_items.map((item) => ({
order_id: order.id,
description: item.description,
item_description: item.item_description,
quantity: item.quantity,
unit: item.unit,
unit_price: item.unit_price,
is_included_in_total: item.is_included_in_total,
position: item.position,
})),
});
}
// Copy scope_sections → order_sections
if (quotation.scope_sections.length > 0) {
await tx.order_sections.createMany({
data: quotation.scope_sections.map((s) => ({
order_id: order.id,
title: s.title,
title_cz: s.title_cz,
content: s.content,
position: s.position,
})),
});
}
// Link quotation back to order and mark as ordered
await tx.quotations.update({
where: { id: quotationId },
data: { order_id: order.id, status: 'ordered', modified_at: new Date() },
});
// Create project automatically
const project = await tx.projects.create({
data: {
project_number: projectNumber,
name: quotation.project_code || quotation.quotation_number || orderNumber,
customer_id: quotation.customer_id,
quotation_id: quotationId,
order_id: order.id,
status: 'aktivni',
},
});
return { order, project };
});
await logAudit({ request, authData: request.authData, action: 'create', entityType: 'order', entityId: result.order.id, description: `Vytvořena objednávka ${orderNumber} z nabídky #${quotationId}` });
return success(reply, { order_id: result.order.id, id: result.order.id, order_number: orderNumber }, 201, 'Objednávka byla vytvořena');
}
// === JSON body — either from-quotation (no attachment) or manual order ===
const body = request.body as Record<string, unknown>;
// From-quotation flow via JSON (no attachment)
if (body.quotationId) {
const quotationId = Number(body.quotationId);
const customerOrderNumber = body.customerOrderNumber ? String(body.customerOrderNumber) : '';
if (!quotationId || isNaN(quotationId)) {
return error(reply, 'Chybí ID nabídky', 400);
}
const quotation = await prisma.quotations.findUnique({
where: { id: quotationId },
include: {
quotation_items: { orderBy: { position: 'asc' } },
scope_sections: { orderBy: { position: 'asc' } },
},
});
if (!quotation) return error(reply, 'Nabídka nenalezena', 404);
if (quotation.order_id) return error(reply, 'Z této nabídky již byla vytvořena objednávka', 400);
const orderNumber = await generateOrderNumber();
const projectNumber = await generateProjectNumber();
const result = await prisma.$transaction(async (tx) => {
const order = await tx.orders.create({
data: {
order_number: orderNumber,
customer_order_number: customerOrderNumber || null,
quotation_id: quotationId,
customer_id: quotation.customer_id,
status: 'prijata',
currency: quotation.currency || 'CZK',
language: quotation.language || 'cs',
vat_rate: quotation.vat_rate ?? 21.0,
apply_vat: quotation.apply_vat ?? true,
exchange_rate: quotation.exchange_rate ?? 1.0,
scope_title: quotation.scope_title,
scope_description: quotation.scope_description,
},
});
if (quotation.quotation_items.length > 0) {
await tx.order_items.createMany({
data: quotation.quotation_items.map((item) => ({
order_id: order.id,
description: item.description,
item_description: item.item_description,
quantity: item.quantity,
unit: item.unit,
unit_price: item.unit_price,
is_included_in_total: item.is_included_in_total,
position: item.position,
})),
});
}
if (quotation.scope_sections.length > 0) {
await tx.order_sections.createMany({
data: quotation.scope_sections.map((s) => ({
order_id: order.id,
title: s.title,
title_cz: s.title_cz,
content: s.content,
position: s.position,
})),
});
}
await tx.quotations.update({
where: { id: quotationId },
data: { order_id: order.id, status: 'ordered', modified_at: new Date() },
});
const project = await tx.projects.create({
data: {
project_number: projectNumber,
name: quotation.project_code || quotation.quotation_number || orderNumber,
customer_id: quotation.customer_id,
quotation_id: quotationId,
order_id: order.id,
status: 'aktivni',
},
});
return { order, project };
});
await logAudit({ request, authData: request.authData, action: 'create', entityType: 'order', entityId: result.order.id, description: `Vytvořena objednávka ${orderNumber} z nabídky #${quotationId}` });
return success(reply, { order_id: result.order.id, id: result.order.id, order_number: orderNumber }, 201, 'Objednávka byla vytvořena');
}
// Manual order creation
const order = await prisma.orders.create({
data: {
order_number: body.order_number ? String(body.order_number) : null,
customer_order_number: body.customer_order_number ? String(body.customer_order_number) : null,
quotation_id: body.quotation_id ? Number(body.quotation_id) : null,
customer_id: body.customer_id ? Number(body.customer_id) : null,
status: body.status ? String(body.status) : 'prijata',
currency: body.currency ? String(body.currency) : 'CZK',
language: body.language ? String(body.language) : 'cs',
vat_rate: body.vat_rate ? Number(body.vat_rate) : 21.0,
apply_vat: body.apply_vat !== false,
exchange_rate: body.exchange_rate ? Number(body.exchange_rate) : 1.0,
scope_title: body.scope_title ? String(body.scope_title) : null,
scope_description: body.scope_description ? String(body.scope_description) : null,
notes: body.notes ? String(body.notes) : null,
},
});
if (Array.isArray(body.items)) {
await prisma.order_items.createMany({
data: (body.items as OrderItemInput[]).map((item, i) => ({
order_id: order.id,
description: item.description ?? null,
item_description: item.item_description ?? null,
quantity: item.quantity ?? 1,
unit: item.unit ?? null,
unit_price: item.unit_price ?? 0,
is_included_in_total: item.is_included_in_total !== false,
position: item.position ?? i,
})),
});
}
if (Array.isArray(body.sections)) {
await prisma.order_sections.createMany({
data: (body.sections as OrderSectionInput[]).map((s, i) => ({
order_id: order.id,
title: s.title ?? null,
title_cz: s.title_cz ?? null,
content: s.content ?? null,
position: s.position ?? i,
})),
});
}
await logAudit({ request, authData: request.authData, action: 'create', entityType: 'order', entityId: order.id, description: `Vytvořena objednávka ${order.order_number}` });
return success(reply, { id: order.id }, 201, 'Objednávka byla vytvořena');
});
fastify.put<{ Params: { id: string } }>('/:id', { preHandler: requirePermission('orders.edit') }, async (request, reply) => {
const id = parseId(request.params.id, reply);
if (id === null) return;
const body = request.body as Record<string, unknown>;
const existing = await prisma.orders.findUnique({ where: { id } });
if (!existing) return error(reply, 'Objednávka nenalezena', 404);
const currentStatus = existing.status as string;
// Validate status transition
if (body.status !== undefined && String(body.status) !== currentStatus) {
const newStatus = String(body.status);
const allowed = VALID_TRANSITIONS[currentStatus] || [];
if (!allowed.includes(newStatus)) {
return error(reply, `Neplatný přechod stavu z "${currentStatus}" na "${newStatus}"`, 400);
}
}
const data: Record<string, unknown> = { modified_at: new Date() };
const strFields = ['order_number', 'customer_order_number', 'status', 'currency', 'language', 'scope_title', 'scope_description', 'notes'];
for (const f of strFields) {
if (body[f] !== undefined) data[f] = body[f] ? String(body[f]) : null;
}
if (body.customer_id !== undefined) data.customer_id = body.customer_id ? Number(body.customer_id) : null;
if (body.vat_rate !== undefined) data.vat_rate = Number(body.vat_rate);
if (body.apply_vat !== undefined) data.apply_vat = body.apply_vat === true || body.apply_vat === 1 || body.apply_vat === '1';
await prisma.orders.update({ where: { id }, data });
// Sync project_number when order_number changes (matching PHP)
if (body.order_number !== undefined && String(body.order_number) !== existing.order_number) {
await prisma.projects.updateMany({
where: { order_id: id },
data: { project_number: String(body.order_number) },
});
}
// Sync project status when order status changes (matching PHP)
if (body.status !== undefined && String(body.status) !== currentStatus) {
const statusMap: Record<string, string> = {
v_realizaci: 'aktivni',
dokoncena: 'dokonceny',
stornovana: 'zruseny',
};
const projectStatus = statusMap[String(body.status)];
if (projectStatus) {
await prisma.projects.updateMany({
where: { order_id: id },
data: { status: projectStatus },
});
}
}
if (Array.isArray(body.items) || Array.isArray(body.sections)) {
await prisma.$transaction(async (tx) => {
if (Array.isArray(body.items)) {
await tx.order_items.deleteMany({ where: { order_id: id } });
await tx.order_items.createMany({
data: (body.items as OrderItemInput[]).map((item, i) => ({
order_id: id, description: item.description ?? null, item_description: item.item_description ?? null,
quantity: item.quantity ?? 1, unit: item.unit ?? null, unit_price: item.unit_price ?? 0,
is_included_in_total: item.is_included_in_total !== false, position: item.position ?? i,
})),
});
}
if (Array.isArray(body.sections)) {
await tx.order_sections.deleteMany({ where: { order_id: id } });
await tx.order_sections.createMany({
data: (body.sections as OrderSectionInput[]).map((s, i) => ({
order_id: id, title: s.title ?? null, title_cz: s.title_cz ?? null, content: s.content ?? null, position: s.position ?? i,
})),
});
}
});
}
await logAudit({ request, authData: request.authData, action: 'update', entityType: 'order', entityId: id, description: `Upravena objednávka ${existing.order_number}` });
return success(reply, { id }, 200, 'Objednávka byla uložena');
});
fastify.delete<{ Params: { id: string } }>('/:id', { preHandler: requirePermission('orders.delete') }, async (request, reply) => {
const id = parseId(request.params.id, reply);
if (id === null) return;
const existing = await prisma.orders.findUnique({ where: { id } });
if (!existing) return error(reply, 'Objednávka nenalezena', 404);
// Clear quotation back-reference (matching PHP)
await prisma.quotations.updateMany({
where: { order_id: id },
data: { order_id: null },
});
// Delete linked project and its notes (matching PHP)
const linkedProjects = await prisma.projects.findMany({ where: { order_id: id }, select: { id: true } });
if (linkedProjects.length > 0) {
const projectIds = linkedProjects.map(p => p.id);
await prisma.project_notes.deleteMany({ where: { project_id: { in: projectIds } } });
await prisma.projects.deleteMany({ where: { order_id: id } });
}
await prisma.orders.delete({ where: { id } });
await logAudit({ request, authData: request.authData, action: 'delete', entityType: 'order', entityId: id, description: `Smazána objednávka ${existing.order_number}` });
return success(reply, null, 200, 'Objednávka smazána');
});
}

View File

@@ -0,0 +1,53 @@
import { FastifyInstance } from 'fastify';
import prisma from '../../config/database';
import { requireAuth } from '../../middleware/auth';
import { success, error } from '../../utils/response';
import bcrypt from 'bcryptjs';
import { config } from '../../config/env';
import { logAudit } from '../../services/audit';
export default async function profileRoutes(fastify: FastifyInstance): Promise<void> {
fastify.get('/', { preHandler: requireAuth }, async (request, reply) => {
const user = await prisma.users.findUnique({
where: { id: request.authData!.userId },
select: {
id: true, username: true, email: true, first_name: true, last_name: true,
totp_enabled: true, last_login: true, password_changed_at: true,
roles: { select: { id: true, name: true, display_name: true } },
},
});
if (!user) return error(reply, 'Uživatel nenalezen', 404);
return success(reply, user);
});
fastify.put('/', { preHandler: requireAuth }, async (request, reply) => {
const body = request.body as Record<string, unknown>;
const userId = request.authData!.userId;
const data: Record<string, unknown> = {};
if (body.email) {
const newEmail = String(body.email).trim();
const existing = await prisma.users.findFirst({ where: { email: newEmail, id: { not: userId } } });
if (existing) return error(reply, 'E-mail již existuje', 409);
data.email = newEmail;
}
if (body.first_name) data.first_name = String(body.first_name);
if (body.last_name) data.last_name = String(body.last_name);
if (body.current_password && body.new_password) {
const user = await prisma.users.findUnique({ where: { id: userId } });
if (!user) return error(reply, 'Uživatel nenalezen', 404);
const valid = await bcrypt.compare(String(body.current_password), user.password_hash);
if (!valid) return error(reply, 'Nesprávné aktuální heslo', 400);
data.password_hash = await bcrypt.hash(String(body.new_password), config.security.bcryptCost);
data.password_changed_at = new Date();
await logAudit({ request, authData: request.authData, action: 'password_change', entityType: 'user', entityId: userId, description: 'Změna hesla' });
}
await prisma.users.update({ where: { id: userId }, data });
return success(reply, null, 200, 'Profil aktualizován');
});
}

View File

@@ -0,0 +1,166 @@
import { FastifyInstance } from 'fastify';
import prisma from '../../config/database';
import { requirePermission } from '../../middleware/auth';
import { logAudit } from '../../services/audit';
import { success, error, parseId } from '../../utils/response';
import { parsePagination, buildPaginationMeta } from '../../utils/pagination';
const PROJECT_ALLOWED_SORT_FIELDS = ['id', 'project_number', 'name', 'status', 'created_at'];
export default async function projectsRoutes(fastify: FastifyInstance): Promise<void> {
fastify.get('/', { preHandler: requirePermission('projects.view') }, async (request, reply) => {
const query = request.query as Record<string, unknown>;
const { page, limit, skip, sort, order, search } = parsePagination(query);
const sortField = PROJECT_ALLOWED_SORT_FIELDS.includes(sort) ? sort : 'id';
const where: Record<string, unknown> = {};
if (query.status) where.status = String(query.status);
if (query.customer_id) where.customer_id = Number(query.customer_id);
if (search) where.OR = [{ name: { contains: search } }, { project_number: { contains: search } }];
const [projects, total] = await Promise.all([
prisma.projects.findMany({
where, skip, take: limit, orderBy: { [sortField]: order },
include: {
customers: { select: { id: true, name: true } },
users: { select: { id: true, first_name: true, last_name: true } },
orders: { select: { order_number: true } },
},
}),
prisma.projects.count({ where }),
]);
const enriched = projects.map(p => ({
...p,
customer_name: p.customers?.name || null,
responsible_user_name: p.users ? `${p.users.first_name} ${p.users.last_name}`.trim() : null,
order_number: p.orders?.[0]?.order_number || null,
}));
return reply.send({ success: true, data: enriched, pagination: buildPaginationMeta(total, page, limit) });
});
fastify.get<{ Params: { id: string } }>('/:id', { preHandler: requirePermission('projects.view') }, async (request, reply) => {
const id = parseId(request.params.id, reply);
if (id === null) return;
const project = await prisma.projects.findUnique({
where: { id },
include: { customers: true, users: true, quotations: true, orders: true, project_notes: { orderBy: { created_at: 'desc' } } },
});
if (!project) return error(reply, 'Projekt nenalezen', 404);
return success(reply, project);
});
fastify.post('/', { preHandler: requirePermission('projects.create') }, async (request, reply) => {
const body = request.body as Record<string, unknown>;
const project = await prisma.projects.create({
data: {
project_number: body.project_number ? String(body.project_number) : null,
name: body.name ? String(body.name) : null,
customer_id: body.customer_id ? Number(body.customer_id) : null,
responsible_user_id: body.responsible_user_id ? Number(body.responsible_user_id) : null,
quotation_id: body.quotation_id ? Number(body.quotation_id) : null,
order_id: body.order_id ? Number(body.order_id) : null,
status: body.status ? String(body.status) : 'aktivni',
start_date: body.start_date ? new Date(String(body.start_date)) : null,
end_date: body.end_date ? new Date(String(body.end_date)) : null,
notes: body.notes ? String(body.notes) : null,
},
});
await logAudit({ request, authData: request.authData, action: 'create', entityType: 'project', entityId: project.id, description: `Vytvořen projekt ${project.name}` });
return success(reply, { id: project.id }, 201, 'Projekt byl vytvořen');
});
fastify.put<{ Params: { id: string } }>('/:id', { preHandler: requirePermission('projects.edit') }, async (request, reply) => {
const id = parseId(request.params.id, reply);
if (id === null) return;
const body = request.body as Record<string, unknown>;
const existing = await prisma.projects.findUnique({ where: { id } });
if (!existing) return error(reply, 'Projekt nenalezen', 404);
const data: Record<string, unknown> = { modified_at: new Date() };
const strFields = ['project_number', 'name', 'status', 'notes'];
for (const f of strFields) if (body[f] !== undefined) data[f] = body[f] ? String(body[f]) : null;
if (body.customer_id !== undefined) data.customer_id = body.customer_id ? Number(body.customer_id) : null;
if (body.responsible_user_id !== undefined) data.responsible_user_id = body.responsible_user_id ? Number(body.responsible_user_id) : null;
if (body.quotation_id !== undefined) data.quotation_id = body.quotation_id ? Number(body.quotation_id) : null;
if (body.order_id !== undefined) data.order_id = body.order_id ? Number(body.order_id) : null;
if (body.start_date !== undefined) data.start_date = body.start_date ? new Date(String(body.start_date)) : null;
if (body.end_date !== undefined) data.end_date = body.end_date ? new Date(String(body.end_date)) : null;
await prisma.projects.update({ where: { id }, data });
await logAudit({ request, authData: request.authData, action: 'update', entityType: 'project', entityId: id, description: `Upraven projekt ${existing.name}` });
return success(reply, { id }, 200, 'Projekt byl uložen');
});
// POST /api/admin/projects/:id/notes
fastify.post<{ Params: { id: string } }>('/:id/notes', { preHandler: requirePermission('projects.edit') }, async (request, reply) => {
const projectId = parseId(request.params.id, reply);
if (projectId === null) return;
const body = request.body as Record<string, unknown>;
const authData = request.authData!;
const note = await prisma.project_notes.create({
data: {
project_id: projectId,
user_id: authData.userId,
user_name: `${authData.firstName} ${authData.lastName}`,
content: body.content ? String(body.content) : null,
},
});
return success(reply, { note }, 201, 'Poznámka byla přidána');
});
// GET /api/admin/projects/next-number — shared sequence with orders (matches PHP)
fastify.get('/next-number', { preHandler: requirePermission('projects.create') }, async (_request, reply) => {
const settings = await prisma.company_settings.findFirst({ select: { order_type_code: true } });
const typeCode = settings?.order_type_code || '71';
const yy = String(new Date().getFullYear()).slice(-2);
const prefix = `${yy}${typeCode}`;
const prefixLen = prefix.length;
const likePattern = `${prefix}%`;
const result = await prisma.$queryRaw<[{ max_seq: bigint | null }]>`
SELECT COALESCE(MAX(seq), 0) as max_seq FROM (
SELECT CAST(SUBSTRING(order_number, ${prefixLen} + 1) AS UNSIGNED) AS seq
FROM orders WHERE order_number LIKE ${likePattern}
UNION ALL
SELECT CAST(SUBSTRING(project_number, ${prefixLen} + 1) AS UNSIGNED) AS seq
FROM projects WHERE project_number LIKE ${likePattern}
) combined
`;
const nextNum = Number(result[0]?.max_seq ?? 0) + 1;
return success(reply, { next_number: `${prefix}${String(nextNum).padStart(4, '0')}` });
});
// DELETE /api/admin/projects/:id/notes/:noteId
fastify.delete<{ Params: { id: string; noteId: string } }>('/:id/notes/:noteId', { preHandler: requirePermission('projects.edit') }, async (request, reply) => {
const noteId = parseId(request.params.noteId, reply);
if (noteId === null) return;
const projectId = parseId(request.params.id, reply);
if (projectId === null) return;
const note = await prisma.project_notes.findFirst({ where: { id: noteId, project_id: projectId } });
if (!note) return error(reply, 'Poznámka nenalezena', 404);
await prisma.project_notes.delete({ where: { id: noteId } });
await logAudit({ request, authData: request.authData, action: 'delete', entityType: 'project', entityId: projectId, description: `Smazána poznámka projektu` });
return success(reply, null, 200, 'Poznámka smazána');
});
fastify.delete<{ Params: { id: string } }>('/:id', { preHandler: requirePermission('projects.delete') }, async (request, reply) => {
const id = parseId(request.params.id, reply);
if (id === null) return;
const existing = await prisma.projects.findUnique({ where: { id } });
if (!existing) return error(reply, 'Projekt nenalezen', 404);
if (existing.order_id) return error(reply, 'Nelze smazat projekt propojený s objednávkou. Nejdříve smažte objednávku.', 400);
await prisma.projects.delete({ where: { id } });
await logAudit({ request, authData: request.authData, action: 'delete', entityType: 'project', entityId: id, description: `Smazán projekt ${existing.name}` });
return success(reply, null, 200, 'Projekt smazán');
});
}

View File

@@ -0,0 +1,326 @@
import { FastifyInstance } from 'fastify';
import prisma from '../../config/database';
import { requirePermission } from '../../middleware/auth';
import { logAudit } from '../../services/audit';
import { success, error, parseId } from '../../utils/response';
import { parsePagination, buildPaginationMeta } from '../../utils/pagination';
interface QuotationItemInput { description?: string; item_description?: string; quantity?: number; unit?: string; unit_price?: number; is_included_in_total?: boolean; position?: number }
interface ScopeSectionInput { title?: string; title_cz?: string; content?: string; position?: number }
const ALLOWED_SORT_FIELDS = ['id', 'quotation_number', 'project_code', 'created_at', 'valid_until', 'currency', 'status'];
export default async function quotationsRoutes(fastify: FastifyInstance): Promise<void> {
fastify.get('/', { preHandler: requirePermission('offers.view') }, async (request, reply) => {
const query = request.query as Record<string, unknown>;
const { page, limit, skip, sort, order, search } = parsePagination(query);
const sortField = ALLOWED_SORT_FIELDS.includes(sort) ? sort : 'id';
const where: Record<string, unknown> = {};
if (query.status) where.status = String(query.status);
if (query.customer_id) where.customer_id = Number(query.customer_id);
if (search) {
where.OR = [
{ quotation_number: { contains: search } },
{ project_code: { contains: search } },
{ customers: { name: { contains: search } } },
];
}
const [quotations, total] = await Promise.all([
prisma.quotations.findMany({
where,
skip,
take: limit,
orderBy: { [sortField]: order },
include: {
customers: { select: { id: true, name: true } },
quotation_items: { orderBy: { position: 'asc' } },
scope_sections: { orderBy: { position: 'asc' } },
},
}),
prisma.quotations.count({ where }),
]);
// Compute totals and map relation names
const enriched = quotations.map(q => {
const subtotal = q.quotation_items
.filter(i => i.is_included_in_total !== false)
.reduce((s, i) => s + (Number(i.quantity) || 0) * (Number(i.unit_price) || 0), 0);
const vatAmount = q.apply_vat ? subtotal * ((Number(q.vat_rate) || 21) / 100) : 0;
const { quotation_items, scope_sections, ...rest } = q;
return {
...rest,
items: quotation_items,
sections: scope_sections,
customer_name: q.customers?.name || null,
subtotal: Math.round(subtotal * 100) / 100,
vat_amount: Math.round(vatAmount * 100) / 100,
total: Math.round((subtotal + vatAmount) * 100) / 100,
};
});
return reply.send({ success: true, data: enriched, pagination: buildPaginationMeta(total, page, limit) });
});
// GET /api/admin/offers/next-number
fastify.get('/next-number', { preHandler: requirePermission('offers.create') }, async (_request, reply) => {
const settings = await prisma.company_settings.findFirst({ select: { quotation_prefix: true } });
const prefix = settings?.quotation_prefix || 'NA';
const year = new Date().getFullYear();
const likePattern = `${year}/${prefix}/%`;
// Match PHP logic: find MAX number from existing quotations
const result = await prisma.$queryRaw<[{ max_num: bigint | null }]>`
SELECT COALESCE(MAX(CAST(SUBSTRING_INDEX(quotation_number, '/', -1) AS UNSIGNED)), 0) as max_num
FROM quotations
WHERE quotation_number LIKE ${likePattern}
`;
const nextNum = Number(result[0]?.max_num ?? 0) + 1;
const number = `${year}/${prefix}/${String(nextNum).padStart(3, '0')}`;
return success(reply, { number, next_number: number });
});
// POST /api/admin/offers/:id/duplicate
fastify.post<{ Params: { id: string } }>('/:id/duplicate', { preHandler: requirePermission('offers.create') }, async (request, reply) => {
const id = parseId(request.params.id, reply);
if (id === null) return;
const original = await prisma.quotations.findUnique({
where: { id },
include: { quotation_items: { orderBy: { position: 'asc' } }, scope_sections: { orderBy: { position: 'asc' } } },
});
if (!original) return error(reply, 'Nabídka nenalezena', 404);
// Get next number by querying MAX from existing quotations (matches PHP logic)
const settings = await prisma.company_settings.findFirst({ select: { quotation_prefix: true } });
const qPrefix = settings?.quotation_prefix || 'NA';
const year = new Date().getFullYear();
const likePattern = `${year}/${qPrefix}/%`;
const result = await prisma.$queryRaw<[{ max_num: bigint | null }]>`
SELECT COALESCE(MAX(CAST(SUBSTRING_INDEX(quotation_number, '/', -1) AS UNSIGNED)), 0) as max_num
FROM quotations
WHERE quotation_number LIKE ${likePattern}
`;
const nextNum = Number(result[0]?.max_num ?? 0) + 1;
const copy = await prisma.quotations.create({
data: {
quotation_number: `${year}/${qPrefix}/${String(nextNum).padStart(3, '0')}`,
project_code: original.project_code,
customer_id: original.customer_id,
valid_until: null,
currency: original.currency,
language: original.language,
vat_rate: original.vat_rate,
apply_vat: original.apply_vat,
exchange_rate: original.exchange_rate,
status: 'active',
scope_title: original.scope_title,
scope_description: original.scope_description,
},
});
if (original.quotation_items.length > 0) {
await prisma.quotation_items.createMany({
data: original.quotation_items.map((item) => ({
quotation_id: copy.id,
description: item.description,
item_description: item.item_description,
quantity: item.quantity,
unit: item.unit,
unit_price: item.unit_price,
is_included_in_total: item.is_included_in_total,
position: item.position,
})),
});
}
if (original.scope_sections.length > 0) {
await prisma.scope_sections.createMany({
data: original.scope_sections.map((s) => ({
quotation_id: copy.id,
title: s.title,
title_cz: s.title_cz,
content: s.content,
position: s.position,
})),
});
}
await logAudit({ request, authData: request.authData, action: 'create', entityType: 'quotation', entityId: copy.id, description: `Duplikována nabídka ${original.quotation_number}${copy.quotation_number}` });
return success(reply, { id: copy.id, quotation_number: copy.quotation_number }, 201, 'Nabídka byla duplikována');
});
// POST /api/admin/offers/:id/invalidate
fastify.post<{ Params: { id: string } }>('/:id/invalidate', { preHandler: requirePermission('offers.edit') }, async (request, reply) => {
const id = parseId(request.params.id, reply);
if (id === null) return;
const existing = await prisma.quotations.findUnique({ where: { id } });
if (!existing) return error(reply, 'Nabídka nenalezena', 404);
await prisma.quotations.update({ where: { id }, data: { status: 'invalidated', modified_at: new Date() } });
await logAudit({ request, authData: request.authData, action: 'update', entityType: 'quotation', entityId: id, description: `Zneplatněna nabídka ${existing.quotation_number}` });
return success(reply, null, 200, 'Nabídka zneplatněna');
});
fastify.get<{ Params: { id: string } }>('/:id', { preHandler: requirePermission('offers.view') }, async (request, reply) => {
const id = parseId(request.params.id, reply);
if (id === null) return;
const quotation = await prisma.quotations.findUnique({
where: { id },
include: {
customers: true,
quotation_items: { orderBy: { position: 'asc' } },
scope_sections: { orderBy: { position: 'asc' } },
},
});
if (!quotation) return error(reply, 'Nabídka nenalezena', 404);
// Fetch linked order if exists
let orderInfo = null;
if (quotation.order_id) {
const order = await prisma.orders.findUnique({
where: { id: quotation.order_id },
select: { id: true, order_number: true, status: true },
});
orderInfo = order;
}
const { quotation_items, scope_sections, ...rest } = quotation;
return success(reply, {
...rest,
items: quotation_items,
sections: scope_sections,
customer: quotation.customers,
customer_name: quotation.customers?.name || null,
order: orderInfo,
});
});
fastify.post('/', { preHandler: requirePermission('offers.create') }, async (request, reply) => {
const body = request.body as Record<string, unknown>;
const quotation = await prisma.quotations.create({
data: {
quotation_number: body.quotation_number ? String(body.quotation_number) : null,
project_code: body.project_code ? String(body.project_code) : null,
customer_id: body.customer_id ? Number(body.customer_id) : null,
valid_until: body.valid_until ? new Date(String(body.valid_until)) : null,
currency: body.currency ? String(body.currency) : 'CZK',
language: body.language ? String(body.language) : 'cs',
vat_rate: body.vat_rate ? Number(body.vat_rate) : 21.0,
apply_vat: body.apply_vat !== false,
exchange_rate: body.exchange_rate ? Number(body.exchange_rate) : 1.0,
status: body.status ? String(body.status) : 'active',
scope_title: body.scope_title ? String(body.scope_title) : null,
scope_description: body.scope_description ? String(body.scope_description) : null,
},
});
if (Array.isArray(body.items)) {
await prisma.quotation_items.createMany({
data: (body.items as QuotationItemInput[]).map((item, i) => ({
quotation_id: quotation.id,
description: item.description ?? null,
item_description: item.item_description ?? null,
quantity: item.quantity ?? 1,
unit: item.unit ?? null,
unit_price: item.unit_price ?? 0,
is_included_in_total: item.is_included_in_total !== false,
position: item.position ?? i,
})),
});
}
if (Array.isArray(body.sections)) {
await prisma.scope_sections.createMany({
data: (body.sections as ScopeSectionInput[]).map((s, i) => ({
quotation_id: quotation.id,
title: s.title ?? null,
title_cz: s.title_cz ?? null,
content: s.content ?? null,
position: s.position ?? i,
})),
});
}
await logAudit({ request, authData: request.authData, action: 'create', entityType: 'quotation', entityId: quotation.id, description: `Vytvořena nabídka ${quotation.quotation_number}` });
return success(reply, { id: quotation.id }, 201, 'Nabídka byla vytvořena');
});
fastify.put<{ Params: { id: string } }>('/:id', { preHandler: requirePermission('offers.edit') }, async (request, reply) => {
const id = parseId(request.params.id, reply);
if (id === null) return;
const body = request.body as Record<string, unknown>;
const existing = await prisma.quotations.findUnique({ where: { id } });
if (!existing) return error(reply, 'Nabídka nenalezena', 404);
if (existing.status === 'invalidated') return error(reply, 'Nelze upravit zneplatněnou nabídku', 400);
await prisma.quotations.update({
where: { id },
data: {
quotation_number: body.quotation_number !== undefined ? String(body.quotation_number) : undefined,
customer_id: body.customer_id !== undefined ? Number(body.customer_id) : undefined,
valid_until: body.valid_until !== undefined ? (body.valid_until ? new Date(String(body.valid_until)) : null) : undefined,
currency: body.currency !== undefined ? String(body.currency) : undefined,
language: body.language !== undefined ? String(body.language) : undefined,
vat_rate: body.vat_rate !== undefined ? Number(body.vat_rate) : undefined,
apply_vat: body.apply_vat !== undefined ? (body.apply_vat === true || body.apply_vat === 1 || body.apply_vat === '1') : undefined,
exchange_rate: body.exchange_rate !== undefined ? Number(body.exchange_rate) : undefined,
status: body.status !== undefined ? String(body.status) : undefined,
project_code: body.project_code !== undefined ? (body.project_code ? String(body.project_code) : null) : undefined,
scope_title: body.scope_title !== undefined ? (body.scope_title ? String(body.scope_title) : null) : undefined,
scope_description: body.scope_description !== undefined ? (body.scope_description ? String(body.scope_description) : null) : undefined,
modified_at: new Date(),
},
});
if (Array.isArray(body.items) || Array.isArray(body.sections)) {
await prisma.$transaction(async (tx) => {
if (Array.isArray(body.items)) {
await tx.quotation_items.deleteMany({ where: { quotation_id: id } });
await tx.quotation_items.createMany({
data: (body.items as QuotationItemInput[]).map((item, i) => ({
quotation_id: id,
description: item.description ?? null,
item_description: item.item_description ?? null,
quantity: item.quantity ?? 1,
unit: item.unit ?? null,
unit_price: item.unit_price ?? 0,
is_included_in_total: item.is_included_in_total !== false,
position: item.position ?? i,
})),
});
}
if (Array.isArray(body.sections)) {
await tx.scope_sections.deleteMany({ where: { quotation_id: id } });
await tx.scope_sections.createMany({
data: (body.sections as ScopeSectionInput[]).map((s, i) => ({
quotation_id: id,
title: s.title ?? null,
title_cz: s.title_cz ?? null,
content: s.content ?? null,
position: s.position ?? i,
})),
});
}
});
}
await logAudit({ request, authData: request.authData, action: 'update', entityType: 'quotation', entityId: id, description: `Upravena nabídka ${existing.quotation_number}` });
return success(reply, { id }, 200, 'Nabídka byla uložena');
});
fastify.delete<{ Params: { id: string } }>('/:id', { preHandler: requirePermission('offers.delete') }, async (request, reply) => {
const id = parseId(request.params.id, reply);
if (id === null) return;
const existing = await prisma.quotations.findUnique({ where: { id } });
if (!existing) return error(reply, 'Nabídka nenalezena', 404);
await prisma.quotations.delete({ where: { id } });
await logAudit({ request, authData: request.authData, action: 'delete', entityType: 'quotation', entityId: id, description: `Smazána nabídka ${existing.quotation_number}` });
return success(reply, null, 200, 'Nabídka smazána');
});
}

View File

@@ -0,0 +1,284 @@
import { FastifyInstance } from 'fastify';
import multipart from '@fastify/multipart';
import { received_invoices_status } from '@prisma/client';
import prisma from '../../config/database';
import { requirePermission } from '../../middleware/auth';
import { logAudit } from '../../services/audit';
import { success, error, parseId } from '../../utils/response';
import { parsePagination, buildPaginationMeta } from '../../utils/pagination';
const VALID_STATUSES = ['unpaid', 'paid'] as const;
const ALLOWED_SORT_FIELDS = ['id', 'supplier_name', 'amount', 'issue_date', 'due_date', 'status', 'created_at'];
export default async function receivedInvoicesRoutes(fastify: FastifyInstance): Promise<void> {
await fastify.register(multipart, { limits: { fileSize: 50 * 1024 * 1024 } });
fastify.get('/', { preHandler: requirePermission('invoices.view') }, async (request, reply) => {
const query = request.query as Record<string, unknown>;
const { page, limit, skip, order } = parsePagination(query);
const where: Record<string, unknown> = {};
if (query.year) where.year = Number(query.year);
if (query.month) where.month = Number(query.month);
if (query.status) where.status = String(query.status);
if (query.supplier_name) where.supplier_name = { contains: String(query.supplier_name) };
// Search across supplier_name, invoice_number, description
if (query.search) {
const search = String(query.search);
where.OR = [
{ supplier_name: { contains: search } },
{ invoice_number: { contains: search } },
{ description: { contains: search } },
];
}
// Sort field whitelisting
const sortField = query.sort && ALLOWED_SORT_FIELDS.includes(String(query.sort)) ? String(query.sort) : 'id';
const [invoices, total] = await Promise.all([
prisma.received_invoices.findMany({ where, skip, take: limit, orderBy: { [sortField]: order } }),
prisma.received_invoices.count({ where }),
]);
return reply.send({ success: true, data: invoices, pagination: buildPaginationMeta(total, page, limit) });
});
// GET /api/admin/received-invoices/stats
fastify.get('/stats', { preHandler: requirePermission('invoices.view') }, async (request, reply) => {
const query = request.query as Record<string, unknown>;
const now = new Date();
const year = Number(query.year) || now.getFullYear();
const month = Number(query.month) || (now.getMonth() + 1);
const where: Record<string, unknown> = { year, month };
const monthInvoices = await prisma.received_invoices.findMany({ where });
// Aggregate by currency → CurrencyAmount[] format
const aggregateByCurrency = (invs: typeof monthInvoices, field: 'amount' | 'vat_amount') => {
const map: Record<string, number> = {};
for (const inv of invs) {
const cur = inv.currency || 'CZK';
map[cur] = (map[cur] || 0) + (Number(inv[field]) || 0);
}
return Object.entries(map).filter(([, v]) => v > 0).map(([currency, amount]) => ({ amount: Math.round(amount * 100) / 100, currency }));
};
const sumCzk = (invs: typeof monthInvoices, field: 'amount' | 'vat_amount') => {
let total = 0;
for (const inv of invs) total += Number(inv[field]) || 0;
return Math.round(total * 100) / 100;
};
// Also get all-time unpaid
const allUnpaid = await prisma.received_invoices.findMany({ where: { status: { not: 'paid' } } });
return success(reply, {
total_month: aggregateByCurrency(monthInvoices, 'amount'),
total_month_czk: sumCzk(monthInvoices, 'amount'),
vat_month: aggregateByCurrency(monthInvoices, 'vat_amount'),
vat_month_czk: sumCzk(monthInvoices, 'vat_amount'),
unpaid: aggregateByCurrency(allUnpaid, 'amount'),
unpaid_czk: sumCzk(allUnpaid, 'amount'),
unpaid_count: allUnpaid.length,
month_count: monthInvoices.length,
});
});
// GET /api/admin/received-invoices/:id/file
fastify.get<{ Params: { id: string } }>('/:id/file', { preHandler: requirePermission('invoices.view') }, async (request, reply) => {
const id = parseId(request.params.id, reply);
if (id === null) return;
const invoice = await prisma.received_invoices.findUnique({
where: { id },
select: { file_data: true, file_name: true, file_mime: true },
});
if (!invoice?.file_data) return error(reply, 'Soubor nenalezen', 404);
const mime = invoice.file_mime || 'application/pdf';
const filename = invoice.file_name || `received-invoice-${id}.pdf`;
return reply
.type(mime)
.header('Content-Disposition', `inline; filename="${filename}"`)
.send(Buffer.from(invoice.file_data));
});
fastify.get<{ Params: { id: string } }>('/:id', { preHandler: requirePermission('invoices.view') }, async (request, reply) => {
const id = parseId(request.params.id, reply);
if (id === null) return;
const invoice = await prisma.received_invoices.findUnique({ where: { id } });
if (!invoice) return error(reply, 'Přijatá faktura nenalezena', 404);
// Don't send file_data in detail response (can be large)
const { file_data: _fileData, ...rest } = invoice;
return success(reply, rest);
});
fastify.post('/', { preHandler: requirePermission('invoices.create') }, async (request, reply) => {
const contentType = request.headers['content-type'] || '';
// Multipart upload: files[] + invoices JSON metadata
if (contentType.includes('multipart/form-data')) {
const parts = request.parts();
const files: Array<{ data: Buffer; name: string; mime: string; size: number }> = [];
let invoicesMeta: Array<Record<string, unknown>> = [];
for await (const part of parts) {
if (part.type === 'file') {
const buf = await part.toBuffer();
files.push({ data: buf, name: part.filename || 'file', mime: part.mimetype || 'application/octet-stream', size: buf.length });
} else if (part.fieldname === 'invoices') {
try { invoicesMeta = JSON.parse(part.value as string); } catch { /* ignore parse error */ }
}
}
if (files.length === 0) return error(reply, 'Vyberte alespoň jeden soubor', 400);
const now = new Date();
const createdIds: number[] = [];
for (let i = 0; i < files.length; i++) {
const file = files[i];
const meta = invoicesMeta[i] || {};
const amount = Number(meta.amount ?? 0);
const vatRate = Number(meta.vat_rate ?? 21);
const vatAmount = Math.round(amount * vatRate) / 100;
const invoice = await prisma.received_invoices.create({
data: {
month: Number(meta.month) || (now.getMonth() + 1),
year: Number(meta.year) || now.getFullYear(),
supplier_name: meta.supplier_name ? String(meta.supplier_name) : file.name,
invoice_number: meta.invoice_number ? String(meta.invoice_number) : null,
description: meta.description ? String(meta.description) : null,
amount,
currency: meta.currency ? String(meta.currency) : 'CZK',
vat_rate: vatRate,
vat_amount: vatAmount,
issue_date: meta.issue_date ? new Date(String(meta.issue_date)) : null,
due_date: meta.due_date ? new Date(String(meta.due_date)) : null,
status: 'unpaid',
notes: meta.notes ? String(meta.notes) : null,
uploaded_by: request.authData?.userId,
file_data: Uint8Array.from(file.data),
file_name: file.name,
file_mime: file.mime,
file_size: file.size,
},
});
createdIds.push(invoice.id);
}
await logAudit({ request, authData: request.authData, action: 'create', entityType: 'invoice', entityId: createdIds[0], description: `Nahráno ${createdIds.length} přijatých faktur` });
return success(reply, { ids: createdIds, count: createdIds.length }, 201, `Nahráno ${createdIds.length} faktur`);
}
// JSON body: single invoice creation (no file)
const body = request.body as Record<string, unknown>;
const status = body.status ? String(body.status) : 'unpaid';
if (!VALID_STATUSES.includes(status as typeof VALID_STATUSES[number])) {
return error(reply, 'Neplatný stav', 400);
}
const amount = Number(body.amount ?? 0);
const vatRate = Number(body.vat_rate ?? 21);
if (!body.supplier_name) return error(reply, 'Název dodavatele je povinný', 400);
const invoice = await prisma.received_invoices.create({
data: {
month: Number(body.month),
year: Number(body.year),
supplier_name: String(body.supplier_name),
invoice_number: body.invoice_number ? String(body.invoice_number) : null,
description: body.description ? String(body.description) : null,
amount,
currency: body.currency ? String(body.currency) : 'CZK',
vat_rate: vatRate,
vat_amount: Number(body.vat_amount ?? 0),
issue_date: body.issue_date ? new Date(String(body.issue_date)) : null,
due_date: body.due_date ? new Date(String(body.due_date)) : null,
status: status as received_invoices_status,
notes: body.notes ? String(body.notes) : null,
uploaded_by: request.authData?.userId,
},
});
await logAudit({ request, authData: request.authData, action: 'create', entityType: 'invoice', entityId: invoice.id, description: `Vytvořena přijatá faktura od ${invoice.supplier_name}` });
return success(reply, { id: invoice.id }, 201, 'Faktura byla vytvořena');
});
fastify.put<{ Params: { id: string } }>('/:id', { preHandler: requirePermission('invoices.edit') }, async (request, reply) => {
const id = parseId(request.params.id, reply);
if (id === null) return;
const body = request.body as Record<string, unknown>;
const existing = await prisma.received_invoices.findUnique({ where: { id } });
if (!existing) return error(reply, 'Přijatá faktura nenalezena', 404);
if (body.status !== undefined) {
const status = String(body.status);
if (!VALID_STATUSES.includes(status as typeof VALID_STATUSES[number])) {
return error(reply, 'Neplatný stav', 400);
}
// Prevent reverting paid status (matching PHP)
if (String(existing.status) === 'paid' && status !== 'paid') {
return error(reply, 'Nelze vrátit stav uhrazené faktury', 400);
}
}
// Recalculate vat_amount when amount or vat_rate changes (matching PHP)
const finalAmount = body.amount !== undefined ? Number(body.amount) : Number(existing.amount);
const finalVatRate = body.vat_rate !== undefined ? Number(body.vat_rate) : Number(existing.vat_rate);
const computedVat = Math.round(finalAmount * finalVatRate) / 100;
// Auto-set paid_date when status transitions to paid (matching PHP)
const newStatus = body.status !== undefined ? String(body.status) : String(existing.status);
const paidDate = newStatus === 'paid' && String(existing.status) !== 'paid'
? new Date()
: (body.paid_date !== undefined ? (body.paid_date ? new Date(String(body.paid_date)) : null) : undefined);
// Auto-update month/year from issue_date if issue_date changes (matching PHP)
let autoMonth = body.month !== undefined ? Number(body.month) : undefined;
let autoYear = body.year !== undefined ? Number(body.year) : undefined;
if (body.issue_date && !body.month && !body.year) {
const issueDate = new Date(String(body.issue_date));
if (!isNaN(issueDate.getTime())) {
autoMonth = issueDate.getMonth() + 1;
autoYear = issueDate.getFullYear();
}
}
await prisma.received_invoices.update({
where: { id },
data: {
supplier_name: body.supplier_name !== undefined ? String(body.supplier_name) : undefined,
invoice_number: body.invoice_number !== undefined ? (body.invoice_number ? String(body.invoice_number) : null) : undefined,
description: body.description !== undefined ? (body.description ? String(body.description) : null) : undefined,
amount: body.amount !== undefined ? Number(body.amount) : undefined,
currency: body.currency !== undefined ? String(body.currency) : undefined,
vat_rate: body.vat_rate !== undefined ? Number(body.vat_rate) : undefined,
vat_amount: (body.amount !== undefined || body.vat_rate !== undefined) ? computedVat : (body.vat_amount !== undefined ? Number(body.vat_amount) : undefined),
issue_date: body.issue_date !== undefined ? (body.issue_date ? new Date(String(body.issue_date)) : null) : undefined,
due_date: body.due_date !== undefined ? (body.due_date ? new Date(String(body.due_date)) : null) : undefined,
paid_date: paidDate,
status: body.status !== undefined ? String(body.status) as received_invoices_status : undefined,
notes: body.notes !== undefined ? (body.notes ? String(body.notes) : null) : undefined,
month: autoMonth,
year: autoYear,
modified_at: new Date(),
},
});
await logAudit({ request, authData: request.authData, action: 'update', entityType: 'invoice', entityId: id, description: `Upravena přijatá faktura` });
return success(reply, { id }, 200, 'Faktura byla uložena');
});
fastify.delete<{ Params: { id: string } }>('/:id', { preHandler: requirePermission('invoices.delete') }, async (request, reply) => {
const id = parseId(request.params.id, reply);
if (id === null) return;
const existing = await prisma.received_invoices.findUnique({ where: { id } });
if (!existing) return error(reply, 'Přijatá faktura nenalezena', 404);
await prisma.received_invoices.delete({ where: { id } });
await logAudit({ request, authData: request.authData, action: 'delete', entityType: 'invoice', entityId: id, description: `Smazána přijatá faktura` });
return success(reply, null, 200, 'Přijatá faktura smazána');
});
}

130
src/routes/admin/roles.ts Normal file
View File

@@ -0,0 +1,130 @@
import { FastifyInstance } from 'fastify';
import prisma from '../../config/database';
import { requirePermission } from '../../middleware/auth';
import { logAudit } from '../../services/audit';
import { success, error, parseId } from '../../utils/response';
export default async function rolesRoutes(fastify: FastifyInstance): Promise<void> {
// GET /api/admin/roles
fastify.get('/', { preHandler: requirePermission('settings.roles') }, async (request, reply) => {
const roles = await prisma.roles.findMany({
include: {
role_permissions: {
include: { permissions: true },
},
},
orderBy: { id: 'asc' },
});
const data = roles.map(r => ({
...r,
permissions: r.role_permissions.map(rp => rp.permissions),
}));
return success(reply, data);
});
// GET /api/admin/roles/permissions
fastify.get('/permissions', { preHandler: requirePermission('settings.roles') }, async (_request, reply) => {
const permissions = await prisma.permissions.findMany({ orderBy: { module: 'asc' } });
return success(reply, permissions);
});
// POST /api/admin/roles
fastify.post('/', { preHandler: requirePermission('settings.roles') }, async (request, reply) => {
const body = request.body as Record<string, unknown>;
const role = await prisma.roles.create({
data: {
name: String(body.name),
display_name: String(body.display_name),
description: body.description ? String(body.description) : null,
},
});
if (Array.isArray(body.permission_ids)) {
await prisma.role_permissions.createMany({
data: (body.permission_ids as number[]).map((pid) => ({
role_id: role.id,
permission_id: pid,
})),
});
}
await logAudit({
request,
authData: request.authData,
action: 'create',
entityType: 'role',
entityId: role.id,
description: `Vytvořena role ${role.name}`,
});
return success(reply, { id: role.id }, 201, 'Role byla vytvořena');
});
// PUT /api/admin/roles/:id
fastify.put<{ Params: { id: string } }>('/:id', { preHandler: requirePermission('settings.roles') }, async (request, reply) => {
const id = parseId(request.params.id, reply);
if (id === null) return;
const body = request.body as Record<string, unknown>;
const existing = await prisma.roles.findUnique({ where: { id } });
if (!existing) return error(reply, 'Role nenalezena', 404);
await prisma.roles.update({
where: { id },
data: {
display_name: body.display_name ? String(body.display_name) : undefined,
description: body.description !== undefined ? String(body.description) : undefined,
},
});
if (Array.isArray(body.permission_ids)) {
await prisma.role_permissions.deleteMany({ where: { role_id: id } });
await prisma.role_permissions.createMany({
data: (body.permission_ids as number[]).map((pid) => ({
role_id: id,
permission_id: pid,
})),
});
}
await logAudit({
request,
authData: request.authData,
action: 'update',
entityType: 'role',
entityId: id,
description: `Upravena role ${existing.name}`,
});
return success(reply, { id }, 200, 'Role byla aktualizována');
});
// DELETE /api/admin/roles/:id
fastify.delete<{ Params: { id: string } }>('/:id', { preHandler: requirePermission('settings.roles') }, async (request, reply) => {
const id = parseId(request.params.id, reply);
if (id === null) return;
const existing = await prisma.roles.findUnique({ where: { id } });
if (!existing) return error(reply, 'Role nenalezena', 404);
if (existing.name === 'admin') {
return error(reply, 'Nelze smazat roli admin', 400);
}
await prisma.roles.delete({ where: { id } });
await logAudit({
request,
authData: request.authData,
action: 'delete',
entityType: 'role',
entityId: id,
description: `Smazána role ${existing.name}`,
});
return success(reply, { id }, 200, 'Role byla smazána');
});
}

View File

@@ -0,0 +1,148 @@
import { FastifyInstance } from 'fastify';
import prisma from '../../config/database';
import { requirePermission } from '../../middleware/auth';
import { success, error, parseId } from '../../utils/response';
interface ScopeSectionInput { title?: string; title_cz?: string; content?: string; position?: number }
export default async function scopeTemplatesRoutes(fastify: FastifyInstance): Promise<void> {
// Legacy ?action= dispatcher for item templates
fastify.get('/', { preHandler: requirePermission('offers.settings') }, async (request, reply) => {
const query = request.query as Record<string, unknown>;
const action = query.action ? String(query.action) : null;
// Item templates
if (action === 'items') {
const items = await prisma.item_templates.findMany({
where: { is_deleted: false },
orderBy: { name: 'asc' },
});
return success(reply, items);
}
// Default: scope templates
const templates = await prisma.scope_templates.findMany({
where: { is_deleted: false },
include: { scope_template_sections: { where: { is_deleted: false }, orderBy: { position: 'asc' } } },
orderBy: { name: 'asc' },
});
return success(reply, templates);
});
// Item template CRUD via ?action=item
fastify.post('/', { preHandler: requirePermission('offers.settings') }, async (request, reply) => {
const query = request.query as Record<string, unknown>;
const body = request.body as Record<string, unknown>;
if (String(query.action) === 'item') {
const itemData = {
name: body.name ? String(body.name) : null,
description: body.description ? String(body.description) : null,
default_price: body.default_price != null ? Number(body.default_price) : 0,
category: body.category ? String(body.category) : null,
};
// Update existing item if id is provided
if (body.id) {
const existingItem = await prisma.item_templates.findUnique({ where: { id: Number(body.id) } });
if (!existingItem) return error(reply, 'Šablona nenalezena', 404);
await prisma.item_templates.update({
where: { id: Number(body.id) },
data: { ...itemData, modified_at: new Date() },
});
return success(reply, { id: Number(body.id) }, 200, 'Položka byla uložena');
}
const item = await prisma.item_templates.create({ data: itemData });
return success(reply, { id: item.id }, 201, 'Položka byla vytvořena');
}
// Scope template create (original logic below)
const template = await prisma.scope_templates.create({
data: {
name: body.name ? String(body.name) : null,
title: body.title ? String(body.title) : null,
description: body.description ? String(body.description) : null,
},
});
if (Array.isArray(body.sections)) {
await prisma.scope_template_sections.createMany({
data: (body.sections as ScopeSectionInput[]).map((s, i) => ({
scope_template_id: template.id,
title: s.title ?? null,
title_cz: s.title_cz ?? null,
content: s.content ?? null,
position: s.position ?? i,
})),
});
}
return success(reply, { id: template.id }, 201, 'Šablona byla vytvořena');
});
// Item template delete via DELETE ?action=item&id=X
fastify.delete('/', { preHandler: requirePermission('offers.settings') }, async (request, reply) => {
const query = request.query as Record<string, unknown>;
if (String(query.action) === 'item' && query.id) {
const id = Number(query.id);
await prisma.item_templates.update({ where: { id }, data: { is_deleted: true, modified_at: new Date() } });
return success(reply, null, 200, 'Šablona smazána');
}
return error(reply, 'Neplatná akce', 400);
});
fastify.get<{ Params: { id: string } }>('/:id', { preHandler: requirePermission('offers.settings') }, async (request, reply) => {
const id = parseId(request.params.id, reply);
if (id === null) return;
const template = await prisma.scope_templates.findUnique({
where: { id },
include: { scope_template_sections: { where: { is_deleted: false }, orderBy: { position: 'asc' } } },
});
if (!template || template.is_deleted) return error(reply, 'Šablona nenalezena', 404);
return success(reply, template);
});
fastify.put<{ Params: { id: string } }>('/:id', { preHandler: requirePermission('offers.settings') }, async (request, reply) => {
const id = parseId(request.params.id, reply);
if (id === null) return;
const body = request.body as Record<string, unknown>;
const existing = await prisma.scope_templates.findUnique({ where: { id } });
if (!existing) return error(reply, 'Šablona nenalezena', 404);
await prisma.scope_templates.update({
where: { id },
data: {
name: body.name !== undefined ? String(body.name) : undefined,
title: body.title !== undefined ? String(body.title) : undefined,
description: body.description !== undefined ? String(body.description) : undefined,
modified_at: new Date(),
},
});
if (Array.isArray(body.sections)) {
await prisma.scope_template_sections.deleteMany({ where: { scope_template_id: id } });
await prisma.scope_template_sections.createMany({
data: (body.sections as ScopeSectionInput[]).map((s, i) => ({
scope_template_id: id,
title: s.title ?? null,
title_cz: s.title_cz ?? null,
content: s.content ?? null,
position: s.position ?? i,
})),
});
}
return success(reply, { id }, 200, 'Šablona byla uložena');
});
fastify.delete<{ Params: { id: string } }>('/:id', { preHandler: requirePermission('offers.settings') }, async (request, reply) => {
const id = parseId(request.params.id, reply);
if (id === null) return;
await prisma.scope_templates.update({ where: { id }, data: { is_deleted: true, modified_at: new Date() } });
return success(reply, null, 200, 'Šablona smazána');
});
}

View File

@@ -0,0 +1,106 @@
import { FastifyInstance } from 'fastify';
import crypto from 'crypto';
import prisma from '../../config/database';
import { requireAuth } from '../../middleware/auth';
import { success, error } from '../../utils/response';
function hashToken(token: string): string {
return crypto.createHash('sha256').update(token).digest('hex');
}
/** Parse user-agent string into browser, OS, and device icon */
function parseUserAgent(ua: string | null): { browser: string; os: string; icon: string } {
if (!ua) return { browser: 'Neznámý prohlížeč', os: 'Neznámý systém', icon: 'monitor' };
// Browser detection
let browser = 'Neznámý prohlížeč';
if (ua.includes('Edg/')) browser = 'Edge';
else if (ua.includes('OPR/') || ua.includes('Opera')) browser = 'Opera';
else if (ua.includes('Chrome/')) browser = 'Chrome';
else if (ua.includes('Safari/') && !ua.includes('Chrome')) browser = 'Safari';
else if (ua.includes('Firefox/')) browser = 'Firefox';
// OS detection
let os = 'Neznámý systém';
if (ua.includes('Windows')) os = 'Windows';
else if (ua.includes('Mac OS X') || ua.includes('Macintosh')) os = 'macOS';
else if (ua.includes('Linux') && !ua.includes('Android')) os = 'Linux';
else if (ua.includes('Android')) os = 'Android';
else if (ua.includes('iPhone') || ua.includes('iPad')) os = 'iOS';
// Device icon
let icon = 'monitor';
if (ua.includes('Mobile') || ua.includes('iPhone') || ua.includes('Android')) {
icon = ua.includes('iPad') || ua.includes('Tablet') ? 'tablet' : 'smartphone';
}
return { browser, os, icon };
}
export default async function sessionsRoutes(fastify: FastifyInstance): Promise<void> {
// GET /api/admin/sessions — list active sessions for current user
fastify.get('/', { preHandler: requireAuth }, async (request, reply) => {
const authData = request.authData!;
const currentToken = request.cookies?.refresh_token;
const currentHash = currentToken ? hashToken(currentToken) : null;
const sessions = await prisma.refresh_tokens.findMany({
where: { user_id: authData.userId, replaced_at: null, expires_at: { gt: new Date() } },
orderBy: { created_at: 'desc' },
});
const enriched = sessions.map(s => {
const device_info = parseUserAgent(s.user_agent);
return {
id: s.id,
is_current: currentHash ? s.token_hash === currentHash : false,
device_info,
ip_address: s.ip_address || '',
created_at: s.created_at ? s.created_at.toISOString() : '',
};
});
return success(reply, enriched);
});
// DELETE /api/admin/sessions/:id — delete specific session
fastify.delete<{ Params: { id: string } }>('/:id', { preHandler: requireAuth }, async (request, reply) => {
const id = parseInt(request.params.id, 10);
const authData = request.authData!;
const session = await prisma.refresh_tokens.findFirst({
where: { id, user_id: authData.userId },
});
if (!session) return error(reply, 'Relace nenalezena', 404);
await prisma.refresh_tokens.update({
where: { id },
data: { replaced_at: new Date() },
});
return success(reply, null, 200, 'Relace ukončena');
});
// DELETE /api/admin/sessions — delete all sessions except current
fastify.delete('/', { preHandler: requireAuth }, async (request, reply) => {
const authData = request.authData!;
const query = request.query as Record<string, unknown>;
if (query.action === 'all') {
// Get current token from cookie to exclude (hash it to match stored token_hash)
const currentToken = request.cookies?.refresh_token;
const currentHash = currentToken ? hashToken(currentToken) : null;
await prisma.refresh_tokens.updateMany({
where: {
user_id: authData.userId,
replaced_at: null,
...(currentHash ? { token_hash: { not: currentHash } } : {}),
},
data: { replaced_at: new Date() },
});
return success(reply, null, 200, 'Všechny ostatní relace ukončeny');
}
return error(reply, 'Neplatná akce', 400);
});
}

237
src/routes/admin/totp.ts Normal file
View File

@@ -0,0 +1,237 @@
import { FastifyInstance } from 'fastify';
import crypto from 'crypto';
import bcrypt from 'bcryptjs';
import prisma from '../../config/database';
import { requireAuth, requirePermission } from '../../middleware/auth';
import { success, error } from '../../utils/response';
import { encrypt } from '../../utils/encryption';
import { OTPAuth } from '../../utils/totp';
import * as OTPAuthLib from 'otpauth';
import { logAudit } from '../../services/audit';
export default async function totpRoutes(fastify: FastifyInstance): Promise<void> {
// GET - generate new TOTP secret
fastify.get('/setup', { preHandler: requireAuth }, async (request, reply) => {
const secret = new OTPAuthLib.Secret();
const totp = new OTPAuthLib.TOTP({
issuer: 'BOHA Automation',
label: request.authData!.email,
secret,
algorithm: 'SHA1',
digits: 6,
period: 30,
});
return success(reply, {
secret: secret.base32,
uri: totp.toString(),
});
});
// POST - enable TOTP
fastify.post('/enable', { preHandler: requireAuth }, async (request, reply) => {
const body = request.body as Record<string, unknown>;
const { secret, code } = body;
if (!secret || !code) {
return error(reply, 'Secret a kód jsou povinné', 400);
}
// Verify the code first
const totp = new OTPAuthLib.TOTP({
secret: OTPAuthLib.Secret.fromBase32(String(secret)),
algorithm: 'SHA1',
digits: 6,
period: 30,
});
const delta = totp.validate({ token: String(code), window: 1 });
if (delta === null) {
return error(reply, 'Neplatný TOTP kód', 400);
}
// Generate 8 backup codes
const backupCodesPlain: string[] = [];
const backupCodesHashed: string[] = [];
for (let i = 0; i < 8; i++) {
const code = crypto.randomBytes(4).toString('hex').toUpperCase();
backupCodesPlain.push(code);
backupCodesHashed.push(bcrypt.hashSync(code, 10));
}
// Encrypt and store
const encryptedSecret = encrypt(String(secret));
await prisma.users.update({
where: { id: request.authData!.userId },
data: {
totp_secret: encryptedSecret,
totp_enabled: true,
totp_backup_codes: JSON.stringify(backupCodesHashed),
},
});
await logAudit({ request, authData: request.authData, action: 'update', entityType: 'user', entityId: request.authData!.userId, description: '2FA aktivováno' });
return success(reply, { backup_codes: backupCodesPlain }, 200, '2FA aktivováno');
});
// PUT - disable TOTP
fastify.put('/disable', { preHandler: requireAuth }, async (request, reply) => {
const body = request.body as Record<string, unknown>;
if (!body.code) {
return error(reply, 'TOTP kód je povinný pro deaktivaci', 400);
}
const user = await prisma.users.findUnique({ where: { id: request.authData!.userId } });
if (!user?.totp_secret) {
return error(reply, '2FA není aktivní', 400);
}
const isValid = OTPAuth.verify(user.totp_secret, String(body.code));
if (!isValid) {
return error(reply, 'Neplatný TOTP kód', 400);
}
await prisma.users.update({
where: { id: request.authData!.userId },
data: { totp_secret: null, totp_enabled: false, totp_backup_codes: null },
});
await logAudit({ request, authData: request.authData, action: 'update', entityType: 'user', entityId: request.authData!.userId, description: '2FA deaktivováno' });
return success(reply, null, 200, '2FA deaktivováno');
});
// GET - TOTP status for current user
fastify.get('/status', { preHandler: requireAuth }, async (request, reply) => {
const user = await prisma.users.findUnique({
where: { id: request.authData!.userId },
select: { totp_enabled: true },
});
return success(reply, { totp_enabled: user?.totp_enabled ?? false });
});
// GET - check if 2FA is required company-wide
fastify.get('/required', { preHandler: [requireAuth, requirePermission('settings.security')] }, async (request, reply) => {
const settings = await prisma.company_settings.findFirst({
select: { require_2fa: true },
});
return success(reply, { require_2fa: settings?.require_2fa ?? false });
});
// POST - toggle mandatory 2FA
fastify.post('/required', { preHandler: [requireAuth, requirePermission('settings.security')] }, async (request, reply) => {
const body = request.body as Record<string, unknown>;
const required = body.required === true || body.required === 1 || body.required === '1';
await prisma.company_settings.updateMany({
data: { require_2fa: required },
});
const message = required
? '2FA je nyní povinné pro všechny uživatele'
: '2FA již není povinné';
return success(reply, null, 200, message);
});
// POST - verify backup code (pre-auth, no requireAuth)
fastify.post('/backup-verify', async (request, reply) => {
const body = request.body as Record<string, unknown>;
const { login_token, code } = body;
if (!login_token || !code) {
return error(reply, 'Login token a záložní kód jsou povinné', 400);
}
const tokenHash = crypto.createHash('sha256').update(String(login_token)).digest('hex');
const storedToken = await prisma.totp_login_tokens.findFirst({
where: { token_hash: tokenHash },
});
if (!storedToken || new Date(storedToken.expires_at) < new Date()) {
return error(reply, 'Neplatný nebo expirovaný login token', 401);
}
const user = await prisma.users.findUnique({
where: { id: storedToken.user_id },
include: { roles: true },
});
if (!user || !user.totp_backup_codes) {
return error(reply, 'Uživatel nenalezen', 401);
}
const backupCodes: string[] = JSON.parse(user.totp_backup_codes as string);
let matchIndex = -1;
for (let i = 0; i < backupCodes.length; i++) {
const isMatch = await bcrypt.compare(String(code), backupCodes[i]);
if (isMatch) {
matchIndex = i;
break;
}
}
if (matchIndex === -1) {
return error(reply, 'Neplatný záložní kód', 401);
}
// Remove used backup code
backupCodes.splice(matchIndex, 1);
await prisma.users.update({
where: { id: user.id },
data: {
totp_backup_codes: JSON.stringify(backupCodes),
failed_login_attempts: 0,
locked_until: null,
last_login: new Date(),
},
});
// Delete used login token
await prisma.totp_login_tokens.delete({ where: { id: storedToken.id } });
// Create tokens (same as /login/totp flow)
const { loadAuthData } = await import('../../services/auth');
const authData = await loadAuthData(user.id);
if (!authData) {
return error(reply, 'Chyba načítání uživatele', 500);
}
const jwt = await import('jsonwebtoken');
const { config } = await import('../../config/env');
const accessToken = jwt.default.sign(
{ sub: user.id, username: user.username, role: user.roles?.name ?? null },
config.jwt.secret,
{ expiresIn: config.jwt.accessTokenExpiry },
);
const refreshTokenRaw = crypto.randomBytes(32).toString('hex');
const refreshTokenHash = crypto.createHash('sha256').update(refreshTokenRaw).digest('hex');
await prisma.refresh_tokens.create({
data: {
user_id: user.id,
token_hash: refreshTokenHash,
expires_at: new Date(Date.now() + config.jwt.refreshTokenSessionExpiry * 1000),
remember_me: false,
ip_address: request.ip,
user_agent: request.headers['user-agent'] ?? null,
},
});
reply.setCookie('refresh_token', refreshTokenRaw, {
httpOnly: true,
secure: config.isProduction,
sameSite: 'strict',
path: '/api/admin',
maxAge: config.jwt.refreshTokenSessionExpiry,
});
return success(reply, { access_token: accessToken, user: authData });
});
}

222
src/routes/admin/trips.ts Normal file
View File

@@ -0,0 +1,222 @@
import { FastifyInstance } from 'fastify';
import prisma from '../../config/database';
import { requireAuth, requirePermission } from '../../middleware/auth';
import { logAudit } from '../../services/audit';
import { success, error } from '../../utils/response';
import { parsePagination, buildPaginationMeta } from '../../utils/pagination';
export default async function tripsRoutes(fastify: FastifyInstance): Promise<void> {
fastify.get('/', { preHandler: requireAuth }, async (request, reply) => {
const query = request.query as Record<string, unknown>;
const { page, limit, skip, order } = parsePagination(query);
const authData = request.authData!;
const isAdmin = authData.permissions.includes('trips.admin');
const where: Record<string, unknown> = {};
if (!isAdmin) where.user_id = authData.userId;
else if (query.user_id) where.user_id = Number(query.user_id);
if (query.vehicle_id) where.vehicle_id = Number(query.vehicle_id);
// Support both "month=3&year=2026" (TripsAdmin) and "month=2026-03" (TripsHistory) formats
if (query.month) {
const monthStr = String(query.month);
let yr: number, mo: number;
if (monthStr.includes('-')) {
// Combined YYYY-MM format
const [yStr, mStr] = monthStr.split('-');
yr = Number(yStr);
mo = Number(mStr);
} else if (query.year) {
yr = Number(query.year);
mo = Number(query.month);
} else {
yr = NaN;
mo = NaN;
}
if (!isNaN(yr) && !isNaN(mo) && mo >= 1 && mo <= 12) {
where.trip_date = {
gte: new Date(yr, mo - 1, 1),
lt: new Date(yr, mo, 1),
};
}
}
const [trips, total] = await Promise.all([
prisma.trips.findMany({
where, skip, take: limit, orderBy: { trip_date: order },
include: {
users: { select: { id: true, first_name: true, last_name: true } },
vehicles: { select: { id: true, name: true, spz: true } },
},
}),
prisma.trips.count({ where }),
]);
return reply.send({ success: true, data: trips, pagination: buildPaginationMeta(total, page, limit) });
});
// GET /api/admin/trips/print — print data for trip report
fastify.get('/print', { preHandler: requirePermission('trips.admin') }, async (request, reply) => {
const query = request.query as Record<string, unknown>;
const filterUserId = query.user_id ? Number(query.user_id) : null;
const filterVehicleId = query.vehicle_id ? Number(query.vehicle_id) : null;
const where: Record<string, unknown> = {};
if (filterUserId) where.user_id = filterUserId;
if (filterVehicleId) where.vehicle_id = filterVehicleId;
if (query.month && query.year) {
where.trip_date = {
gte: new Date(Number(query.year), Number(query.month) - 1, 1),
lt: new Date(Number(query.year), Number(query.month), 1),
};
}
const trips = await prisma.trips.findMany({
where,
include: {
users: { select: { id: true, first_name: true, last_name: true } },
vehicles: { select: { id: true, name: true, spz: true } },
},
orderBy: { trip_date: 'asc' },
});
const vehicles = await prisma.vehicles.findMany({ orderBy: { name: 'asc' } });
const users = await prisma.users.findMany({
where: { is_active: true },
select: { id: true, first_name: true, last_name: true },
orderBy: { last_name: 'asc' },
});
let totalKm = 0;
let businessKm = 0;
let privateKm = 0;
for (const t of trips) {
const dist = Number(t.end_km) - Number(t.start_km);
totalKm += dist;
if (t.is_business) businessKm += dist;
else privateKm += dist;
}
return success(reply, {
trips,
vehicles,
users: users.map(u => ({ id: u.id, name: `${u.first_name} ${u.last_name}`.trim() })),
totals: { total_km: totalKm, business_km: businessKm, private_km: privateKm, count: trips.length },
});
});
// GET /api/admin/trips/last-km/:vehicleId
fastify.get<{ Params: { vehicleId: string } }>('/last-km/:vehicleId', { preHandler: requireAuth }, async (request, reply) => {
const vehicleId = parseInt(request.params.vehicleId, 10);
if (isNaN(vehicleId)) return error(reply, 'Neplatné ID vozidla', 400);
const lastTrip = await prisma.trips.findFirst({
where: { vehicle_id: vehicleId },
orderBy: { id: 'desc' },
select: { end_km: true },
});
return success(reply, { last_km: lastTrip ? Number(lastTrip.end_km) : 0 });
});
fastify.post('/', { preHandler: requireAuth }, async (request, reply) => {
const body = request.body as Record<string, unknown>;
const authData = request.authData!;
const trip = await prisma.trips.create({
data: {
vehicle_id: Number(body.vehicle_id),
user_id: body.user_id ? Number(body.user_id) : authData.userId,
trip_date: new Date(String(body.trip_date)),
start_km: Number(body.start_km),
end_km: Number(body.end_km),
route_from: String(body.route_from),
route_to: String(body.route_to),
is_business: body.is_business === true || body.is_business === 1 || body.is_business === '1',
notes: body.notes ? String(body.notes) : null,
},
});
// Update vehicle actual_km
await prisma.vehicles.update({
where: { id: Number(body.vehicle_id) },
data: { actual_km: Number(body.end_km) },
});
await logAudit({ request, authData, action: 'create', entityType: 'trip', entityId: trip.id, description: `Vytvořena jízda` });
return success(reply, { id: trip.id }, 201, 'Jízda byla zaznamenána');
});
fastify.put<{ Params: { id: string } }>('/:id', { preHandler: requireAuth }, async (request, reply) => {
const id = parseInt(request.params.id, 10);
if (isNaN(id)) return error(reply, 'Neplatné ID', 400);
const body = request.body as Record<string, unknown>;
const authData = request.authData!;
const existing = await prisma.trips.findUnique({ where: { id } });
if (!existing) return error(reply, 'Jízda nenalezena', 404);
// Ownership check — same as DELETE handler
const isAdmin = authData.permissions.includes('trips.admin');
if (existing.user_id !== authData.userId && !isAdmin) {
return error(reply, 'Nemáte oprávnění upravit tuto jízdu', 403);
}
const data: Record<string, unknown> = {};
if (body.trip_date !== undefined) data.trip_date = new Date(String(body.trip_date));
if (body.start_km !== undefined) data.start_km = Number(body.start_km);
if (body.end_km !== undefined) data.end_km = Number(body.end_km);
if (body.route_from !== undefined) data.route_from = String(body.route_from);
if (body.route_to !== undefined) data.route_to = String(body.route_to);
if (body.is_business !== undefined) data.is_business = body.is_business === true || body.is_business === 1 || body.is_business === '1';
if (body.notes !== undefined) data.notes = body.notes ? String(body.notes) : null;
await prisma.trips.update({ where: { id }, data });
// Update vehicle actual_km if end_km changed
if (body.end_km !== undefined) {
const vehicleId = existing.vehicle_id;
const maxTrip = await prisma.trips.findFirst({
where: { vehicle_id: vehicleId },
orderBy: { end_km: 'desc' },
select: { end_km: true },
});
if (maxTrip) {
await prisma.vehicles.update({ where: { id: vehicleId }, data: { actual_km: Number(maxTrip.end_km) } });
}
}
await logAudit({ request, authData, action: 'update', entityType: 'trip', entityId: id, description: `Upravena jízda` });
return success(reply, { id }, 200, 'Záznam byl aktualizován');
});
fastify.delete<{ Params: { id: string } }>('/:id', { preHandler: requireAuth }, async (request, reply) => {
const id = parseInt(request.params.id, 10);
if (isNaN(id)) return error(reply, 'Neplatné ID', 400);
const authData = request.authData!;
const existing = await prisma.trips.findUnique({ where: { id } });
if (!existing) return error(reply, 'Jízda nenalezena', 404);
// Allow users to delete their own trips, admins can delete any
const isAdmin = authData.permissions.includes('trips.admin');
if (existing.user_id !== authData.userId && !isAdmin) {
return error(reply, 'Nemáte oprávnění smazat tuto jízdu', 403);
}
const vehicleId = existing.vehicle_id;
await prisma.trips.delete({ where: { id } });
// Recalculate vehicle actual_km after deletion
const maxTrip = await prisma.trips.findFirst({
where: { vehicle_id: vehicleId },
orderBy: { end_km: 'desc' },
select: { end_km: true },
});
const vehicle = await prisma.vehicles.findUnique({ where: { id: vehicleId }, select: { initial_km: true } });
await prisma.vehicles.update({
where: { id: vehicleId },
data: { actual_km: maxTrip ? Number(maxTrip.end_km) : (vehicle?.initial_km ?? 0) },
});
await logAudit({ request, authData, action: 'delete', entityType: 'trip', entityId: id, description: `Smazána jízda` });
return success(reply, { id }, 200, 'Záznam byl smazán');
});
}

226
src/routes/admin/users.ts Normal file
View File

@@ -0,0 +1,226 @@
import { FastifyInstance } from 'fastify';
import prisma from '../../config/database';
import { requirePermission } from '../../middleware/auth';
import { logAudit } from '../../services/audit';
import { success, error, parseId } from '../../utils/response';
import { parsePagination, buildPaginationMeta } from '../../utils/pagination';
import bcrypt from 'bcryptjs';
import { config } from '../../config/env';
const ALLOWED_SORT_FIELDS = ['id', 'username', 'email', 'first_name', 'last_name', 'created_at'];
export default async function usersRoutes(fastify: FastifyInstance): Promise<void> {
// GET /api/admin/users
fastify.get('/', { preHandler: requirePermission('users.view') }, async (request, reply) => {
const { page, limit, skip, sort, order, search } = parsePagination(request.query as Record<string, unknown>);
const sortField = ALLOWED_SORT_FIELDS.includes(sort) ? sort : 'id';
const where = search
? {
OR: [
{ username: { contains: search } },
{ email: { contains: search } },
{ first_name: { contains: search } },
{ last_name: { contains: search } },
],
}
: {};
const [users, total] = await Promise.all([
prisma.users.findMany({
where,
skip,
take: limit,
orderBy: { [sortField]: order },
select: {
id: true, username: true, email: true, first_name: true, last_name: true,
role_id: true, is_active: true, last_login: true, totp_enabled: true,
created_at: true, updated_at: true,
roles: { select: { id: true, name: true, display_name: true } },
},
}),
prisma.users.count({ where }),
]);
return reply.send({
success: true,
data: users,
pagination: buildPaginationMeta(total, page, limit),
});
});
// GET /api/admin/users/:id
fastify.get<{ Params: { id: string } }>('/:id', { preHandler: requirePermission('users.view') }, async (request, reply) => {
const id = parseId(request.params.id, reply);
if (id === null) return;
const user = await prisma.users.findUnique({
where: { id },
select: {
id: true, username: true, email: true, first_name: true, last_name: true,
role_id: true, is_active: true, last_login: true, totp_enabled: true,
created_at: true, updated_at: true,
roles: { select: { id: true, name: true, display_name: true } },
},
});
if (!user) return error(reply, 'Uživatel nenalezen', 404);
return success(reply, user);
});
// POST /api/admin/users
fastify.post('/', { preHandler: requirePermission('users.create') }, async (request, reply) => {
const body = request.body as Record<string, unknown>;
const username = body.username ? String(body.username).trim() : '';
const email = body.email ? String(body.email).trim() : '';
const password = body.password ? String(body.password) : '';
const firstName = body.first_name ? String(body.first_name).trim() : '';
const lastName = body.last_name ? String(body.last_name).trim() : '';
const roleId = body.role_id;
// Required fields
if (!username || !email || !password || !firstName || !lastName || !roleId) {
return error(reply, 'Všechna pole jsou povinná', 400);
}
// Password length
if (password.length < 8) {
return error(reply, 'Heslo musí mít alespoň 8 znaků', 400);
}
// Email format
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
return error(reply, 'Neplatný formát e-mailu', 400);
}
// Username uniqueness
const existingUsername = await prisma.users.findFirst({ where: { username } });
if (existingUsername) {
return error(reply, 'Uživatelské jméno již existuje', 409);
}
// Email uniqueness
const existingEmail = await prisma.users.findFirst({ where: { email } });
if (existingEmail) {
return error(reply, 'E-mail již existuje', 409);
}
const passwordHash = await bcrypt.hash(password, config.security.bcryptCost);
const user = await prisma.users.create({
data: {
username,
email,
password_hash: passwordHash,
first_name: firstName,
last_name: lastName,
role_id: roleId ? Number(roleId) : null,
is_active: body.is_active !== false,
},
});
await logAudit({
request,
authData: request.authData,
action: 'create',
entityType: 'user',
entityId: user.id,
description: `Vytvořen uživatel ${user.username}`,
});
return success(reply, { id: user.id }, 201, 'Uživatel byl vytvořen');
});
// PUT /api/admin/users/:id
fastify.put<{ Params: { id: string } }>('/:id', { preHandler: requirePermission('users.edit') }, async (request, reply) => {
const id = parseId(request.params.id, reply);
if (id === null) return;
const body = request.body as Record<string, unknown>;
const existing = await prisma.users.findUnique({ where: { id } });
if (!existing) return error(reply, 'Uživatel nenalezen', 404);
const data: Record<string, unknown> = {};
// Username validation and uniqueness
if (body.username !== undefined) {
const newUsername = String(body.username).trim();
if (newUsername !== existing.username) {
const existingUsername = await prisma.users.findFirst({ where: { username: newUsername } });
if (existingUsername) {
return error(reply, 'Uživatelské jméno již existuje', 409);
}
}
data.username = newUsername;
}
// Email validation and uniqueness
if (body.email !== undefined) {
const newEmail = String(body.email).trim();
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(newEmail)) {
return error(reply, 'Neplatný formát e-mailu', 400);
}
const existingEmail = await prisma.users.findFirst({
where: { email: newEmail, id: { not: id } },
});
if (existingEmail) {
return error(reply, 'E-mail již existuje', 409);
}
data.email = newEmail;
}
if (body.first_name !== undefined) data.first_name = String(body.first_name);
if (body.last_name !== undefined) data.last_name = String(body.last_name);
if (body.role_id !== undefined) data.role_id = body.role_id ? Number(body.role_id) : null;
if (body.is_active !== undefined) data.is_active = body.is_active === true || body.is_active === 1 || body.is_active === '1';
if (body.password) {
const newPassword = String(body.password);
if (newPassword.length < 8) {
return error(reply, 'Heslo musí mít alespoň 8 znaků', 400);
}
data.password_hash = await bcrypt.hash(newPassword, config.security.bcryptCost);
data.password_changed_at = new Date();
}
await prisma.users.update({ where: { id }, data });
await logAudit({
request,
authData: request.authData,
action: 'update',
entityType: 'user',
entityId: id,
description: `Upraven uživatel ${existing.username}`,
});
return success(reply, { id }, 200, 'Uživatel byl uložen');
});
// DELETE /api/admin/users/:id
fastify.delete<{ Params: { id: string } }>('/:id', { preHandler: requirePermission('users.delete') }, async (request, reply) => {
const id = parseId(request.params.id, reply);
if (id === null) return;
const authData = request.authData;
if (id === authData?.userId) {
return error(reply, 'Nelze smazat vlastní účet', 400);
}
const existing = await prisma.users.findUnique({ where: { id } });
if (!existing) return error(reply, 'Uživatel nenalezen', 404);
await prisma.refresh_tokens.deleteMany({ where: { user_id: id } });
await prisma.users.delete({ where: { id } });
await logAudit({
request,
authData: request.authData,
action: 'delete',
entityType: 'user',
entityId: id,
description: `Smazán uživatel ${existing.username}`,
});
return success(reply, null, 200, 'Uživatel smazán');
});
}

View File

@@ -0,0 +1,83 @@
import { FastifyInstance } from 'fastify';
import prisma from '../../config/database';
import { requirePermission } from '../../middleware/auth';
import { logAudit } from '../../services/audit';
import { success, error, parseId } from '../../utils/response';
export default async function vehiclesRoutes(fastify: FastifyInstance): Promise<void> {
fastify.get('/', { preHandler: requirePermission('trips.vehicles') }, async (_request, reply) => {
const vehicles = await prisma.vehicles.findMany({ orderBy: { name: 'asc' } });
// Compute current_km and trip_count from trips table
const tripStats = await prisma.trips.groupBy({
by: ['vehicle_id'],
_max: { end_km: true },
_count: { id: true },
});
const statsMap = new Map(tripStats.map(s => [s.vehicle_id, { maxKm: s._max.end_km ?? 0, count: s._count.id }]));
const enriched = vehicles.map(v => {
const stats = statsMap.get(v.id);
return {
...v,
current_km: stats ? Math.max(v.initial_km, stats.maxKm) : v.initial_km,
trip_count: stats?.count ?? 0,
};
});
return success(reply, enriched);
});
fastify.post('/', { preHandler: requirePermission('trips.vehicles') }, async (request, reply) => {
const body = request.body as Record<string, unknown>;
if (!body.spz || !body.name) return error(reply, 'SPZ a název jsou povinné', 400);
const vehicle = await prisma.vehicles.create({
data: {
spz: String(body.spz),
name: String(body.name),
brand: body.brand ? String(body.brand) : null,
model: body.model ? String(body.model) : null,
initial_km: body.initial_km ? Number(body.initial_km) : 0,
actual_km: body.actual_km ? Number(body.actual_km) : 0,
is_active: body.is_active !== false,
},
});
await logAudit({ request, authData: request.authData, action: 'create', entityType: 'vehicle', entityId: vehicle.id, description: `Vytvořeno vozidlo ${vehicle.name}` });
return success(reply, { id: vehicle.id }, 201, 'Vozidlo bylo vytvořeno');
});
fastify.put<{ Params: { id: string } }>('/:id', { preHandler: requirePermission('trips.vehicles') }, async (request, reply) => {
const id = parseId(request.params.id, reply);
if (id === null) return;
const body = request.body as Record<string, unknown>;
const existing = await prisma.vehicles.findUnique({ where: { id } });
if (!existing) return error(reply, 'Vozidlo nenalezeno', 404);
await prisma.vehicles.update({
where: { id },
data: {
spz: body.spz !== undefined ? String(body.spz) : undefined,
name: body.name !== undefined ? String(body.name) : undefined,
brand: body.brand !== undefined ? (body.brand ? String(body.brand) : null) : undefined,
model: body.model !== undefined ? (body.model ? String(body.model) : null) : undefined,
initial_km: body.initial_km !== undefined ? Number(body.initial_km) : undefined,
actual_km: body.actual_km !== undefined ? Number(body.actual_km) : undefined,
is_active: body.is_active !== undefined ? (body.is_active === true || body.is_active === 1 || body.is_active === '1') : undefined,
},
});
await logAudit({ request, authData: request.authData, action: 'update', entityType: 'vehicle', entityId: id, description: `Upraveno vozidlo ${existing.name}` });
return success(reply, { id }, 200, 'Vozidlo bylo uloženo');
});
fastify.delete<{ Params: { id: string } }>('/:id', { preHandler: requirePermission('trips.vehicles') }, async (request, reply) => {
const id = parseId(request.params.id, reply);
if (id === null) return;
const existing = await prisma.vehicles.findUnique({ where: { id } });
if (!existing) return error(reply, 'Vozidlo nenalezeno', 404);
await prisma.vehicles.delete({ where: { id } });
await logAudit({ request, authData: request.authData, action: 'delete', entityType: 'vehicle', entityId: id, description: `Smazáno vozidlo ${existing.name}` });
return success(reply, null, 200, 'Vozidlo smazáno');
});
}