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

@@ -21,8 +21,8 @@ const STATUS_CLASSES: Record<string, string> = {
unpaid: "admin-badge-invoice-overdue",
paid: "admin-badge-invoice-paid",
};
const CURRENCY_OPTIONS = ["CZK", "EUR", "USD", "GBP"];
const VAT_RATE_OPTIONS = [0, 10, 12, 15, 21];
const DEFAULT_CURRENCIES = ["CZK", "EUR", "USD", "GBP"];
const DEFAULT_VAT_RATES = [0, 10, 12, 15, 21];
const MONTH_NAMES = [
"leden",
@@ -124,13 +124,20 @@ function formatCzkWithDetail(
return { value: formatMultiCurrency(amounts), detail: null };
}
function emptyMeta(): UploadMeta {
interface CompanySettings {
default_currency: string;
default_vat_rate: number;
available_currencies: string[];
available_vat_rates: number[];
}
function emptyMeta(settings: CompanySettings | null): UploadMeta {
return {
supplier_name: "",
invoice_number: "",
amount: "",
currency: "CZK",
vat_rate: "21",
currency: settings?.default_currency || "CZK",
vat_rate: String(settings?.default_vat_rate ?? 21),
issue_date: "",
due_date: "",
notes: "",
@@ -168,6 +175,8 @@ export default function ReceivedInvoices({
const [saving, setSaving] = useState(false);
const [supplierNames, setSupplierNames] = useState<string[]>([]);
const [companySettings, setCompanySettings] =
useState<CompanySettings | null>(null);
const [uploadFiles, setUploadFiles] = useState<File[]>([]);
const [uploadMeta, setUploadMeta] = useState<UploadMeta[]>([]);
@@ -231,6 +240,22 @@ export default function ReceivedInvoices({
.catch(() => {});
}, []);
useEffect(() => {
apiFetch(`${API_BASE}/company-settings`)
.then((r) => r.json())
.then((d) => {
if (d.success) setCompanySettings(d.data);
})
.catch(() => {});
}, []);
const currencyOptions =
companySettings?.available_currencies || DEFAULT_CURRENCIES;
const vatRateOptions =
companySettings?.available_vat_rates || DEFAULT_VAT_RATES;
const defaultCurrency = companySettings?.default_currency || "CZK";
const defaultVatRate = String(companySettings?.default_vat_rate ?? 21);
// Fetch stats (silent refresh without animation)
const refreshStats = useCallback(async () => {
try {
@@ -292,7 +317,10 @@ export default function ReceivedInvoices({
return true;
});
setUploadFiles((prev) => [...prev, ...valid]);
setUploadMeta((prev) => [...prev, ...valid.map(() => emptyMeta())]);
setUploadMeta((prev) => [
...prev,
...valid.map(() => emptyMeta(companySettings)),
]);
e.target.value = "";
};
@@ -1041,12 +1069,14 @@ export default function ReceivedInvoices({
<FormField label="Měna" style={{ width: "90px" }}>
<select
className="admin-form-select"
value={uploadMeta[idx]?.currency || "CZK"}
value={
uploadMeta[idx]?.currency || defaultCurrency
}
onChange={(e) =>
updateMeta(idx, "currency", e.target.value)
}
>
{CURRENCY_OPTIONS.map((c) => (
{currencyOptions.map((c) => (
<option key={c} value={c}>
{c}
</option>
@@ -1056,12 +1086,14 @@ export default function ReceivedInvoices({
<FormField label="DPH %" style={{ width: "90px" }}>
<select
className="admin-form-select"
value={uploadMeta[idx]?.vat_rate || "21"}
value={
uploadMeta[idx]?.vat_rate || defaultVatRate
}
onChange={(e) =>
updateMeta(idx, "vat_rate", e.target.value)
}
>
{VAT_RATE_OPTIONS.map((r) => (
{vatRateOptions.map((r) => (
<option key={r} value={String(r)}>
{r}%
</option>
@@ -1085,14 +1117,14 @@ export default function ReceivedInvoices({
uploadMeta[idx].amount || "0",
);
const r = parseFloat(
uploadMeta[idx].vat_rate || "21",
uploadMeta[idx].vat_rate || defaultVatRate,
);
return r > 0
? Math.round((a - a / (1 + r / 100)) * 100) /
100
: 0;
})(),
uploadMeta[idx].currency || "CZK",
uploadMeta[idx].currency || defaultCurrency,
)}
</div>
)}
@@ -1255,7 +1287,7 @@ export default function ReceivedInvoices({
}
disabled={ro}
>
{CURRENCY_OPTIONS.map((c) => (
{currencyOptions.map((c) => (
<option key={c} value={c}>
{c}
</option>
@@ -1275,7 +1307,7 @@ export default function ReceivedInvoices({
}
disabled={ro}
>
{VAT_RATE_OPTIONS.map((r) => (
{vatRateOptions.map((r) => (
<option key={r} value={String(r)}>
{r}%
</option>
@@ -1296,14 +1328,14 @@ export default function ReceivedInvoices({
(() => {
const a = parseFloat(editInvoice.amount || "0");
const r = parseFloat(
editInvoice.vat_rate || "21",
editInvoice.vat_rate || defaultVatRate,
);
return r > 0
? Math.round((a - a / (1 + r / 100)) * 100) /
100
: 0;
})(),
editInvoice.currency || "CZK",
editInvoice.currency || defaultCurrency,
)}
</div>
)}