diff --git a/index.html b/index.html index e99868a..28613ec 100644 --- a/index.html +++ b/index.html @@ -1,19 +1,28 @@ - + - - - - - - - - - - - BOHA | Admin - - -
- - + + + + + + + + + + + + + + BOHA | Admin + + +
+ + diff --git a/package-lock.json b/package-lock.json index 0231da6..3fb6035 100644 --- a/package-lock.json +++ b/package-lock.json @@ -28,6 +28,7 @@ "framer-motion": "^12.38.0", "hi-base32": "^0.5.1", "jsonwebtoken": "^9.0.3", + "node-cron": "^4.2.1", "nodemailer": "^8.0.2", "otpauth": "^9.5.0", "prisma": "^6.19.2", @@ -46,6 +47,7 @@ "@types/jsonwebtoken": "^9.0.10", "@types/mysql": "^2.15.27", "@types/node": "^25.5.0", + "@types/node-cron": "^3.0.11", "@types/nodemailer": "^7.0.11", "@types/qrcode": "^1.5.6", "@types/react": "^19.2.14", @@ -1547,6 +1549,13 @@ "undici-types": "~7.18.0" } }, + "node_modules/@types/node-cron": { + "version": "3.0.11", + "resolved": "https://registry.npmjs.org/@types/node-cron/-/node-cron-3.0.11.tgz", + "integrity": "sha512-0ikrnug3/IyneSHqCBeslAhlK2aBfYek1fGo4bP4QnZPmiqSGRK+Oy7ZMisLWkesffJvQ1cqAcBnJC+8+nxIAg==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/nodemailer": { "version": "7.0.11", "resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-7.0.11.tgz", @@ -4232,6 +4241,15 @@ "node": ">= 0.4.0" } }, + "node_modules/node-cron": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/node-cron/-/node-cron-4.2.1.tgz", + "integrity": "sha512-lgimEHPE/QDgFlywTd8yTR61ptugX3Qer29efeyWw2rv259HtGBNn1vZVmp8lB9uo9wC0t/AT4iGqXxia+CJFg==", + "license": "ISC", + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/node-fetch-native": { "version": "1.6.7", "resolved": "https://registry.npmjs.org/node-fetch-native/-/node-fetch-native-1.6.7.tgz", diff --git a/package.json b/package.json index 22c9a52..ec6cda2 100644 --- a/package.json +++ b/package.json @@ -43,6 +43,7 @@ "framer-motion": "^12.38.0", "hi-base32": "^0.5.1", "jsonwebtoken": "^9.0.3", + "node-cron": "^4.2.1", "nodemailer": "^8.0.2", "otpauth": "^9.5.0", "prisma": "^6.19.2", @@ -61,6 +62,7 @@ "@types/jsonwebtoken": "^9.0.10", "@types/mysql": "^2.15.27", "@types/node": "^25.5.0", + "@types/node-cron": "^3.0.11", "@types/nodemailer": "^7.0.11", "@types/qrcode": "^1.5.6", "@types/react": "^19.2.14", diff --git a/prisma/migrations/20260326_add_invoice_alert_log/migration.sql b/prisma/migrations/20260326_add_invoice_alert_log/migration.sql new file mode 100644 index 0000000..985fc41 --- /dev/null +++ b/prisma/migrations/20260326_add_invoice_alert_log/migration.sql @@ -0,0 +1,9 @@ +CREATE TABLE `invoice_alert_log` ( + `id` INT NOT NULL AUTO_INCREMENT, + `invoice_type` VARCHAR(20) NOT NULL, + `invoice_id` INT NOT NULL, + `alert_type` VARCHAR(20) NOT NULL, + `sent_at` DATETIME(0) NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + UNIQUE KEY `invoice_type_invoice_id_alert_type` (`invoice_type`, `invoice_id`, `alert_type`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index e5f5b32..6d60854 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -601,6 +601,16 @@ enum leave_requests_status { cancelled } +model invoice_alert_log { + id Int @id @default(autoincrement()) + invoice_type String @db.VarChar(20) // "created" or "received" + invoice_id Int + alert_type String @db.VarChar(20) // "3days" or "due" + sent_at DateTime @default(now()) @db.DateTime(0) + + @@unique([invoice_type, invoice_id, alert_type]) +} + enum received_invoices_status { unpaid paid diff --git a/public/favicon-96x96.png b/public/favicon-96x96.png new file mode 100644 index 0000000..4fe2296 Binary files /dev/null and b/public/favicon-96x96.png differ diff --git a/public/favicon.ico b/public/favicon.ico new file mode 100644 index 0000000..5a95c0a Binary files /dev/null and b/public/favicon.ico differ diff --git a/public/favicon.svg b/public/favicon.svg new file mode 100644 index 0000000..ad0d6bb --- /dev/null +++ b/public/favicon.svg @@ -0,0 +1,3 @@ + \ No newline at end of file diff --git a/src/config/env.ts b/src/config/env.ts index 49b35e8..da3356a 100644 --- a/src/config/env.ts +++ b/src/config/env.ts @@ -65,6 +65,7 @@ export const config = { smtpFrom: process.env.SMTP_FROM || "", smtpFromName: process.env.SMTP_FROM_NAME || "BOHA Automation", leaveNotify: process.env.LEAVE_NOTIFY_EMAIL || "", + invoiceAlert: process.env.INVOICE_ALERT_EMAIL || "", }, appUrl: process.env.APP_URL || "", diff --git a/src/server.ts b/src/server.ts index 5a0beed..78f59f3 100644 --- a/src/server.ts +++ b/src/server.ts @@ -177,6 +177,21 @@ async function start() { }); } + // --- Invoice alert cron (daily at 8:00 AM) --- + if (config.email.invoiceAlert) { + const cron = await import("node-cron"); + cron.default.schedule("0 8 * * *", async () => { + try { + const { checkInvoiceAlerts } = + await import("./services/invoice-alerts"); + await checkInvoiceAlerts(); + } catch (err) { + app.log.error(err, "Invoice alert cron failed"); + } + }); + console.log("Invoice alert cron scheduled (daily 8:00)"); + } + // --- Start --- const port = config.isProduction ? config.port : 3000; try { diff --git a/src/services/invoice-alerts.ts b/src/services/invoice-alerts.ts new file mode 100644 index 0000000..9681110 --- /dev/null +++ b/src/services/invoice-alerts.ts @@ -0,0 +1,227 @@ +import prisma from "../config/database"; +import { config } from "../config/env"; +import { sendMail } from "./mailer"; +import { localDateCzStr, localDateStr } from "../utils/date"; + +interface AlertInvoice { + id: number; + type: "created" | "received"; + number: string; + counterparty: string; + amount: string; + currency: string; + due_date: string; + days_label: string; +} + +function escapeHtml(str: string): string { + return str + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """); +} + +function formatAmount(n: number | { toNumber?: () => number }): string { + const num = typeof n === "number" ? n : Number(n); + return num.toLocaleString("cs-CZ", { + minimumFractionDigits: 2, + maximumFractionDigits: 2, + }); +} + +export async function checkInvoiceAlerts(): Promise { + const alertEmail = config.email.invoiceAlert; + if (!alertEmail) return; + + const today = new Date(); + const todayStr = localDateStr(today); + const in3days = new Date(today); + in3days.setDate(in3days.getDate() + 3); + const in3daysStr = localDateStr(in3days); + + const alerts: AlertInvoice[] = []; + + // --- Created invoices (customer owes us) --- + const createdInvoices = await prisma.invoices.findMany({ + where: { + status: { in: ["issued", "overdue"] }, + due_date: { not: null }, + }, + include: { + customers: { select: { name: true } }, + invoice_items: true, + }, + }); + + for (const inv of createdInvoices) { + if (!inv.due_date) continue; + const dueDateStr = localDateStr(new Date(inv.due_date)); + + let alertType: string | null = null; + let daysLabel = ""; + + if (dueDateStr === todayStr) { + alertType = "due"; + daysLabel = "splatnost dnes"; + } else if (dueDateStr === in3daysStr) { + alertType = "3days"; + daysLabel = "splatnost za 3 dny"; + } + + if (!alertType) continue; + + const alreadySent = await prisma.invoice_alert_log.findUnique({ + where: { + invoice_type_invoice_id_alert_type: { + invoice_type: "created", + invoice_id: inv.id, + alert_type: alertType, + }, + }, + }); + if (alreadySent) continue; + + const subtotal = inv.invoice_items.reduce( + (sum, item) => sum + Number(item.quantity) * Number(item.unit_price), + 0, + ); + + alerts.push({ + id: inv.id, + type: "created", + number: inv.invoice_number || `#${inv.id}`, + counterparty: inv.customers?.name || "—", + amount: formatAmount(subtotal), + currency: inv.currency || "CZK", + due_date: localDateCzStr(new Date(inv.due_date)), + days_label: daysLabel, + }); + + await prisma.invoice_alert_log.create({ + data: { + invoice_type: "created", + invoice_id: inv.id, + alert_type: alertType, + }, + }); + } + + // --- Received invoices (we owe supplier) --- + const receivedInvoices = await prisma.received_invoices.findMany({ + where: { + status: "unpaid", + due_date: { not: null }, + }, + }); + + for (const inv of receivedInvoices) { + if (!inv.due_date) continue; + const dueDateStr = localDateStr(new Date(inv.due_date)); + + let alertType: string | null = null; + let daysLabel = ""; + + if (dueDateStr === todayStr) { + alertType = "due"; + daysLabel = "splatnost dnes"; + } else if (dueDateStr === in3daysStr) { + alertType = "3days"; + daysLabel = "splatnost za 3 dny"; + } + + if (!alertType) continue; + + const alreadySent = await prisma.invoice_alert_log.findUnique({ + where: { + invoice_type_invoice_id_alert_type: { + invoice_type: "received", + invoice_id: inv.id, + alert_type: alertType, + }, + }, + }); + if (alreadySent) continue; + + alerts.push({ + id: inv.id, + type: "received", + number: inv.invoice_number || inv.supplier_name, + counterparty: inv.supplier_name, + amount: formatAmount(inv.amount), + currency: inv.currency, + due_date: localDateCzStr(new Date(inv.due_date)), + days_label: daysLabel, + }); + + await prisma.invoice_alert_log.create({ + data: { + invoice_type: "received", + invoice_id: inv.id, + alert_type: alertType, + }, + }); + } + + if (alerts.length === 0) return; + + // --- Build summary email --- + const createdAlerts = alerts.filter((a) => a.type === "created"); + const receivedAlerts = alerts.filter((a) => a.type === "received"); + + const subject = `Upozornění na splatnost faktur (${alerts.length})`; + + const buildTable = (items: AlertInvoice[], title: string) => { + if (items.length === 0) return ""; + const rows = items + .map( + (a) => ` + + ${escapeHtml(a.number)} + ${escapeHtml(a.counterparty)} + ${a.amount} ${a.currency} + ${a.due_date} + ${a.days_label} + `, + ) + .join(""); + + return ` +

${escapeHtml(title)} (${items.length})

+ + + + + + + + + + + ${rows} +
ČísloFirmaČástkaSplatnostStav
`; + }; + + const html = ` + + +

Upozornění na splatnost faktur

+

Následující faktury se blíží ke splatnosti nebo jsou dnes splatné:

+ ${buildTable(createdAlerts, "Vydané faktury (neuhrazené zákazníkem)")} + ${buildTable(receivedAlerts, "Přijaté faktury (k úhradě)")} +
+

+ Automatické upozornění vygenerováno ${localDateCzStr(today)}. +

+ + `; + + const sent = await sendMail(alertEmail, subject, html); + if (!sent) { + console.error(`InvoiceAlerts: Failed to send alert to ${alertEmail}`); + } else { + console.log( + `InvoiceAlerts: Sent ${alerts.length} alert(s) to ${alertEmail}`, + ); + } +}