feat: system settings, dynamic logos, template numbering, permission consolidation

- System settings page with tabs: Security, System, Firma
- Configurable attendance rules (break thresholds, rounding) from DB
- Configurable document numbering with template patterns ({YYYY}/{PREFIX}/{NNN})
- Dynamic logo upload (light/dark variants) served from DB instead of static files
- Email settings (SMTP from/name, alert/leave emails) configurable in UI
- Currency and VAT rate lists configurable, used across all modules
- Permissions simplified: offers.settings + settings.roles + settings.security → settings.manage
- Leaflet bundled locally, removed unpkg.com from CSP
- Silent catch blocks fixed with proper logging
- console.log replaced with app.log.info in server.ts
- Schema renamed: company-settings.schema → settings.schema
- App info section: version, Node.js, uptime, memory, DB status, NAS status

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
BOHA
2026-03-27 10:15:47 +01:00
parent f49015a627
commit 6b31b2f74b
43 changed files with 2094 additions and 525 deletions

View File

@@ -2,6 +2,7 @@ import { attendance_leave_type, Prisma } from "@prisma/client";
import prisma from "../config/database";
import { getBusinessDaysInMonth } from "../utils/czech-holidays";
import { localDateStr } from "../utils/date";
import { getSystemSettings } from "./system-settings";
type AttendanceWithRelations = Prisma.attendanceGetPayload<{
include: {
@@ -48,13 +49,13 @@ function calcWorkedHours(
return Math.max(0, mins) / 60;
}
const roundUp15 = (d: Date) => {
const ms = 15 * 60 * 1000;
const roundUp = (d: Date, minutes: number) => {
const ms = minutes * 60 * 1000;
return new Date(Math.ceil(d.getTime() / ms) * ms);
};
const roundDown15 = (d: Date) => {
const ms = 15 * 60 * 1000;
const roundDown = (d: Date, minutes: number) => {
const ms = minutes * 60 * 1000;
return new Date(Math.floor(d.getTime() / ms) * ms);
};
@@ -1189,6 +1190,7 @@ export async function createLeave(data: LeaveData, authUserId: number) {
}
export async function punchAction(userId: number, data: PunchData) {
const settings = await getSystemSettings();
const action = data.punch_action;
const now = new Date();
const y = now.getFullYear(),
@@ -1223,7 +1225,7 @@ export async function punchAction(userId: number, data: PunchData) {
return { error: "Máte již aktivní směnu. Nejdříve zaznamenejte odchod." };
}
const arrivalTime = roundUp15(now);
const arrivalTime = roundUp(now, settings.clock_rounding_minutes);
const record = await prisma.attendance.create({
data: {
user_id: userId,
@@ -1257,7 +1259,7 @@ export async function punchAction(userId: number, data: PunchData) {
return { error: "Nemáte aktivní směnu." };
}
const departureTime = roundDown15(now);
const departureTime = roundDown(now, settings.clock_rounding_minutes);
const updateData: Record<string, unknown> = {
departure_time: departureTime,
@@ -1270,9 +1272,12 @@ export async function punchAction(userId: number, data: PunchData) {
if (!ongoing.break_start && ongoing.arrival_time) {
const shiftMs = departureTime.getTime() - ongoing.arrival_time.getTime();
const shiftHours = shiftMs / (1000 * 60 * 60);
if (shiftHours > 6) {
if (shiftHours > settings.break_threshold_hours) {
const midpoint = new Date(ongoing.arrival_time.getTime() + shiftMs / 2);
const breakMins = shiftHours > 12 ? 30 : 15;
const breakMins =
shiftHours > settings.break_threshold_hours * 2
? settings.break_duration_long
: settings.break_duration_short;
updateData.break_start = midpoint;
updateData.break_end = new Date(
midpoint.getTime() + breakMins * 60 * 1000,
@@ -1311,9 +1316,11 @@ export async function punchAction(userId: number, data: PunchData) {
return { error: "Nemáte aktivní směnu bez přestávky." };
}
const ms10 = 10 * 60 * 1000;
const breakStart = new Date(Math.round(now.getTime() / ms10) * ms10);
const breakEnd = new Date(breakStart.getTime() + 30 * 60 * 1000);
const msRound = settings.clock_rounding_minutes * 60 * 1000;
const breakStart = new Date(Math.round(now.getTime() / msRound) * msRound);
const breakEnd = new Date(
breakStart.getTime() + settings.break_duration_long * 60 * 1000,
);
await prisma.attendance.update({
where: { id: ongoing.id },