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:
BOHA
2026-03-26 10:36:39 +01:00
parent 0317ba3168
commit baceb88347
60 changed files with 2475 additions and 563 deletions

View File

@@ -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");