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:
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user