feat: NAS storage for invoices/offers, code cleanup, date/time fixes
- NAS storage for created invoices (PDF via puppeteer), received invoices, and offers with auto-save on create/edit - Deterministic file paths derived from DB fields (no file_path column needed) - Separate NAS mount points: NAS_FINANCIALS_PATH, NAS_OFFERS_PATH - Invoice language field (cs/en) stored per invoice, replaces lang modal - Invoices list filtered by month/year matching KPI card selection - Centralized date helpers (src/utils/date.ts) replacing all .toISOString() calls that returned UTC instead of local time - Attendance project switching uses exact time (not rounded) - Comment cleanup: removed ~100 unnecessary/Czech comments - Removed as-any casts in orders and attendance - Prisma migrations: add invoice language, drop received_invoices BLOB columns Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -16,6 +16,7 @@ import {
|
||||
UpdateAttendanceSchema,
|
||||
} from "../../schemas/attendance.schema";
|
||||
import * as attendanceService from "../../services/attendance.service";
|
||||
import { localMonthStr } from "../../utils/date";
|
||||
|
||||
export default async function attendanceRoutes(
|
||||
fastify: FastifyInstance,
|
||||
@@ -125,7 +126,7 @@ export default async function attendanceRoutes(
|
||||
|
||||
const monthStr = query.month
|
||||
? String(query.month)
|
||||
: `${new Date().getFullYear()}-${String(new Date().getMonth() + 1).padStart(2, "0")}`;
|
||||
: localMonthStr(new Date());
|
||||
const filterUserId = query.user_id ? Number(query.user_id) : null;
|
||||
const data = await attendanceService.getPrintData(monthStr, filterUserId);
|
||||
return reply.send({ success: true, data });
|
||||
|
||||
@@ -128,7 +128,6 @@ export default async function authRoutes(
|
||||
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)
|
||||
@@ -149,7 +148,6 @@ export default async function authRoutes(
|
||||
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(
|
||||
{
|
||||
|
||||
@@ -40,8 +40,7 @@ export default async function bankAccountsRoutes(
|
||||
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,
|
||||
is_default: !!body.is_default,
|
||||
position: body.position ? Number(body.position) : 0,
|
||||
},
|
||||
});
|
||||
@@ -107,9 +106,7 @@ export default async function bankAccountsRoutes(
|
||||
currency:
|
||||
body.currency !== undefined ? String(body.currency) : undefined,
|
||||
is_default:
|
||||
body.is_default !== undefined
|
||||
? !!body.is_default
|
||||
: undefined,
|
||||
body.is_default !== undefined ? !!body.is_default : undefined,
|
||||
position:
|
||||
body.position !== undefined ? Number(body.position) : undefined,
|
||||
modified_at: new Date(),
|
||||
|
||||
@@ -2,6 +2,7 @@ import { FastifyInstance } from "fastify";
|
||||
import prisma from "../../config/database";
|
||||
import { requireAuth } from "../../middleware/auth";
|
||||
import { success } from "../../utils/response";
|
||||
import { localTimeStr } from "../../utils/date";
|
||||
|
||||
export default async function dashboardRoutes(
|
||||
fastify: FastifyInstance,
|
||||
@@ -106,9 +107,7 @@ export default async function dashboardRoutes(
|
||||
name: `${user.first_name} ${user.last_name}`,
|
||||
initials: `${firstInitial}${lastInitial}`.toUpperCase(),
|
||||
status,
|
||||
arrived_at: a.arrival_time
|
||||
? `${String(a.arrival_time.getHours()).padStart(2, "0")}:${String(a.arrival_time.getMinutes()).padStart(2, "0")}`
|
||||
: null,
|
||||
arrived_at: a.arrival_time ? localTimeStr(a.arrival_time) : null,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -252,7 +251,7 @@ export default async function dashboardRoutes(
|
||||
entity_type: log.entity_type ?? "",
|
||||
description: log.description ?? "",
|
||||
username: log.username ?? null,
|
||||
created_at: log.created_at ? log.created_at.toISOString() : "",
|
||||
created_at: log.created_at ?? "",
|
||||
}));
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,9 @@ import { FastifyInstance } from "fastify";
|
||||
import QRCode from "qrcode";
|
||||
import prisma from "../../config/database";
|
||||
import { requirePermission } from "../../middleware/auth";
|
||||
import { localDateCzStr } from "../../utils/date";
|
||||
import { nasFinancialsManager } from "../../services/nas-financials-manager";
|
||||
import { htmlToPdf } from "../../utils/html-to-pdf";
|
||||
|
||||
/* ── Helpers ─────────────────────────────────────────────────────── */
|
||||
|
||||
@@ -9,7 +12,7 @@ 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()}`;
|
||||
return localDateCzStr(d);
|
||||
}
|
||||
|
||||
function formatNum(n: number, decimals = 2): string {
|
||||
@@ -278,7 +281,6 @@ export default async function invoicesPdfRoutes(
|
||||
unknown
|
||||
> | null;
|
||||
|
||||
// Order number lookup
|
||||
let orderNumber = "";
|
||||
if (invoice.order_id) {
|
||||
const orderRow = await prisma.orders.findUnique({
|
||||
@@ -298,7 +300,6 @@ export default async function invoicesPdfRoutes(
|
||||
}
|
||||
}
|
||||
|
||||
// Logo
|
||||
let logoImg = "";
|
||||
if (settings?.logo_data) {
|
||||
const buf = Buffer.from(settings.logo_data as Buffer);
|
||||
@@ -313,7 +314,6 @@ export default async function invoicesPdfRoutes(
|
||||
const currency = invoice.currency || "CZK";
|
||||
const applyVat = !!invoice.apply_vat;
|
||||
|
||||
// Calculations
|
||||
const vatSummary: Record<string, { base: number; vat: number }> = {};
|
||||
let subtotal = 0;
|
||||
|
||||
@@ -380,7 +380,6 @@ export default async function invoicesPdfRoutes(
|
||||
});
|
||||
}
|
||||
|
||||
// Address lines
|
||||
const supp = buildAddressLines(settings, true, t);
|
||||
const cust = buildAddressLines(customer, false, t);
|
||||
|
||||
@@ -410,7 +409,6 @@ export default async function invoicesPdfRoutes(
|
||||
|
||||
const invoiceNumber = escapeHtml(invoice.invoice_number);
|
||||
|
||||
// Items HTML
|
||||
const itemsHtml = items
|
||||
.map((item, i) => {
|
||||
const qty = Number(item.quantity);
|
||||
@@ -434,7 +432,6 @@ export default async function invoicesPdfRoutes(
|
||||
})
|
||||
.join("");
|
||||
|
||||
// VAT recap rows
|
||||
const vatRecapHtml = vatRecap
|
||||
.map(
|
||||
(vr) => `<tr>
|
||||
@@ -446,7 +443,6 @@ export default async function invoicesPdfRoutes(
|
||||
)
|
||||
.join("");
|
||||
|
||||
// VAT detail rows for totals section
|
||||
let vatDetailHtml = "";
|
||||
if (applyVat) {
|
||||
for (const [rate, data] of Object.entries(vatSummary)) {
|
||||
@@ -460,7 +456,6 @@ export default async function invoicesPdfRoutes(
|
||||
}
|
||||
}
|
||||
|
||||
// Notes section
|
||||
const notesRaw = invoice.notes ?? "";
|
||||
const notesStripped = notesRaw.replace(/<[^>]*>/g, "").trim();
|
||||
const notesHtml = notesStripped
|
||||
@@ -1027,6 +1022,31 @@ ${indentCSS}
|
||||
</body>
|
||||
</html>`;
|
||||
|
||||
// Save PDF to NAS
|
||||
if (nasFinancialsManager.isConfigured() && invoice.invoice_number) {
|
||||
const issueDate = invoice.issue_date
|
||||
? new Date(invoice.issue_date)
|
||||
: new Date();
|
||||
const saveMode = query.save === "1";
|
||||
const pdfPromise = htmlToPdf(html)
|
||||
.then((pdfBuffer) => {
|
||||
nasFinancialsManager.saveIssuedInvoicePdf(
|
||||
invoice.invoice_number!,
|
||||
issueDate.getFullYear(),
|
||||
issueDate.getMonth() + 1,
|
||||
pdfBuffer,
|
||||
);
|
||||
})
|
||||
.catch((err) => {
|
||||
request.log.error(err, "Failed to save invoice PDF to NAS");
|
||||
});
|
||||
|
||||
if (saveMode) {
|
||||
await pdfPromise;
|
||||
return reply.send({ success: true, message: "PDF uloženo" });
|
||||
}
|
||||
}
|
||||
|
||||
return reply.type("text/html").send(html);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
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";
|
||||
@@ -19,6 +20,7 @@ import {
|
||||
updateInvoice,
|
||||
deleteInvoice,
|
||||
} from "../../services/invoices.service";
|
||||
import { nasFinancialsManager } from "../../services/nas-financials-manager";
|
||||
|
||||
export default async function invoicesRoutes(
|
||||
fastify: FastifyInstance,
|
||||
@@ -46,6 +48,8 @@ export default async function invoicesRoutes(
|
||||
search,
|
||||
status: query.status ? String(query.status) : undefined,
|
||||
customer_id: query.customer_id ? Number(query.customer_id) : undefined,
|
||||
month: query.month ? Number(query.month) : undefined,
|
||||
year: query.year ? Number(query.year) : undefined,
|
||||
});
|
||||
|
||||
return reply.send({
|
||||
@@ -185,6 +189,13 @@ export default async function invoicesRoutes(
|
||||
const existing = await deleteInvoice(id);
|
||||
if (!existing) return error(reply, "Faktura nenalezena", 404);
|
||||
|
||||
// Delete PDF from NAS
|
||||
if (existing.invoice_number && existing.issue_date) {
|
||||
const d = new Date(existing.issue_date);
|
||||
const relPath = `Vydané/${d.getFullYear()}/${String(d.getMonth() + 1).padStart(2, "0")}/${existing.invoice_number}.pdf`;
|
||||
nasFinancialsManager.deleteIssuedInvoice(relPath);
|
||||
}
|
||||
|
||||
await logAudit({
|
||||
request,
|
||||
authData: request.authData,
|
||||
@@ -196,4 +207,33 @@ export default async function invoicesRoutes(
|
||||
return success(reply, null, 200, "Faktura smazána");
|
||||
},
|
||||
);
|
||||
|
||||
// GET /api/admin/invoices/:id/file — serve PDF from NAS
|
||||
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.invoices.findUnique({
|
||||
where: { id },
|
||||
select: { invoice_number: true, issue_date: true },
|
||||
});
|
||||
if (!invoice?.invoice_number || !invoice.issue_date)
|
||||
return error(reply, "Faktura nenalezena", 404);
|
||||
|
||||
const d = new Date(invoice.issue_date);
|
||||
const relPath = `Vydané/${d.getFullYear()}/${String(d.getMonth() + 1).padStart(2, "0")}/${invoice.invoice_number}.pdf`;
|
||||
const file = nasFinancialsManager.readIssuedInvoice(relPath);
|
||||
if (!file) return error(reply, "PDF soubor nenalezen", 404);
|
||||
|
||||
return reply
|
||||
.type("application/pdf")
|
||||
.header(
|
||||
"Content-Disposition",
|
||||
`inline; filename="${invoice.invoice_number}.pdf"`,
|
||||
)
|
||||
.send(file.data);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -205,7 +205,6 @@ export default async function leaveRequestsRoutes(
|
||||
}
|
||||
}
|
||||
|
||||
// Count business days and create attendance records
|
||||
let totalBusinessDays = 0;
|
||||
const current = new Date(dateFrom);
|
||||
const attendanceCreates: Array<{
|
||||
@@ -242,7 +241,6 @@ export default async function leaveRequestsRoutes(
|
||||
|
||||
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) {
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
import { FastifyInstance } from "fastify";
|
||||
import prisma from "../../config/database";
|
||||
import { requirePermission } from "../../middleware/auth";
|
||||
import { localDateCzStr } from "../../utils/date";
|
||||
import { nasOffersManager } from "../../services/nas-offers-manager";
|
||||
import { htmlToPdf } from "../../utils/html-to-pdf";
|
||||
|
||||
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()}`;
|
||||
return localDateCzStr(d);
|
||||
}
|
||||
|
||||
/** Format number with comma decimal separator and non-breaking space thousands separator */
|
||||
@@ -53,7 +56,6 @@ 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(
|
||||
@@ -95,7 +97,6 @@ function buildAddressLines(
|
||||
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;
|
||||
@@ -201,6 +202,7 @@ export default async function offersPdfRoutes(
|
||||
{ preHandler: requirePermission("offers.view") },
|
||||
async (request, reply) => {
|
||||
const id = parseInt(request.params.id, 10);
|
||||
const query = request.query as Record<string, string>;
|
||||
|
||||
try {
|
||||
const quotation = await prisma.quotations.findUnique({
|
||||
@@ -225,7 +227,6 @@ export default async function offersPdfRoutes(
|
||||
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);
|
||||
@@ -236,7 +237,6 @@ export default async function offersPdfRoutes(
|
||||
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) {
|
||||
@@ -251,7 +251,6 @@ export default async function offersPdfRoutes(
|
||||
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()) {
|
||||
@@ -260,7 +259,6 @@ export default async function offersPdfRoutes(
|
||||
}
|
||||
}
|
||||
|
||||
// Addresses
|
||||
const cust = buildAddressLines(
|
||||
quotation.customers as unknown as Record<string, unknown>,
|
||||
false,
|
||||
@@ -288,7 +286,6 @@ export default async function offersPdfRoutes(
|
||||
indentCSS += ` li.ql-indent-${n} { padding-left: ${liPad}em; }\n`;
|
||||
}
|
||||
|
||||
// Items HTML
|
||||
let itemsHtml = "";
|
||||
items.forEach((item, i) => {
|
||||
const lineTotal =
|
||||
@@ -304,7 +301,6 @@ export default async function offersPdfRoutes(
|
||||
</tr>`;
|
||||
});
|
||||
|
||||
// Totals HTML
|
||||
let totalsHtml = "";
|
||||
if (applyVat) {
|
||||
totalsHtml += `<div class="detail-rows">
|
||||
@@ -328,7 +324,6 @@ export default async function offersPdfRoutes(
|
||||
|
||||
const quotationNumber = escapeHtml(quotation.quotation_number);
|
||||
|
||||
// Scope HTML
|
||||
let scopeHtml = "";
|
||||
if (hasScopeContent) {
|
||||
scopeHtml += '<div class="scope-page">';
|
||||
@@ -768,6 +763,30 @@ ${indentCSS}
|
||||
</body>
|
||||
</html>`;
|
||||
|
||||
// Save PDF to NAS
|
||||
if (nasOffersManager.isConfigured() && quotation.quotation_number) {
|
||||
const created = quotation.created_at
|
||||
? new Date(quotation.created_at)
|
||||
: new Date();
|
||||
const saveMode = query.save === "1";
|
||||
const pdfPromise = htmlToPdf(html)
|
||||
.then((pdfBuffer) => {
|
||||
nasOffersManager.saveOfferPdf(
|
||||
quotation.quotation_number!,
|
||||
created.getFullYear(),
|
||||
pdfBuffer,
|
||||
);
|
||||
})
|
||||
.catch((err) => {
|
||||
request.log.error(err, "Failed to save offer PDF to NAS");
|
||||
});
|
||||
|
||||
if (saveMode) {
|
||||
await pdfPromise;
|
||||
return reply.send({ success: true, message: "PDF uloženo" });
|
||||
}
|
||||
}
|
||||
|
||||
return reply.type("text/html").send(html);
|
||||
} catch (err) {
|
||||
request.log.error(err, "PDF generation failed");
|
||||
|
||||
@@ -208,7 +208,7 @@ export default async function ordersRoutes(
|
||||
if ("error" in manualParsed) return error(reply, manualParsed.error, 400);
|
||||
const body = manualParsed.data;
|
||||
|
||||
const result = await createOrder(body as any);
|
||||
const result = await createOrder(body);
|
||||
|
||||
await logAudit({
|
||||
request,
|
||||
@@ -236,7 +236,7 @@ export default async function ordersRoutes(
|
||||
const parsed = parseBody(UpdateOrderSchema, request.body);
|
||||
if ("error" in parsed) return error(reply, parsed.error, 400);
|
||||
|
||||
const result = await updateOrder(id, parsed.data as any);
|
||||
const result = await updateOrder(id, parsed.data);
|
||||
if ("error" in result) return error(reply, result.error!, result.status!);
|
||||
|
||||
await logAudit({
|
||||
|
||||
@@ -60,7 +60,6 @@ export default async function projectFilesRoutes(
|
||||
.send(stream);
|
||||
}
|
||||
|
||||
// List files
|
||||
if (!project.project_number)
|
||||
return error(reply, "Projekt nemá číslo projektu");
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
invalidateOffer,
|
||||
getNextOfferNumber,
|
||||
} from "../../services/offers.service";
|
||||
import { nasOffersManager } from "../../services/nas-offers-manager";
|
||||
|
||||
const LOCK_TIMEOUT_MS = 10 * 1000; // 10 seconds — lock expires if no heartbeat
|
||||
|
||||
@@ -122,7 +123,6 @@ export default async function quotationsRoutes(
|
||||
const data = await getOffer(id);
|
||||
if (!data) return error(reply, "Nabídka nenalezena", 404);
|
||||
|
||||
// Include lock info
|
||||
const quotation = await prisma.quotations.findUnique({
|
||||
where: { id },
|
||||
select: { locked_by: true, locked_at: true },
|
||||
@@ -305,6 +305,14 @@ export default async function quotationsRoutes(
|
||||
const existing = await deleteOffer(id);
|
||||
if (!existing) return error(reply, "Nabídka nenalezena", 404);
|
||||
|
||||
// Delete PDF from NAS
|
||||
if (existing.quotation_number && existing.created_at) {
|
||||
const yr = new Date(existing.created_at).getFullYear();
|
||||
nasOffersManager.deleteOfferPdf(
|
||||
nasOffersManager.buildRelativePath(existing.quotation_number, yr),
|
||||
);
|
||||
}
|
||||
|
||||
await logAudit({
|
||||
request,
|
||||
authData: request.authData,
|
||||
@@ -316,4 +324,38 @@ export default async function quotationsRoutes(
|
||||
return success(reply, null, 200, "Nabídka smazána");
|
||||
},
|
||||
);
|
||||
|
||||
// GET /api/admin/offers/:id/file — serve PDF from NAS
|
||||
fastify.get<{ Params: { id: string } }>(
|
||||
"/:id/file",
|
||||
{ preHandler: requirePermission("offers.view") },
|
||||
async (request, reply) => {
|
||||
const id = parseId(request.params.id, reply);
|
||||
if (id === null) return;
|
||||
const offer = await prisma.quotations.findUnique({
|
||||
where: { id },
|
||||
select: { quotation_number: true, created_at: true },
|
||||
});
|
||||
if (!offer?.quotation_number)
|
||||
return error(reply, "Nabídka nenalezena", 404);
|
||||
|
||||
const year = offer.created_at
|
||||
? new Date(offer.created_at).getFullYear()
|
||||
: new Date().getFullYear();
|
||||
const relPath = nasOffersManager.buildRelativePath(
|
||||
offer.quotation_number,
|
||||
year,
|
||||
);
|
||||
const file = nasOffersManager.readOfferPdf(relPath);
|
||||
if (!file) return error(reply, "PDF soubor nenalezen", 404);
|
||||
|
||||
return reply
|
||||
.type("application/pdf")
|
||||
.header(
|
||||
"Content-Disposition",
|
||||
`inline; filename="${offer.quotation_number}.pdf"`,
|
||||
)
|
||||
.send(file.data);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
CreateReceivedInvoiceSchema,
|
||||
UpdateReceivedInvoiceSchema,
|
||||
} from "../../schemas/received-invoices.schema";
|
||||
import { nasFinancialsManager } from "../../services/nas-financials-manager";
|
||||
|
||||
const VALID_STATUSES = ["unpaid", "paid"] as const;
|
||||
const ALLOWED_SORT_FIELDS = [
|
||||
@@ -160,16 +161,31 @@ export default async function receivedInvoicesRoutes(
|
||||
if (id === null) return;
|
||||
const invoice = await prisma.received_invoices.findUnique({
|
||||
where: { id },
|
||||
select: { file_data: true, file_name: true, file_mime: true },
|
||||
select: {
|
||||
file_name: true,
|
||||
file_mime: true,
|
||||
year: true,
|
||||
month: true,
|
||||
},
|
||||
});
|
||||
if (!invoice?.file_data) return error(reply, "Soubor nenalezen", 404);
|
||||
if (!invoice?.file_name) return error(reply, "Soubor nenalezen", 404);
|
||||
|
||||
const relPath = nasFinancialsManager.buildReceivedPath(
|
||||
invoice.file_name,
|
||||
invoice.year,
|
||||
invoice.month,
|
||||
);
|
||||
const nasFile = nasFinancialsManager.readReceivedInvoice(relPath);
|
||||
if (!nasFile) return error(reply, "Soubor na NAS 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));
|
||||
.header(
|
||||
"Content-Disposition",
|
||||
`inline; filename="${invoice.file_name}"`,
|
||||
)
|
||||
.send(nasFile.data);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -183,9 +199,10 @@ export default async function receivedInvoicesRoutes(
|
||||
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);
|
||||
return success(reply, {
|
||||
...invoice,
|
||||
has_file: !!invoice.file_name,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
@@ -230,6 +247,8 @@ export default async function receivedInvoicesRoutes(
|
||||
const now = new Date();
|
||||
const createdIds: number[] = [];
|
||||
|
||||
const useNas = nasFinancialsManager.isConfigured();
|
||||
|
||||
for (let i = 0; i < files.length; i++) {
|
||||
const file = files[i];
|
||||
const meta = invoicesMeta[i] || {};
|
||||
@@ -241,10 +260,34 @@ export default async function receivedInvoicesRoutes(
|
||||
? Math.round((amount - amount / (1 + vatRate / 100)) * 100) / 100
|
||||
: 0;
|
||||
|
||||
const issueDate = meta.issue_date
|
||||
? new Date(String(meta.issue_date))
|
||||
: null;
|
||||
const invoiceMonth = issueDate
|
||||
? issueDate.getMonth() + 1
|
||||
: Number(meta.month) || now.getMonth() + 1;
|
||||
const invoiceYear = issueDate
|
||||
? issueDate.getFullYear()
|
||||
: Number(meta.year) || now.getFullYear();
|
||||
|
||||
if (!useNas) {
|
||||
return error(reply, "NAS úložiště není nakonfigurováno", 503);
|
||||
}
|
||||
|
||||
const nasResult = nasFinancialsManager.saveReceivedInvoice(
|
||||
file.name,
|
||||
invoiceYear,
|
||||
invoiceMonth,
|
||||
file.data,
|
||||
);
|
||||
if ("error" in nasResult) {
|
||||
return error(reply, nasResult.error, 503);
|
||||
}
|
||||
|
||||
const invoice = await prisma.received_invoices.create({
|
||||
data: {
|
||||
month: Number(meta.month) || now.getMonth() + 1,
|
||||
year: Number(meta.year) || now.getFullYear(),
|
||||
month: invoiceMonth,
|
||||
year: invoiceYear,
|
||||
supplier_name: meta.supplier_name
|
||||
? String(meta.supplier_name)
|
||||
: file.name,
|
||||
@@ -263,7 +306,6 @@ export default async function receivedInvoicesRoutes(
|
||||
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,
|
||||
@@ -488,6 +530,15 @@ export default async function receivedInvoicesRoutes(
|
||||
});
|
||||
if (!existing) return error(reply, "Přijatá faktura nenalezena", 404);
|
||||
|
||||
if (existing.file_name) {
|
||||
const relPath = nasFinancialsManager.buildReceivedPath(
|
||||
existing.file_name,
|
||||
existing.year,
|
||||
existing.month,
|
||||
);
|
||||
nasFinancialsManager.deleteReceivedInvoice(relPath);
|
||||
}
|
||||
|
||||
await prisma.received_invoices.delete({ where: { id } });
|
||||
await logAudit({
|
||||
request,
|
||||
|
||||
@@ -27,7 +27,6 @@ export default async function scopeTemplatesRoutes(
|
||||
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 },
|
||||
@@ -70,7 +69,6 @@ export default async function scopeTemplatesRoutes(
|
||||
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) },
|
||||
@@ -92,7 +90,6 @@ export default async function scopeTemplatesRoutes(
|
||||
return success(reply, { id: item.id }, 201, "Položka byla vytvořena");
|
||||
}
|
||||
|
||||
// Scope template create (original logic below)
|
||||
const scopeParsed = parseBody(CreateScopeTemplateSchema, request.body);
|
||||
if ("error" in scopeParsed) return error(reply, scopeParsed.error, 400);
|
||||
const body = scopeParsed.data;
|
||||
|
||||
@@ -21,7 +21,6 @@ function parseUserAgent(ua: string | null): {
|
||||
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";
|
||||
@@ -29,7 +28,6 @@ function parseUserAgent(ua: string | null): {
|
||||
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";
|
||||
@@ -37,7 +35,6 @@ function parseUserAgent(ua: string | null): {
|
||||
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") ||
|
||||
@@ -76,7 +73,7 @@ export default async function sessionsRoutes(
|
||||
is_current: currentHash ? s.token_hash === currentHash : false,
|
||||
device_info,
|
||||
ip_address: s.ip_address || "",
|
||||
created_at: s.created_at ? s.created_at.toISOString() : "",
|
||||
created_at: s.created_at ?? "",
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
@@ -44,7 +44,6 @@ export default async function totpRoutes(
|
||||
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",
|
||||
@@ -66,7 +65,6 @@ export default async function totpRoutes(
|
||||
backupCodesHashed.push(bcrypt.hashSync(code, 10));
|
||||
}
|
||||
|
||||
// Encrypt and store
|
||||
const encryptedSecret = encrypt(String(secret));
|
||||
await prisma.users.update({
|
||||
where: { id: request.authData!.userId },
|
||||
@@ -237,7 +235,6 @@ export default async function totpRoutes(
|
||||
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 },
|
||||
@@ -249,7 +246,6 @@ export default async function totpRoutes(
|
||||
},
|
||||
});
|
||||
|
||||
// Delete used login token
|
||||
await prisma.totp_login_tokens.delete({ where: { id: storedToken.id } });
|
||||
|
||||
// Create tokens (same as /login/totp flow)
|
||||
|
||||
@@ -176,13 +176,11 @@ export default async function tripsRoutes(
|
||||
end_km: Number(body.end_km),
|
||||
route_from: String(body.route_from),
|
||||
route_to: String(body.route_to),
|
||||
is_business:
|
||||
!!body.is_business,
|
||||
is_business: !!body.is_business,
|
||||
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) },
|
||||
@@ -227,9 +225,7 @@ export default async function tripsRoutes(
|
||||
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;
|
||||
if (body.is_business !== undefined) data.is_business = !!body.is_business;
|
||||
if (body.notes !== undefined)
|
||||
data.notes = body.notes ? String(body.notes) : null;
|
||||
|
||||
|
||||
@@ -107,9 +107,7 @@ export default async function vehiclesRoutes(
|
||||
actual_km:
|
||||
body.actual_km !== undefined ? Number(body.actual_km) : undefined,
|
||||
is_active:
|
||||
body.is_active !== undefined
|
||||
? !!body.is_active
|
||||
: undefined,
|
||||
body.is_active !== undefined ? !!body.is_active : undefined,
|
||||
},
|
||||
});
|
||||
await logAudit({
|
||||
|
||||
Reference in New Issue
Block a user