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:
@@ -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" }}
|
||||
|
||||
Reference in New Issue
Block a user