feat: invoice due date email alerts, add favicon
- Daily cron (8:00 AM) checks created and received invoices - Alerts 3 days before due date and on due date - Summary email to INVOICE_ALERT_EMAIL with grouped tables - Tracks sent alerts in invoice_alert_log to prevent duplicates - node-cron scheduler runs inside the app process - Favicon files copied from PHP project Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
43
index.html
43
index.html
@@ -1,19 +1,28 @@
|
|||||||
<!DOCTYPE html>
|
<!doctype html>
|
||||||
<html lang="cs" data-theme="dark">
|
<html lang="cs" data-theme="dark">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<meta name="theme-color" content="#12121a" />
|
<meta name="theme-color" content="#12121a" />
|
||||||
<meta http-equiv="Cache-Control" content="no-cache, no-store, must-revalidate" />
|
<meta
|
||||||
<meta http-equiv="Pragma" content="no-cache" />
|
http-equiv="Cache-Control"
|
||||||
<meta http-equiv="Expires" content="0" />
|
content="no-cache, no-store, must-revalidate"
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
/>
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
<meta http-equiv="Pragma" content="no-cache" />
|
||||||
<link href="https://fonts.googleapis.com/css2?family=DM+Mono:wght@300;400;500&family=Plus+Jakarta+Sans:wght@300;400;500;600;700;800&family=Urbanist:wght@300;400;500;600;700;800&display=swap" rel="stylesheet" />
|
<meta http-equiv="Expires" content="0" />
|
||||||
<title>BOHA | Admin</title>
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
</head>
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||||
<body>
|
<link
|
||||||
<div id="root"></div>
|
href="https://fonts.googleapis.com/css2?family=DM+Mono:wght@300;400;500&family=Plus+Jakarta+Sans:wght@300;400;500;600;700;800&family=Urbanist:wght@300;400;500;600;700;800&display=swap"
|
||||||
<script type="module" src="/src/main.tsx"></script>
|
rel="stylesheet"
|
||||||
</body>
|
/>
|
||||||
|
<link rel="icon" type="image/png" href="/favicon-96x96.png" sizes="96x96" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
|
<link rel="shortcut icon" href="/favicon.ico" />
|
||||||
|
<title>BOHA | Admin</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
18
package-lock.json
generated
18
package-lock.json
generated
@@ -28,6 +28,7 @@
|
|||||||
"framer-motion": "^12.38.0",
|
"framer-motion": "^12.38.0",
|
||||||
"hi-base32": "^0.5.1",
|
"hi-base32": "^0.5.1",
|
||||||
"jsonwebtoken": "^9.0.3",
|
"jsonwebtoken": "^9.0.3",
|
||||||
|
"node-cron": "^4.2.1",
|
||||||
"nodemailer": "^8.0.2",
|
"nodemailer": "^8.0.2",
|
||||||
"otpauth": "^9.5.0",
|
"otpauth": "^9.5.0",
|
||||||
"prisma": "^6.19.2",
|
"prisma": "^6.19.2",
|
||||||
@@ -46,6 +47,7 @@
|
|||||||
"@types/jsonwebtoken": "^9.0.10",
|
"@types/jsonwebtoken": "^9.0.10",
|
||||||
"@types/mysql": "^2.15.27",
|
"@types/mysql": "^2.15.27",
|
||||||
"@types/node": "^25.5.0",
|
"@types/node": "^25.5.0",
|
||||||
|
"@types/node-cron": "^3.0.11",
|
||||||
"@types/nodemailer": "^7.0.11",
|
"@types/nodemailer": "^7.0.11",
|
||||||
"@types/qrcode": "^1.5.6",
|
"@types/qrcode": "^1.5.6",
|
||||||
"@types/react": "^19.2.14",
|
"@types/react": "^19.2.14",
|
||||||
@@ -1547,6 +1549,13 @@
|
|||||||
"undici-types": "~7.18.0"
|
"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": {
|
"node_modules/@types/nodemailer": {
|
||||||
"version": "7.0.11",
|
"version": "7.0.11",
|
||||||
"resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-7.0.11.tgz",
|
"resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-7.0.11.tgz",
|
||||||
@@ -4232,6 +4241,15 @@
|
|||||||
"node": ">= 0.4.0"
|
"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": {
|
"node_modules/node-fetch-native": {
|
||||||
"version": "1.6.7",
|
"version": "1.6.7",
|
||||||
"resolved": "https://registry.npmjs.org/node-fetch-native/-/node-fetch-native-1.6.7.tgz",
|
"resolved": "https://registry.npmjs.org/node-fetch-native/-/node-fetch-native-1.6.7.tgz",
|
||||||
|
|||||||
@@ -43,6 +43,7 @@
|
|||||||
"framer-motion": "^12.38.0",
|
"framer-motion": "^12.38.0",
|
||||||
"hi-base32": "^0.5.1",
|
"hi-base32": "^0.5.1",
|
||||||
"jsonwebtoken": "^9.0.3",
|
"jsonwebtoken": "^9.0.3",
|
||||||
|
"node-cron": "^4.2.1",
|
||||||
"nodemailer": "^8.0.2",
|
"nodemailer": "^8.0.2",
|
||||||
"otpauth": "^9.5.0",
|
"otpauth": "^9.5.0",
|
||||||
"prisma": "^6.19.2",
|
"prisma": "^6.19.2",
|
||||||
@@ -61,6 +62,7 @@
|
|||||||
"@types/jsonwebtoken": "^9.0.10",
|
"@types/jsonwebtoken": "^9.0.10",
|
||||||
"@types/mysql": "^2.15.27",
|
"@types/mysql": "^2.15.27",
|
||||||
"@types/node": "^25.5.0",
|
"@types/node": "^25.5.0",
|
||||||
|
"@types/node-cron": "^3.0.11",
|
||||||
"@types/nodemailer": "^7.0.11",
|
"@types/nodemailer": "^7.0.11",
|
||||||
"@types/qrcode": "^1.5.6",
|
"@types/qrcode": "^1.5.6",
|
||||||
"@types/react": "^19.2.14",
|
"@types/react": "^19.2.14",
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -601,6 +601,16 @@ enum leave_requests_status {
|
|||||||
cancelled
|
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 {
|
enum received_invoices_status {
|
||||||
unpaid
|
unpaid
|
||||||
paid
|
paid
|
||||||
|
|||||||
BIN
public/favicon-96x96.png
Normal file
BIN
public/favicon-96x96.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.8 KiB |
BIN
public/favicon.ico
Normal file
BIN
public/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
3
public/favicon.svg
Normal file
3
public/favicon.svg
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" version="1.1" xmlns:xlink="http://www.w3.org/1999/xlink" width="100" height="100" viewBox="0 0 100 100"><image width="100" height="100" xlink:href="data:image/png;base64,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"></image><style>@media (prefers-color-scheme: light) { :root { filter: none; } }
|
||||||
|
@media (prefers-color-scheme: dark) { :root { filter: none; } }
|
||||||
|
</style></svg>
|
||||||
|
After Width: | Height: | Size: 2.8 KiB |
@@ -65,6 +65,7 @@ export const config = {
|
|||||||
smtpFrom: process.env.SMTP_FROM || "",
|
smtpFrom: process.env.SMTP_FROM || "",
|
||||||
smtpFromName: process.env.SMTP_FROM_NAME || "BOHA Automation",
|
smtpFromName: process.env.SMTP_FROM_NAME || "BOHA Automation",
|
||||||
leaveNotify: process.env.LEAVE_NOTIFY_EMAIL || "",
|
leaveNotify: process.env.LEAVE_NOTIFY_EMAIL || "",
|
||||||
|
invoiceAlert: process.env.INVOICE_ALERT_EMAIL || "",
|
||||||
},
|
},
|
||||||
|
|
||||||
appUrl: process.env.APP_URL || "",
|
appUrl: process.env.APP_URL || "",
|
||||||
|
|||||||
@@ -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 ---
|
// --- Start ---
|
||||||
const port = config.isProduction ? config.port : 3000;
|
const port = config.isProduction ? config.port : 3000;
|
||||||
try {
|
try {
|
||||||
|
|||||||
227
src/services/invoice-alerts.ts
Normal file
227
src/services/invoice-alerts.ts
Normal file
@@ -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, ">")
|
||||||
|
.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<void> {
|
||||||
|
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) => `
|
||||||
|
<tr>
|
||||||
|
<td style="padding: 8px; border-bottom: 1px solid #ddd;">${escapeHtml(a.number)}</td>
|
||||||
|
<td style="padding: 8px; border-bottom: 1px solid #ddd;">${escapeHtml(a.counterparty)}</td>
|
||||||
|
<td style="padding: 8px; border-bottom: 1px solid #ddd; text-align: right;">${a.amount} ${a.currency}</td>
|
||||||
|
<td style="padding: 8px; border-bottom: 1px solid #ddd;">${a.due_date}</td>
|
||||||
|
<td style="padding: 8px; border-bottom: 1px solid #ddd; color: ${a.days_label.includes("dnes") ? "#dc3545" : "#e67e22"}; font-weight: bold;">${a.days_label}</td>
|
||||||
|
</tr>`,
|
||||||
|
)
|
||||||
|
.join("");
|
||||||
|
|
||||||
|
return `
|
||||||
|
<h3 style="color: #333; margin-top: 24px;">${escapeHtml(title)} (${items.length})</h3>
|
||||||
|
<table style="width: 100%; border-collapse: collapse; margin: 12px 0;">
|
||||||
|
<thead>
|
||||||
|
<tr style="background: #f5f5f5;">
|
||||||
|
<th style="padding: 8px; text-align: left;">Číslo</th>
|
||||||
|
<th style="padding: 8px; text-align: left;">Firma</th>
|
||||||
|
<th style="padding: 8px; text-align: right;">Částka</th>
|
||||||
|
<th style="padding: 8px; text-align: left;">Splatnost</th>
|
||||||
|
<th style="padding: 8px; text-align: left;">Stav</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>${rows}</tbody>
|
||||||
|
</table>`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const html = `
|
||||||
|
<html>
|
||||||
|
<body style="font-family: Arial, sans-serif; line-height: 1.6; color: #333;">
|
||||||
|
<h2 style="color: #dc3545;">Upozornění na splatnost faktur</h2>
|
||||||
|
<p>Následující faktury se blíží ke splatnosti nebo jsou dnes splatné:</p>
|
||||||
|
${buildTable(createdAlerts, "Vydané faktury (neuhrazené zákazníkem)")}
|
||||||
|
${buildTable(receivedAlerts, "Přijaté faktury (k úhradě)")}
|
||||||
|
<hr style="margin: 30px 0; border: none; border-top: 1px solid #ddd;">
|
||||||
|
<p style="font-size: 12px; color: #999;">
|
||||||
|
Automatické upozornění vygenerováno ${localDateCzStr(today)}.
|
||||||
|
</p>
|
||||||
|
</body>
|
||||||
|
</html>`;
|
||||||
|
|
||||||
|
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}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user