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

@@ -56,12 +56,6 @@ const TRANSITION_CLASSES: Record<string, string> = {
paid: "admin-btn admin-btn-primary",
};
const VAT_OPTIONS = [
{ value: 21, label: "21%" },
{ value: 12, label: "12%" },
{ value: 0, label: "0%" },
];
interface InvoiceItem {
id?: number;
_key: string;
@@ -145,6 +139,7 @@ function SortableInvoiceRow({
index,
currency,
apply_vat,
vatOptions,
onUpdate,
onRemove,
canDelete,
@@ -153,6 +148,7 @@ function SortableInvoiceRow({
index: number;
currency: string;
apply_vat: boolean;
vatOptions: { value: number; label: string }[];
onUpdate: (
index: number,
field: keyof InvoiceItem,
@@ -266,7 +262,7 @@ function SortableInvoiceRow({
minWidth: "4.5rem",
}}
>
{VAT_OPTIONS.map((o) => (
{vatOptions.map((o) => (
<option key={o.value} value={o.value}>
{o.label}
</option>
@@ -309,6 +305,7 @@ function SortableInvoiceEditRow({
item,
index,
apply_vat,
vatOptions,
onUpdate,
onRemove,
canDelete,
@@ -316,6 +313,7 @@ function SortableInvoiceEditRow({
item: InvoiceItem;
index: number;
apply_vat: boolean;
vatOptions: { value: number; label: string }[];
onUpdate: (index: number, field: string, value: string | number) => void;
onRemove: (index: number) => void;
canDelete: boolean;
@@ -427,7 +425,7 @@ function SortableInvoiceEditRow({
minWidth: "4.5rem",
}}
>
{VAT_OPTIONS.map((o) => (
{vatOptions.map((o) => (
<option key={o.value} value={o.value}>
{o.label}
</option>
@@ -543,6 +541,45 @@ export default function InvoiceDetail() {
const [customerSearch, setCustomerSearch] = useState("");
const [showCustomerDropdown, setShowCustomerDropdown] = useState(false);
const [companySettings, setCompanySettings] = useState<{
default_currency: string;
default_vat_rate: number;
available_currencies: string[];
available_vat_rates: number[];
} | null>(null);
useEffect(() => {
apiFetch(`${API_BASE}/company-settings`)
.then((r) => r.json())
.then((d) => {
if (d.success) setCompanySettings(d.data);
})
.catch(() => {});
}, []);
const vatOptions = (
companySettings?.available_vat_rates || [0, 10, 12, 15, 21]
).map((v) => ({
value: v,
label: `${v}%`,
}));
useEffect(() => {
if (companySettings && !isEdit) {
setForm((prev) => ({
...prev,
currency:
prev.currency === "CZK"
? companySettings.default_currency || "CZK"
: prev.currency,
vat_rate:
prev.vat_rate === 21
? (companySettings.default_vat_rate ?? 21)
: prev.vat_rate,
}));
}
}, [companySettings, isEdit]);
const DRAFT_KEY = "boha_invoice_draft";
const clearDraft = useCallback(() => {
@@ -634,13 +671,16 @@ export default function InvoiceDetail() {
const orderData = await results[3].json();
if (orderData.success) {
const order = orderData.data;
const vatRate = Number(order.vat_rate) || 21;
const vatRate =
Number(order.vat_rate) ||
(companySettings?.default_vat_rate ?? 21);
setForm((prev) => ({
...prev,
customer_id: order.customer_id,
customer_name: order.customer_name || "",
order_id: order.id,
currency: order.currency || "CZK",
currency:
order.currency || companySettings?.default_currency || "CZK",
apply_vat: Number(order.apply_vat) || 0,
vat_rate: vatRate,
}));
@@ -963,7 +1003,8 @@ export default function InvoiceDetail() {
quantity: Number(item.quantity) || 1,
unit: item.unit || "",
unit_price: Number(item.unit_price) || 0,
vat_rate: Number(item.vat_rate) || 21,
vat_rate:
Number(item.vat_rate) || (companySettings?.default_vat_rate ?? 21),
})),
);
setEditingItems(true);
@@ -988,7 +1029,7 @@ export default function InvoiceDetail() {
quantity: 1,
unit: "ks",
unit_price: 0,
vat_rate: 21,
vat_rate: companySettings?.default_vat_rate ?? 21,
},
]);
};
@@ -1373,9 +1414,18 @@ export default function InvoiceDetail() {
}
className="admin-form-select"
>
<option value="CZK">CZK ()</option>
<option value="EUR">EUR</option>
<option value="USD">USD ($)</option>
{(
companySettings?.available_currencies || [
"CZK",
"EUR",
"USD",
"GBP",
]
).map((c) => (
<option key={c} value={c}>
{c}
</option>
))}
</select>
</FormField>
<FormField label="Jazyk faktury">
@@ -1514,6 +1564,7 @@ export default function InvoiceDetail() {
index={index}
currency={form.currency}
apply_vat={!!form.apply_vat}
vatOptions={vatOptions}
onUpdate={updateItem}
onRemove={removeItem}
canDelete={items.length > 1}
@@ -1851,6 +1902,7 @@ export default function InvoiceDetail() {
item={item}
index={index}
apply_vat={!!Number(invoice.apply_vat)}
vatOptions={vatOptions}
onUpdate={updateEditItem}
onRemove={removeEditItem}
canDelete={editItems.length > 1}