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:
@@ -24,8 +24,6 @@ const FIELD_LABELS: Record<string, string> = {
|
||||
vat_id: "DIČ",
|
||||
};
|
||||
|
||||
const currentYear = new Date().getFullYear().toString().slice(-2);
|
||||
|
||||
interface CustomField {
|
||||
name: string;
|
||||
value: string;
|
||||
@@ -41,11 +39,6 @@ interface CompanyForm {
|
||||
country: string;
|
||||
company_id: string;
|
||||
vat_id: string;
|
||||
quotation_prefix: string;
|
||||
default_currency: string;
|
||||
default_vat_rate: number;
|
||||
order_type_code: string;
|
||||
invoice_type_code: string;
|
||||
}
|
||||
|
||||
interface BankAccount {
|
||||
@@ -69,14 +62,19 @@ interface BankForm {
|
||||
is_default: boolean;
|
||||
}
|
||||
|
||||
export default function CompanySettings() {
|
||||
export default function CompanySettings({
|
||||
embedded,
|
||||
}: { embedded?: boolean } = {}) {
|
||||
const alert = useAlert();
|
||||
const { hasPermission } = useAuth();
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [uploadingLogo, setUploadingLogo] = useState(false);
|
||||
const [uploadingLogoDark, setUploadingLogoDark] = useState(false);
|
||||
const [logoUrl, setLogoUrl] = useState<string | null>(null);
|
||||
const [logoUrlDark, setLogoUrlDark] = useState<string | null>(null);
|
||||
const logoUrlRef = useRef<string | null>(null);
|
||||
const logoUrlDarkRef = useRef<string | null>(null);
|
||||
const [form, setForm] = useState<CompanyForm>({
|
||||
company_name: "",
|
||||
street: "",
|
||||
@@ -85,11 +83,6 @@ export default function CompanySettings() {
|
||||
country: "",
|
||||
company_id: "",
|
||||
vat_id: "",
|
||||
quotation_prefix: "N",
|
||||
default_currency: "EUR",
|
||||
default_vat_rate: 21,
|
||||
order_type_code: "71",
|
||||
invoice_type_code: "81",
|
||||
});
|
||||
const [customFields, setCustomFields] = useState<CustomField[]>([]);
|
||||
const customFieldKeyCounter = useRef(0);
|
||||
@@ -97,6 +90,12 @@ export default function CompanySettings() {
|
||||
...DEFAULT_FIELD_ORDER,
|
||||
]);
|
||||
const [bankAccounts, setBankAccounts] = useState<BankAccount[]>([]);
|
||||
const [availableCurrencies, setAvailableCurrencies] = useState<string[]>([
|
||||
"CZK",
|
||||
"EUR",
|
||||
"USD",
|
||||
"GBP",
|
||||
]);
|
||||
const [bankLoading, setBankLoading] = useState(true);
|
||||
const [bankSaving, setBankSaving] = useState(false);
|
||||
const [editingBank, setEditingBank] = useState<number | null>(null);
|
||||
@@ -151,17 +150,28 @@ export default function CompanySettings() {
|
||||
return key;
|
||||
};
|
||||
|
||||
const fetchLogo = useCallback(async () => {
|
||||
const fetchLogo = useCallback(async (variant: "light" | "dark" = "light") => {
|
||||
try {
|
||||
const resp = await apiFetch(`${API_BASE}/company-settings/logo`);
|
||||
const resp = await apiFetch(
|
||||
`${API_BASE}/company-settings/logo?variant=${variant}`,
|
||||
);
|
||||
if (resp.ok) {
|
||||
const blob = await resp.blob();
|
||||
setLogoUrl((prev) => {
|
||||
if (prev) URL.revokeObjectURL(prev);
|
||||
const url = URL.createObjectURL(blob);
|
||||
logoUrlRef.current = url;
|
||||
return url;
|
||||
});
|
||||
if (variant === "dark") {
|
||||
setLogoUrlDark((prev) => {
|
||||
if (prev) URL.revokeObjectURL(prev);
|
||||
const url = URL.createObjectURL(blob);
|
||||
logoUrlDarkRef.current = url;
|
||||
return url;
|
||||
});
|
||||
} else {
|
||||
setLogoUrl((prev) => {
|
||||
if (prev) URL.revokeObjectURL(prev);
|
||||
const url = URL.createObjectURL(blob);
|
||||
logoUrlRef.current = url;
|
||||
return url;
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// ignore - no logo
|
||||
@@ -183,11 +193,6 @@ export default function CompanySettings() {
|
||||
country: d.country || "",
|
||||
company_id: d.company_id || "",
|
||||
vat_id: d.vat_id || "",
|
||||
quotation_prefix: d.quotation_prefix || "N",
|
||||
default_currency: d.default_currency || "EUR",
|
||||
default_vat_rate: d.default_vat_rate || 21,
|
||||
order_type_code: d.order_type_code || "71",
|
||||
invoice_type_code: d.invoice_type_code || "81",
|
||||
});
|
||||
const cf =
|
||||
Array.isArray(d.custom_fields) && d.custom_fields.length > 0
|
||||
@@ -207,8 +212,17 @@ export default function CompanySettings() {
|
||||
} else {
|
||||
setFieldOrder([...DEFAULT_FIELD_ORDER]);
|
||||
}
|
||||
if (
|
||||
Array.isArray(d.available_currencies) &&
|
||||
d.available_currencies.length > 0
|
||||
) {
|
||||
setAvailableCurrencies(d.available_currencies);
|
||||
}
|
||||
if (d.has_logo) {
|
||||
fetchLogo();
|
||||
fetchLogo("light");
|
||||
}
|
||||
if (d.has_logo_dark) {
|
||||
fetchLogo("dark");
|
||||
}
|
||||
} else {
|
||||
alert.error(result.error || "Nepodařilo se načíst nastavení");
|
||||
@@ -316,10 +330,11 @@ export default function CompanySettings() {
|
||||
fetchBankAccounts();
|
||||
}, [fetchData, fetchBankAccounts]);
|
||||
|
||||
// Cleanup blob URL on unmount
|
||||
// Cleanup blob URLs on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (logoUrlRef.current) URL.revokeObjectURL(logoUrlRef.current);
|
||||
if (logoUrlDarkRef.current) URL.revokeObjectURL(logoUrlDarkRef.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
@@ -351,30 +366,38 @@ export default function CompanySettings() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleLogoUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const handleLogoUpload = async (
|
||||
e: React.ChangeEvent<HTMLInputElement>,
|
||||
variant: "light" | "dark" = "light",
|
||||
) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
setUploadingLogo(true);
|
||||
const setUploading =
|
||||
variant === "dark" ? setUploadingLogoDark : setUploadingLogo;
|
||||
setUploading(true);
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append("logo", file);
|
||||
|
||||
const response = await apiFetch(`${API_BASE}/company-settings/logo`, {
|
||||
method: "POST",
|
||||
body: formData,
|
||||
});
|
||||
const response = await apiFetch(
|
||||
`${API_BASE}/company-settings/logo?variant=${variant}`,
|
||||
{
|
||||
method: "POST",
|
||||
body: formData,
|
||||
},
|
||||
);
|
||||
const result = await response.json();
|
||||
if (result.success) {
|
||||
alert.success(result.message || "Logo bylo nahráno");
|
||||
fetchLogo();
|
||||
fetchLogo(variant);
|
||||
} else {
|
||||
alert.error(result.error || "Nepodařilo se nahrát logo");
|
||||
}
|
||||
} catch {
|
||||
alert.error("Chyba připojení");
|
||||
} finally {
|
||||
setUploadingLogo(false);
|
||||
setUploading(false);
|
||||
e.target.value = "";
|
||||
}
|
||||
};
|
||||
@@ -383,7 +406,7 @@ export default function CompanySettings() {
|
||||
setForm((prev) => ({ ...prev, [field]: value }));
|
||||
};
|
||||
|
||||
if (!hasPermission("offers.settings")) return <Forbidden />;
|
||||
if (!embedded && !hasPermission("settings.manage")) return <Forbidden />;
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
@@ -464,33 +487,33 @@ export default function CompanySettings() {
|
||||
|
||||
return (
|
||||
<div>
|
||||
<motion.div
|
||||
className="admin-page-header"
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.25 }}
|
||||
>
|
||||
<div>
|
||||
<h1 className="admin-page-title">Nastavení firmy</h1>
|
||||
<p className="admin-page-subtitle">
|
||||
Firemní údaje, číslování dokladů a výchozí hodnoty
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
className="admin-btn admin-btn-primary"
|
||||
disabled={saving}
|
||||
{!embedded && (
|
||||
<motion.div
|
||||
className="admin-page-header"
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.25 }}
|
||||
>
|
||||
{saving ? (
|
||||
<>
|
||||
<div className="admin-spinner admin-spinner-sm" />
|
||||
Ukládání...
|
||||
</>
|
||||
) : (
|
||||
"Uložit nastavení"
|
||||
)}
|
||||
</button>
|
||||
</motion.div>
|
||||
<div>
|
||||
<h1 className="admin-page-title">Nastavení firmy</h1>
|
||||
<p className="admin-page-subtitle">Firemní údaje a bankovní účty</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
className="admin-btn admin-btn-primary"
|
||||
disabled={saving}
|
||||
>
|
||||
{saving ? (
|
||||
<>
|
||||
<div className="admin-spinner admin-spinner-sm" />
|
||||
Ukládání...
|
||||
</>
|
||||
) : (
|
||||
"Uložit nastavení"
|
||||
)}
|
||||
</button>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
<div className="offers-settings-grid">
|
||||
{/* Company Info */}
|
||||
@@ -898,10 +921,11 @@ export default function CompanySettings() {
|
||||
}
|
||||
className="admin-form-select"
|
||||
>
|
||||
<option value="CZK">CZK</option>
|
||||
<option value="EUR">EUR</option>
|
||||
<option value="USD">USD</option>
|
||||
<option value="GBP">GBP</option>
|
||||
{availableCurrencies.map((c) => (
|
||||
<option key={c} value={c}>
|
||||
{c}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</FormField>
|
||||
</div>
|
||||
@@ -1055,177 +1079,135 @@ export default function CompanySettings() {
|
||||
<h3 className="admin-card-title">Logo</h3>
|
||||
</div>
|
||||
<div className="admin-card-body">
|
||||
<div className="offers-logo-section">
|
||||
{logoUrl && (
|
||||
<div className="offers-logo-preview">
|
||||
<img src={logoUrl} alt="Logo" />
|
||||
</div>
|
||||
)}
|
||||
<label
|
||||
className="admin-btn admin-btn-secondary"
|
||||
style={{ cursor: "pointer" }}
|
||||
>
|
||||
{uploadingLogo ? (
|
||||
<>
|
||||
<div className="admin-spinner admin-spinner-sm" />
|
||||
Nahrávání...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
>
|
||||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
|
||||
<polyline points="17 8 12 3 7 8" />
|
||||
<line x1="12" y1="3" x2="12" y2="15" />
|
||||
</svg>
|
||||
Nahrát logo
|
||||
</>
|
||||
<div className="admin-form-row">
|
||||
<div className="offers-logo-section">
|
||||
<label
|
||||
className="admin-form-label"
|
||||
style={{ display: "block", marginBottom: 4 }}
|
||||
>
|
||||
Logo (světlý režim)
|
||||
</label>
|
||||
{logoUrl && (
|
||||
<div className="offers-logo-preview">
|
||||
<img src={logoUrl} alt="Logo (světlý režim)" />
|
||||
</div>
|
||||
)}
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
onChange={handleLogoUpload}
|
||||
style={{ display: "none" }}
|
||||
disabled={uploadingLogo}
|
||||
/>
|
||||
</label>
|
||||
<small className="admin-form-hint">
|
||||
PNG, JPEG, GIF nebo WebP, max 5 MB
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* Cislovani dokladu */}
|
||||
<motion.div
|
||||
className="admin-card"
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.25, delay: 0.15 }}
|
||||
>
|
||||
<div className="admin-card-header">
|
||||
<h3 className="admin-card-title">Číslování dokladů</h3>
|
||||
</div>
|
||||
<div className="admin-card-body">
|
||||
<div className="admin-form">
|
||||
<FormField label="Nabídky — prefix">
|
||||
<input
|
||||
type="text"
|
||||
value={form.quotation_prefix}
|
||||
onChange={(e) =>
|
||||
updateField("quotation_prefix", e.target.value)
|
||||
}
|
||||
className="admin-form-input"
|
||||
placeholder="N"
|
||||
style={{ maxWidth: 120 }}
|
||||
/>
|
||||
<small className="admin-form-hint">
|
||||
Formát: ROK/PREFIX/ČÍSLO — ukázka: {new Date().getFullYear()}/
|
||||
{form.quotation_prefix || "N"}/001
|
||||
</small>
|
||||
</FormField>
|
||||
<hr
|
||||
style={{
|
||||
border: "none",
|
||||
borderTop: "1px solid var(--border-color)",
|
||||
margin: "0.75rem 0",
|
||||
}}
|
||||
/>
|
||||
<FormField label="Objednávky a projekty — typový kód">
|
||||
<input
|
||||
type="text"
|
||||
value={form.order_type_code}
|
||||
onChange={(e) =>
|
||||
updateField("order_type_code", e.target.value)
|
||||
}
|
||||
className="admin-form-input"
|
||||
placeholder="71"
|
||||
style={{ maxWidth: 120 }}
|
||||
/>
|
||||
<small className="admin-form-hint">
|
||||
Formát: RRKÓD#### — ukázka: {currentYear}
|
||||
{form.order_type_code || "71"}0001
|
||||
</small>
|
||||
</FormField>
|
||||
<hr
|
||||
style={{
|
||||
border: "none",
|
||||
borderTop: "1px solid var(--border-color)",
|
||||
margin: "0.75rem 0",
|
||||
}}
|
||||
/>
|
||||
<FormField label="Faktury — typový kód">
|
||||
<input
|
||||
type="text"
|
||||
value={form.invoice_type_code}
|
||||
onChange={(e) =>
|
||||
updateField("invoice_type_code", e.target.value)
|
||||
}
|
||||
className="admin-form-input"
|
||||
placeholder="81"
|
||||
style={{ maxWidth: 120 }}
|
||||
/>
|
||||
<small className="admin-form-hint">
|
||||
Formát: RRKÓD#### — ukázka: {currentYear}
|
||||
{form.invoice_type_code || "81"}0001
|
||||
</small>
|
||||
</FormField>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* Default values */}
|
||||
<motion.div
|
||||
className="admin-card"
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.25, delay: 0.15 }}
|
||||
>
|
||||
<div className="admin-card-header">
|
||||
<h3 className="admin-card-title">Výchozí hodnoty</h3>
|
||||
</div>
|
||||
<div className="admin-card-body">
|
||||
<div className="admin-form">
|
||||
<div className="admin-form-row">
|
||||
<FormField label="Výchozí měna">
|
||||
<select
|
||||
value={form.default_currency}
|
||||
onChange={(e) =>
|
||||
updateField("default_currency", e.target.value)
|
||||
}
|
||||
className="admin-form-select"
|
||||
>
|
||||
<option value="EUR">EUR</option>
|
||||
<option value="USD">USD</option>
|
||||
<option value="CZK">CZK</option>
|
||||
<option value="GBP">GBP</option>
|
||||
</select>
|
||||
</FormField>
|
||||
<FormField label="Výchozí sazba DPH (%)">
|
||||
<label
|
||||
className="admin-btn admin-btn-secondary"
|
||||
style={{ cursor: "pointer" }}
|
||||
>
|
||||
{uploadingLogo ? (
|
||||
<>
|
||||
<div className="admin-spinner admin-spinner-sm" />
|
||||
Nahrávání...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
>
|
||||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
|
||||
<polyline points="17 8 12 3 7 8" />
|
||||
<line x1="12" y1="3" x2="12" y2="15" />
|
||||
</svg>
|
||||
Nahrát logo
|
||||
</>
|
||||
)}
|
||||
<input
|
||||
type="number"
|
||||
value={form.default_vat_rate}
|
||||
onChange={(e) =>
|
||||
updateField(
|
||||
"default_vat_rate",
|
||||
parseFloat(e.target.value) || 0,
|
||||
)
|
||||
}
|
||||
className="admin-form-input"
|
||||
step="0.1"
|
||||
type="file"
|
||||
accept="image/*"
|
||||
onChange={(e) => handleLogoUpload(e, "light")}
|
||||
style={{ display: "none" }}
|
||||
disabled={uploadingLogo}
|
||||
/>
|
||||
</FormField>
|
||||
</label>
|
||||
<small className="admin-form-hint">
|
||||
PNG, JPEG, GIF nebo WebP, max 5 MB
|
||||
</small>
|
||||
</div>
|
||||
<div className="offers-logo-section">
|
||||
<label
|
||||
className="admin-form-label"
|
||||
style={{ display: "block", marginBottom: 4 }}
|
||||
>
|
||||
Logo (tmavý režim)
|
||||
</label>
|
||||
{logoUrlDark && (
|
||||
<div className="offers-logo-preview">
|
||||
<img src={logoUrlDark} alt="Logo (tmavý režim)" />
|
||||
</div>
|
||||
)}
|
||||
<label
|
||||
className="admin-btn admin-btn-secondary"
|
||||
style={{ cursor: "pointer" }}
|
||||
>
|
||||
{uploadingLogoDark ? (
|
||||
<>
|
||||
<div className="admin-spinner admin-spinner-sm" />
|
||||
Nahrávání...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
>
|
||||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
|
||||
<polyline points="17 8 12 3 7 8" />
|
||||
<line x1="12" y1="3" x2="12" y2="15" />
|
||||
</svg>
|
||||
Nahrát logo
|
||||
</>
|
||||
)}
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
onChange={(e) => handleLogoUpload(e, "dark")}
|
||||
style={{ display: "none" }}
|
||||
disabled={uploadingLogoDark}
|
||||
/>
|
||||
</label>
|
||||
<small className="admin-form-hint">
|
||||
PNG, JPEG, GIF nebo WebP, max 5 MB
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
{embedded && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.25, delay: 0.2 }}
|
||||
>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
className="admin-btn admin-btn-primary"
|
||||
style={{ width: "100%", marginTop: "1rem" }}
|
||||
disabled={saving}
|
||||
>
|
||||
{saving ? (
|
||||
<>
|
||||
<div className="admin-spinner admin-spinner-sm" />
|
||||
Ukládání...
|
||||
</>
|
||||
) : (
|
||||
"Uložit nastavení firmy"
|
||||
)}
|
||||
</button>
|
||||
</motion.div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user