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

@@ -98,7 +98,7 @@ const emptyForm: OfferForm = {
customer_name: "",
created_at: new Date().toISOString().split("T")[0],
valid_until: "",
currency: "EUR",
currency: "CZK",
language: "EN",
vat_rate: 21,
apply_vat: false,
@@ -327,6 +327,12 @@ export default function OfferDetail() {
const [customerOrderNumber, setCustomerOrderNumber] = useState("");
const [orderAttachment, setOrderAttachment] = useState<File | null>(null);
const [pdfLoading, setPdfLoading] = useState(false);
const [companySettings, setCompanySettings] = useState<{
default_currency: string;
default_vat_rate: number;
available_currencies: string[];
available_vat_rates: number[];
} | null>(null);
const [lockedBy, setLockedBy] = useState<{
user_id: number;
username: string;
@@ -336,6 +342,31 @@ export default function OfferDetail() {
useModalLock(showOrderModal);
useEffect(() => {
apiFetch(`${API_BASE}/company-settings`)
.then((r) => r.json())
.then((d) => {
if (d.success) setCompanySettings(d.data);
})
.catch(() => {});
}, []);
useEffect(() => {
if (companySettings && !isEdit) {
setForm((prev) => ({
...prev,
currency:
prev.currency === "EUR"
? companySettings.default_currency || "CZK"
: prev.currency,
vat_rate:
prev.vat_rate === 21
? (companySettings.default_vat_rate ?? 21)
: prev.vat_rate,
}));
}
}, [companySettings, isEdit]);
const isInvalidated = offerStatus === "invalidated";
const isLockedByOther = !!lockedBy;
const isExpiredNotInvalidated =
@@ -362,9 +393,9 @@ export default function OfferDetail() {
valid_until: d.valid_until
? String(d.valid_until).substring(0, 10)
: "",
currency: d.currency || "EUR",
currency: d.currency || companySettings?.default_currency || "CZK",
language: d.language || "EN",
vat_rate: d.vat_rate ?? 21,
vat_rate: d.vat_rate ?? companySettings?.default_vat_rate ?? 21,
apply_vat: !!d.apply_vat,
exchange_rate: d.exchange_rate || "",
scope_title: d.scope_title || "",
@@ -408,7 +439,7 @@ export default function OfferDetail() {
} finally {
setLoading(false);
}
}, [id, alert, navigate, hasPermission]);
}, [id, alert, navigate, hasPermission, companySettings]);
// Heartbeat to keep lock alive + cleanup on unmount
useEffect(() => {
@@ -1125,10 +1156,18 @@ export default function OfferDetail() {
className="admin-form-select"
disabled={isInvalidated || isLockedByOther}
>
<option value="EUR">EUR</option>
<option value="USD">USD</option>
<option value="CZK">CZK</option>
<option value="GBP">GBP</option>
{(
companySettings?.available_currencies || [
"CZK",
"EUR",
"USD",
"GBP",
]
).map((c) => (
<option key={c} value={c}>
{c}
</option>
))}
</select>
</FormField>
<FormField label="Jazyk nabídky">
@@ -1147,16 +1186,22 @@ export default function OfferDetail() {
<div className="offers-form-row-3">
<FormField label="Sazba DPH (%)">
<div className="flex-row-gap">
<input
type="number"
<select
value={form.vat_rate}
onChange={(e) =>
updateForm("vat_rate", parseFloat(e.target.value) || 0)
}
className="admin-form-input flex-1"
step="0.1"
readOnly={isInvalidated || isLockedByOther}
/>
className="admin-form-select flex-1"
disabled={isInvalidated || isLockedByOther}
>
{(
companySettings?.available_vat_rates || [0, 10, 12, 15, 21]
).map((r) => (
<option key={r} value={r}>
{r}%
</option>
))}
</select>
<label
className="admin-form-checkbox"
style={{ whiteSpace: "nowrap" }}