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})
+
+
+
+ | Číslo |
+ Firma |
+ Částka |
+ Splatnost |
+ Stav |
+
+
+ ${rows}
+
`;
+ };
+
+ 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}`,
+ );
+ }
+}