v1.6.7: add settings.system permission, move numbering/VAT to company tab, rename security tab to roles
- New settings.system permission with migration (idempotent INSERT) - requireAnyPermission helper for route guards accepting multiple perms - Move document numbering + currency/VAT cards from system tab to CompanySettings - Rename security tab to roles, add canManageSystem alongside canManageCompany - TOTP required endpoint and system-info now use settings.system - Roles list now includes user_count - Sidebar Settings link includes settings.system Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "app-ts",
|
"name": "app-ts",
|
||||||
"version": "1.6.6",
|
"version": "1.6.7",
|
||||||
"description": "",
|
"description": "",
|
||||||
"main": "dist/server.js",
|
"main": "dist/server.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
-- Insert the settings.system permission (also available via seed)
|
||||||
|
INSERT INTO `permissions` (`name`, `display_name`, `module`, `description`)
|
||||||
|
VALUES ('settings.system', 'Systémová nastavení', 'settings', 'Spravovat systémová nastavení, 2FA, e-maily, limity')
|
||||||
|
ON DUPLICATE KEY UPDATE `name` = `name`;
|
||||||
@@ -248,7 +248,13 @@ const PERMISSIONS: {
|
|||||||
name: "settings.company",
|
name: "settings.company",
|
||||||
display_name: "Nastavení společnosti",
|
display_name: "Nastavení společnosti",
|
||||||
module: "settings",
|
module: "settings",
|
||||||
description: "Upravovat údaje o společnosti, logo, 2FA",
|
description: "Upravovat údaje o společnosti, logo",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "settings.system",
|
||||||
|
display_name: "Systémová nastavení",
|
||||||
|
module: "settings",
|
||||||
|
description: "Spravovat systémová nastavení, 2FA, e-maily, limity",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "settings.banking",
|
name: "settings.banking",
|
||||||
|
|||||||
@@ -360,6 +360,7 @@ const menuSections: MenuSection[] = [
|
|||||||
"settings.company",
|
"settings.company",
|
||||||
"settings.banking",
|
"settings.banking",
|
||||||
"settings.roles",
|
"settings.roles",
|
||||||
|
"settings.system",
|
||||||
"settings.templates",
|
"settings.templates",
|
||||||
],
|
],
|
||||||
icon: (
|
icon: (
|
||||||
|
|||||||
@@ -48,6 +48,16 @@ interface CompanyForm {
|
|||||||
country: string;
|
country: string;
|
||||||
company_id: string;
|
company_id: string;
|
||||||
vat_id: string;
|
vat_id: string;
|
||||||
|
offer_number_pattern: string;
|
||||||
|
order_number_pattern: string;
|
||||||
|
invoice_number_pattern: string;
|
||||||
|
quotation_prefix: string;
|
||||||
|
order_type_code: string;
|
||||||
|
invoice_type_code: string;
|
||||||
|
default_currency: string;
|
||||||
|
default_vat_rate: number;
|
||||||
|
available_currencies: string[];
|
||||||
|
available_vat_rates: number[];
|
||||||
}
|
}
|
||||||
|
|
||||||
interface BankForm {
|
interface BankForm {
|
||||||
@@ -65,6 +75,8 @@ export default function CompanySettings({
|
|||||||
}: { embedded?: boolean } = {}) {
|
}: { embedded?: boolean } = {}) {
|
||||||
const alert = useAlert();
|
const alert = useAlert();
|
||||||
const { hasPermission } = useAuth();
|
const { hasPermission } = useAuth();
|
||||||
|
const canEditCompany = hasPermission("settings.company");
|
||||||
|
const canManageBanking = hasPermission("settings.banking");
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [uploadingLogo, setUploadingLogo] = useState(false);
|
const [uploadingLogo, setUploadingLogo] = useState(false);
|
||||||
@@ -81,17 +93,21 @@ export default function CompanySettings({
|
|||||||
country: "",
|
country: "",
|
||||||
company_id: "",
|
company_id: "",
|
||||||
vat_id: "",
|
vat_id: "",
|
||||||
|
offer_number_pattern: "",
|
||||||
|
order_number_pattern: "",
|
||||||
|
invoice_number_pattern: "",
|
||||||
|
quotation_prefix: "",
|
||||||
|
order_type_code: "",
|
||||||
|
invoice_type_code: "",
|
||||||
|
default_currency: "CZK",
|
||||||
|
default_vat_rate: 21,
|
||||||
|
available_currencies: ["CZK", "EUR", "USD", "GBP"],
|
||||||
|
available_vat_rates: [0, 10, 12, 15, 21],
|
||||||
});
|
});
|
||||||
const [customFields, setCustomFields] = useState<CustomField[]>([]);
|
const [customFields, setCustomFields] = useState<CustomField[]>([]);
|
||||||
const [fieldOrder, setFieldOrder] = useState<string[]>([
|
const [fieldOrder, setFieldOrder] = useState<string[]>([
|
||||||
...DEFAULT_FIELD_ORDER,
|
...DEFAULT_FIELD_ORDER,
|
||||||
]);
|
]);
|
||||||
const [availableCurrencies, setAvailableCurrencies] = useState<string[]>([
|
|
||||||
"CZK",
|
|
||||||
"EUR",
|
|
||||||
"USD",
|
|
||||||
"GBP",
|
|
||||||
]);
|
|
||||||
const [bankSaving, setBankSaving] = useState(false);
|
const [bankSaving, setBankSaving] = useState(false);
|
||||||
const [editingBank, setEditingBank] = useState<number | null>(null);
|
const [editingBank, setEditingBank] = useState<number | null>(null);
|
||||||
const [bankDeleteConfirm, setBankDeleteConfirm] = useState<{
|
const [bankDeleteConfirm, setBankDeleteConfirm] = useState<{
|
||||||
@@ -202,6 +218,24 @@ export default function CompanySettings({
|
|||||||
country: settingsData.country || "",
|
country: settingsData.country || "",
|
||||||
company_id: settingsData.company_id || "",
|
company_id: settingsData.company_id || "",
|
||||||
vat_id: settingsData.vat_id || "",
|
vat_id: settingsData.vat_id || "",
|
||||||
|
offer_number_pattern: settingsData.offer_number_pattern || "",
|
||||||
|
order_number_pattern: settingsData.order_number_pattern || "",
|
||||||
|
invoice_number_pattern: settingsData.invoice_number_pattern || "",
|
||||||
|
quotation_prefix: settingsData.quotation_prefix || "",
|
||||||
|
order_type_code: settingsData.order_type_code || "",
|
||||||
|
invoice_type_code: settingsData.invoice_type_code || "",
|
||||||
|
default_currency: settingsData.default_currency || "CZK",
|
||||||
|
default_vat_rate: settingsData.default_vat_rate ?? 21,
|
||||||
|
available_currencies:
|
||||||
|
Array.isArray(settingsData.available_currencies) &&
|
||||||
|
settingsData.available_currencies.length > 0
|
||||||
|
? settingsData.available_currencies
|
||||||
|
: ["CZK", "EUR", "USD", "GBP"],
|
||||||
|
available_vat_rates:
|
||||||
|
Array.isArray(settingsData.available_vat_rates) &&
|
||||||
|
settingsData.available_vat_rates.length > 0
|
||||||
|
? settingsData.available_vat_rates
|
||||||
|
: [0, 10, 12, 15, 21],
|
||||||
});
|
});
|
||||||
const cf: CustomField[] =
|
const cf: CustomField[] =
|
||||||
Array.isArray(settingsData.custom_fields) &&
|
Array.isArray(settingsData.custom_fields) &&
|
||||||
@@ -221,12 +255,6 @@ export default function CompanySettings({
|
|||||||
} else {
|
} else {
|
||||||
setFieldOrder([...DEFAULT_FIELD_ORDER]);
|
setFieldOrder([...DEFAULT_FIELD_ORDER]);
|
||||||
}
|
}
|
||||||
if (
|
|
||||||
Array.isArray(settingsData.available_currencies) &&
|
|
||||||
settingsData.available_currencies.length > 0
|
|
||||||
) {
|
|
||||||
setAvailableCurrencies(settingsData.available_currencies);
|
|
||||||
}
|
|
||||||
if (settingsData.has_logo) {
|
if (settingsData.has_logo) {
|
||||||
fetchLogo("light");
|
fetchLogo("light");
|
||||||
}
|
}
|
||||||
@@ -406,7 +434,7 @@ export default function CompanySettings({
|
|||||||
setForm((prev) => ({ ...prev, [field]: value }));
|
setForm((prev) => ({ ...prev, [field]: value }));
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!embedded && !hasPermission("settings.company")) return <Forbidden />;
|
if (!embedded && !canEditCompany) return <Forbidden />;
|
||||||
|
|
||||||
if (settingsLoading) {
|
if (settingsLoading) {
|
||||||
return (
|
return (
|
||||||
@@ -482,6 +510,7 @@ export default function CompanySettings({
|
|||||||
|
|
||||||
<div className="admin-settings-grid">
|
<div className="admin-settings-grid">
|
||||||
{/* Company Info */}
|
{/* Company Info */}
|
||||||
|
{(canEditCompany || !embedded) && (
|
||||||
<motion.div
|
<motion.div
|
||||||
className="admin-card"
|
className="admin-card"
|
||||||
initial={{ opacity: 0, y: 12 }}
|
initial={{ opacity: 0, y: 12 }}
|
||||||
@@ -497,7 +526,9 @@ export default function CompanySettings({
|
|||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={form.company_name}
|
value={form.company_name}
|
||||||
onChange={(e) => updateField("company_name", e.target.value)}
|
onChange={(e) =>
|
||||||
|
updateField("company_name", e.target.value)
|
||||||
|
}
|
||||||
className="admin-form-input"
|
className="admin-form-input"
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
@@ -524,7 +555,9 @@ export default function CompanySettings({
|
|||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={form.postal_code}
|
value={form.postal_code}
|
||||||
onChange={(e) => updateField("postal_code", e.target.value)}
|
onChange={(e) =>
|
||||||
|
updateField("postal_code", e.target.value)
|
||||||
|
}
|
||||||
className="admin-form-input"
|
className="admin-form-input"
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
@@ -542,7 +575,9 @@ export default function CompanySettings({
|
|||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={form.company_id}
|
value={form.company_id}
|
||||||
onChange={(e) => updateField("company_id", e.target.value)}
|
onChange={(e) =>
|
||||||
|
updateField("company_id", e.target.value)
|
||||||
|
}
|
||||||
className="admin-form-input"
|
className="admin-form-input"
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
@@ -705,8 +740,10 @@ export default function CompanySettings({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Bank Accounts */}
|
{/* Bank Accounts */}
|
||||||
|
{(canManageBanking || !embedded) && (
|
||||||
<motion.div
|
<motion.div
|
||||||
className="admin-card"
|
className="admin-card"
|
||||||
initial={{ opacity: 0, y: 12 }}
|
initial={{ opacity: 0, y: 12 }}
|
||||||
@@ -754,7 +791,9 @@ export default function CompanySettings({
|
|||||||
>
|
>
|
||||||
<td>{acc.account_name}</td>
|
<td>{acc.account_name}</td>
|
||||||
<td>{acc.bank_name}</td>
|
<td>{acc.bank_name}</td>
|
||||||
<td className="admin-mono">{acc.account_number}</td>
|
<td className="admin-mono">
|
||||||
|
{acc.account_number}
|
||||||
|
</td>
|
||||||
<td className="admin-mono">{acc.iban}</td>
|
<td className="admin-mono">{acc.iban}</td>
|
||||||
<td className="admin-mono">{acc.bic}</td>
|
<td className="admin-mono">{acc.bic}</td>
|
||||||
<td>{acc.currency}</td>
|
<td>{acc.currency}</td>
|
||||||
@@ -825,7 +864,9 @@ export default function CompanySettings({
|
|||||||
className="text-secondary"
|
className="text-secondary"
|
||||||
style={{ margin: "0 0 12px", fontSize: "0.9rem" }}
|
style={{ margin: "0 0 12px", fontSize: "0.9rem" }}
|
||||||
>
|
>
|
||||||
{editingBank !== null ? "Upravit účet" : "Přidat nový účet"}
|
{editingBank !== null
|
||||||
|
? "Upravit účet"
|
||||||
|
: "Přidat nový účet"}
|
||||||
</h4>
|
</h4>
|
||||||
<div className="admin-form">
|
<div className="admin-form">
|
||||||
<div className="admin-form-row">
|
<div className="admin-form-row">
|
||||||
@@ -884,7 +925,7 @@ export default function CompanySettings({
|
|||||||
}
|
}
|
||||||
className="admin-form-select"
|
className="admin-form-select"
|
||||||
>
|
>
|
||||||
{availableCurrencies.map((c) => (
|
{form.available_currencies.map((c) => (
|
||||||
<option key={c} value={c}>
|
<option key={c} value={c}>
|
||||||
{c}
|
{c}
|
||||||
</option>
|
</option>
|
||||||
@@ -898,7 +939,10 @@ export default function CompanySettings({
|
|||||||
type="text"
|
type="text"
|
||||||
value={bankForm.iban}
|
value={bankForm.iban}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
setBankForm((f) => ({ ...f, iban: e.target.value }))
|
setBankForm((f) => ({
|
||||||
|
...f,
|
||||||
|
iban: e.target.value,
|
||||||
|
}))
|
||||||
}
|
}
|
||||||
className="admin-form-input"
|
className="admin-form-input"
|
||||||
placeholder="CZ65 0800 0000 1920 0014 5399"
|
placeholder="CZ65 0800 0000 1920 0014 5399"
|
||||||
@@ -909,7 +953,10 @@ export default function CompanySettings({
|
|||||||
type="text"
|
type="text"
|
||||||
value={bankForm.bic}
|
value={bankForm.bic}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
setBankForm((f) => ({ ...f, bic: e.target.value }))
|
setBankForm((f) => ({
|
||||||
|
...f,
|
||||||
|
bic: e.target.value,
|
||||||
|
}))
|
||||||
}
|
}
|
||||||
className="admin-form-input"
|
className="admin-form-input"
|
||||||
placeholder="GIBACZPX"
|
placeholder="GIBACZPX"
|
||||||
@@ -959,8 +1006,10 @@ export default function CompanySettings({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* PDF Field Order */}
|
{/* PDF Field Order */}
|
||||||
|
{(canEditCompany || !embedded) && (
|
||||||
<motion.div
|
<motion.div
|
||||||
className="admin-card"
|
className="admin-card"
|
||||||
initial={{ opacity: 0, y: 12 }}
|
initial={{ opacity: 0, y: 12 }}
|
||||||
@@ -1030,13 +1079,247 @@ export default function CompanySettings({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Logo */}
|
{/* Document Numbering */}
|
||||||
|
{(canEditCompany || !embedded) && (
|
||||||
|
<motion.div
|
||||||
|
className="admin-card"
|
||||||
|
initial={{ opacity: 0, y: 12 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ duration: 0.25, delay: 0.1 }}
|
||||||
|
>
|
||||||
|
<div className="admin-card-header">
|
||||||
|
<h3 className="admin-card-title">Číslování dokladů</h3>
|
||||||
|
</div>
|
||||||
|
<div className="admin-card-body">
|
||||||
|
<div className="admin-form">
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: "0.75rem",
|
||||||
|
background: "var(--bg-tertiary)",
|
||||||
|
borderRadius: 8,
|
||||||
|
fontSize: "0.8rem",
|
||||||
|
color: "var(--text-secondary)",
|
||||||
|
marginBottom: "1rem",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<strong>Dostupné zástupné znaky:</strong>{" "}
|
||||||
|
<code>{"{YYYY}"}</code> rok, <code>{"{YY}"}</code> rok (2
|
||||||
|
číslice), <code>{"{PREFIX}"}</code> prefix nabídky,{" "}
|
||||||
|
<code>{"{CODE}"}</code> typový kód, <code>{"{NNN}"}</code>{" "}
|
||||||
|
pořadí (3 číslice), <code>{"{NNNN}"}</code> pořadí (4
|
||||||
|
číslice), <code>{"{NNNNN}"}</code> pořadí (5 číslic)
|
||||||
|
</div>
|
||||||
|
{[
|
||||||
|
{
|
||||||
|
label: "Nabídky",
|
||||||
|
patternKey: "offer_number_pattern" as const,
|
||||||
|
defaultPattern: "{YYYY}/{PREFIX}/{NNN}",
|
||||||
|
prefixKey: "quotation_prefix" as const,
|
||||||
|
prefixLabel: "Prefix",
|
||||||
|
codeKey: null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Objednávky a projekty",
|
||||||
|
patternKey: "order_number_pattern" as const,
|
||||||
|
defaultPattern: "{YY}{CODE}{NNNN}",
|
||||||
|
prefixKey: null,
|
||||||
|
codeKey: "order_type_code" as const,
|
||||||
|
codeLabel: "Typový kód",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Faktury",
|
||||||
|
patternKey: "invoice_number_pattern" as const,
|
||||||
|
defaultPattern: "{YY}{CODE}{NNNN}",
|
||||||
|
prefixKey: null,
|
||||||
|
codeKey: "invoice_type_code" as const,
|
||||||
|
codeLabel: "Typový kód",
|
||||||
|
},
|
||||||
|
].map((cfg, idx) => {
|
||||||
|
const pattern = form[cfg.patternKey] || cfg.defaultPattern;
|
||||||
|
const yyyy = String(new Date().getFullYear());
|
||||||
|
const yy = yyyy.slice(-2);
|
||||||
|
const prefix = cfg.prefixKey
|
||||||
|
? form[cfg.prefixKey] || "NA"
|
||||||
|
: "";
|
||||||
|
const code = cfg.codeKey ? form[cfg.codeKey] || "" : "";
|
||||||
|
const preview = pattern.replace(
|
||||||
|
/\{(\w+)\}/g,
|
||||||
|
(m: string, k: string) => {
|
||||||
|
if (k === "YYYY") return yyyy;
|
||||||
|
if (k === "YY") return yy;
|
||||||
|
if (k === "PREFIX") return prefix;
|
||||||
|
if (k === "CODE") return code;
|
||||||
|
if (/^N+$/.test(k)) return "1".padStart(k.length, "0");
|
||||||
|
return m;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<div key={cfg.patternKey}>
|
||||||
|
{idx > 0 && (
|
||||||
|
<hr
|
||||||
|
style={{
|
||||||
|
border: "none",
|
||||||
|
borderTop: "1px solid var(--border-color)",
|
||||||
|
margin: "1rem 0",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<div
|
||||||
|
className="fw-600 text-md"
|
||||||
|
style={{ marginBottom: "0.5rem" }}
|
||||||
|
>
|
||||||
|
{cfg.label}
|
||||||
|
</div>
|
||||||
|
<div className="admin-form-row">
|
||||||
|
<FormField label="Formát">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={form[cfg.patternKey]}
|
||||||
|
onChange={(e) =>
|
||||||
|
updateField(cfg.patternKey, e.target.value)
|
||||||
|
}
|
||||||
|
className="admin-form-input"
|
||||||
|
placeholder={cfg.defaultPattern}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
{cfg.prefixKey && (
|
||||||
|
<FormField label={cfg.prefixLabel || "Prefix"}>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={form[cfg.prefixKey]}
|
||||||
|
onChange={(e) =>
|
||||||
|
updateField(cfg.prefixKey, e.target.value)
|
||||||
|
}
|
||||||
|
className="admin-form-input"
|
||||||
|
style={{ maxWidth: 100 }}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
)}
|
||||||
|
{cfg.codeKey && (
|
||||||
|
<FormField label={cfg.codeLabel || "Kód"}>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={form[cfg.codeKey]}
|
||||||
|
onChange={(e) =>
|
||||||
|
updateField(cfg.codeKey, e.target.value)
|
||||||
|
}
|
||||||
|
className="admin-form-input"
|
||||||
|
style={{ maxWidth: 100 }}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<small
|
||||||
|
style={{
|
||||||
|
color: "var(--text-tertiary)",
|
||||||
|
fontSize: "0.8rem",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Ukázka:{" "}
|
||||||
|
<strong style={{ color: "var(--text-primary)" }}>
|
||||||
|
{preview}
|
||||||
|
</strong>
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Currency & VAT */}
|
||||||
|
{(canEditCompany || !embedded) && (
|
||||||
<motion.div
|
<motion.div
|
||||||
className="admin-card"
|
className="admin-card"
|
||||||
initial={{ opacity: 0, y: 12 }}
|
initial={{ opacity: 0, y: 12 }}
|
||||||
animate={{ opacity: 1, y: 0 }}
|
animate={{ opacity: 1, y: 0 }}
|
||||||
transition={{ duration: 0.25, delay: 0.12 }}
|
transition={{ duration: 0.25, delay: 0.12 }}
|
||||||
|
>
|
||||||
|
<div className="admin-card-header">
|
||||||
|
<h3 className="admin-card-title">Měna a DPH</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-input"
|
||||||
|
>
|
||||||
|
{form.available_currencies.map((c) => (
|
||||||
|
<option key={c} value={c}>
|
||||||
|
{c}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</FormField>
|
||||||
|
<FormField label="Výchozí sazba DPH (%)">
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={form.default_vat_rate}
|
||||||
|
onChange={(e) =>
|
||||||
|
updateField("default_vat_rate", Number(e.target.value))
|
||||||
|
}
|
||||||
|
className="admin-form-input"
|
||||||
|
min={0}
|
||||||
|
step={1}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
</div>
|
||||||
|
<div className="admin-form-row">
|
||||||
|
<FormField label="Dostupné měny">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={form.available_currencies.join(", ")}
|
||||||
|
onChange={(e) =>
|
||||||
|
setForm((prev) => ({
|
||||||
|
...prev,
|
||||||
|
available_currencies: e.target.value
|
||||||
|
.split(",")
|
||||||
|
.map((s) => s.trim())
|
||||||
|
.filter(Boolean),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
className="admin-form-input"
|
||||||
|
placeholder="CZK, EUR, USD"
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
<FormField label="Dostupné sazby DPH (%)">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={form.available_vat_rates.join(", ")}
|
||||||
|
onChange={(e) =>
|
||||||
|
setForm((prev) => ({
|
||||||
|
...prev,
|
||||||
|
available_vat_rates: e.target.value
|
||||||
|
.split(",")
|
||||||
|
.map((s) => Number(s.trim()))
|
||||||
|
.filter((n) => !isNaN(n)),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
className="admin-form-input"
|
||||||
|
placeholder="0, 12, 21"
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Logo */}
|
||||||
|
{(canEditCompany || !embedded) && (
|
||||||
|
<motion.div
|
||||||
|
className="admin-card"
|
||||||
|
initial={{ opacity: 0, y: 12 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ duration: 0.25, delay: 0.14 }}
|
||||||
>
|
>
|
||||||
<div className="admin-card-header">
|
<div className="admin-card-header">
|
||||||
<h3 className="admin-card-title">Logo</h3>
|
<h3 className="admin-card-title">Logo</h3>
|
||||||
@@ -1146,9 +1429,10 @@ export default function CompanySettings({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{embedded && (
|
{embedded && canEditCompany && (
|
||||||
<motion.div
|
<motion.div
|
||||||
initial={{ opacity: 0, y: 12 }}
|
initial={{ opacity: 0, y: 12 }}
|
||||||
animate={{ opacity: 1, y: 0 }}
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
|||||||
@@ -71,6 +71,7 @@ interface Role {
|
|||||||
description: string | null;
|
description: string | null;
|
||||||
permissions: Permission[];
|
permissions: Permission[];
|
||||||
role_permissions?: unknown[];
|
role_permissions?: unknown[];
|
||||||
|
user_count?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface RoleForm {
|
interface RoleForm {
|
||||||
@@ -109,38 +110,44 @@ export default function Settings() {
|
|||||||
const { hasPermission } = useAuth();
|
const { hasPermission } = useAuth();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
const canManage = hasPermission("settings.roles");
|
const canManageRoles = hasPermission("settings.roles");
|
||||||
|
const canManageCompany = hasPermission("settings.company");
|
||||||
|
const canManageSystem = hasPermission("settings.system");
|
||||||
|
const canManageBanking = hasPermission("settings.banking");
|
||||||
|
const canAccessSettings =
|
||||||
|
canManageRoles || canManageCompany || canManageSystem || canManageBanking;
|
||||||
|
|
||||||
// ── TanStack Query: roles, permissions, users ──
|
const availableTabs = useMemo(() => {
|
||||||
|
const tabs: Array<"roles" | "system" | "firma"> = [];
|
||||||
|
if (canManageRoles) tabs.push("roles");
|
||||||
|
if (canManageSystem) tabs.push("system");
|
||||||
|
if (canManageCompany || canManageBanking) tabs.push("firma");
|
||||||
|
return tabs;
|
||||||
|
}, [canManageRoles, canManageCompany, canManageSystem, canManageBanking]);
|
||||||
|
|
||||||
|
// ── TanStack Query: roles, permissions ──
|
||||||
const { data: rolesData, isPending: rolesLoading } = useQuery({
|
const { data: rolesData, isPending: rolesLoading } = useQuery({
|
||||||
queryKey: ["settings", "roles"],
|
queryKey: ["settings", "roles"],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const [rolesRes, permsRes, usersRes] = await Promise.all([
|
const [rolesRes, permsRes] = await Promise.all([
|
||||||
apiFetch(`${API_BASE}/roles`),
|
apiFetch(`${API_BASE}/roles`),
|
||||||
apiFetch(`${API_BASE}/roles/permissions`),
|
apiFetch(`${API_BASE}/roles/permissions`),
|
||||||
apiFetch(`${API_BASE}/users`),
|
|
||||||
]);
|
]);
|
||||||
const rolesResult = await rolesRes.json();
|
const rolesResult = await rolesRes.json();
|
||||||
const permsResult = await permsRes.json();
|
const permsResult = await permsRes.json();
|
||||||
const usersResult = await usersRes.json();
|
|
||||||
if (!rolesResult.success)
|
if (!rolesResult.success)
|
||||||
throw new Error(rolesResult.error || "Nepodařilo se načíst role");
|
throw new Error(rolesResult.error || "Nepodařilo se načíst role");
|
||||||
if (!permsResult.success)
|
if (!permsResult.success)
|
||||||
throw new Error(permsResult.error || "Nepodařilo se načíst oprávnění");
|
throw new Error(permsResult.error || "Nepodařilo se načíst oprávnění");
|
||||||
if (!usersResult.success)
|
|
||||||
throw new Error(usersResult.error || "Nepodařilo se načíst uživatele");
|
|
||||||
return {
|
return {
|
||||||
roles: Array.isArray(rolesResult.data) ? rolesResult.data : [],
|
roles: Array.isArray(rolesResult.data) ? rolesResult.data : [],
|
||||||
permissions: Array.isArray(permsResult.data) ? permsResult.data : [],
|
permissions: Array.isArray(permsResult.data) ? permsResult.data : [],
|
||||||
users: Array.isArray(usersResult.data) ? usersResult.data : [],
|
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
enabled: canManage,
|
enabled: canManageRoles,
|
||||||
});
|
});
|
||||||
|
|
||||||
const roles = rolesData?.roles ?? [];
|
const roles = rolesData?.roles ?? [];
|
||||||
const users = rolesData?.users ?? [];
|
|
||||||
|
|
||||||
// Group permissions by module
|
// Group permissions by module
|
||||||
const permissionGroups = useMemo<Record<string, Permission[]>>(() => {
|
const permissionGroups = useMemo<Record<string, Permission[]>>(() => {
|
||||||
const perms: Permission[] = rolesData?.permissions ?? [];
|
const perms: Permission[] = rolesData?.permissions ?? [];
|
||||||
@@ -158,27 +165,32 @@ export default function Settings() {
|
|||||||
useQuery(require2FAOptions());
|
useQuery(require2FAOptions());
|
||||||
const require2FA = totpData?.require_2fa ?? false;
|
const require2FA = totpData?.require_2fa ?? false;
|
||||||
|
|
||||||
// ── TanStack Query: system settings (lazy, only on system/security tab) ──
|
// ── TanStack Query: system settings (lazy, only on system/roles tab) ──
|
||||||
const [searchParams, setSearchParams] = useSearchParams();
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
const tabParam = searchParams.get("tab");
|
const tabParam = searchParams.get("tab");
|
||||||
|
const defaultTab = availableTabs[0] ?? "roles";
|
||||||
const activeTab = (
|
const activeTab = (
|
||||||
tabParam === "system"
|
tabParam === "roles" && availableTabs.includes("roles")
|
||||||
|
? "roles"
|
||||||
|
: tabParam === "system" && availableTabs.includes("system")
|
||||||
? "system"
|
? "system"
|
||||||
: tabParam === "firma"
|
: tabParam === "firma" && availableTabs.includes("firma")
|
||||||
? "firma"
|
? "firma"
|
||||||
: "security"
|
: defaultTab
|
||||||
) as "security" | "system" | "firma";
|
) as "roles" | "system" | "firma";
|
||||||
const setActiveTab = (tab: "security" | "system" | "firma") =>
|
const setActiveTab = (tab: "roles" | "system" | "firma") =>
|
||||||
setSearchParams({ tab }, { replace: true });
|
setSearchParams({ tab }, { replace: true });
|
||||||
|
|
||||||
const { data: sysSettingsData, isPending: sysSettingsLoading } = useQuery({
|
const { data: sysSettingsData, isPending: sysSettingsLoading } = useQuery({
|
||||||
...companySettingsOptions(),
|
...companySettingsOptions(),
|
||||||
enabled: canManage && (activeTab === "system" || activeTab === "security"),
|
enabled:
|
||||||
|
(canManageRoles || canManageSystem) &&
|
||||||
|
(activeTab === "system" || activeTab === "roles"),
|
||||||
});
|
});
|
||||||
|
|
||||||
const { data: systemInfo } = useQuery({
|
const { data: systemInfo } = useQuery({
|
||||||
...systemInfoOptions(),
|
...systemInfoOptions(),
|
||||||
enabled: canManage && activeTab === "system",
|
enabled: canManageSystem && activeTab === "system",
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── Local state ──
|
// ── Local state ──
|
||||||
@@ -247,7 +259,7 @@ export default function Settings() {
|
|||||||
useModalLock(showModal);
|
useModalLock(showModal);
|
||||||
|
|
||||||
// ── Early return after all hooks ──
|
// ── Early return after all hooks ──
|
||||||
if (!canManage) {
|
if (!canAccessSettings) {
|
||||||
return <Navigate to="/" replace />;
|
return <Navigate to="/" replace />;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -453,7 +465,7 @@ export default function Settings() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if (rolesLoading) {
|
if (canManageRoles && rolesLoading) {
|
||||||
return (
|
return (
|
||||||
<Skeleton
|
<Skeleton
|
||||||
name="settings"
|
name="settings"
|
||||||
@@ -516,36 +528,42 @@ export default function Settings() {
|
|||||||
? "Systémová nastavení"
|
? "Systémová nastavení"
|
||||||
: activeTab === "firma"
|
: activeTab === "firma"
|
||||||
? "Informace o firmě"
|
? "Informace o firmě"
|
||||||
: "Zabezpečení a správa rolí"}
|
: "Role"}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
{canManage && (
|
{availableTabs.length > 0 && (
|
||||||
<div style={{ display: "flex", gap: "0.5rem", marginBottom: "1.5rem" }}>
|
<div style={{ display: "flex", gap: "0.5rem", marginBottom: "1.5rem" }}>
|
||||||
|
{availableTabs.includes("roles") && (
|
||||||
<button
|
<button
|
||||||
className={`admin-btn admin-btn-sm ${activeTab === "security" ? "admin-btn-primary" : "admin-btn-secondary"}`}
|
className={`admin-btn admin-btn-sm ${activeTab === "roles" ? "admin-btn-primary" : "admin-btn-secondary"}`}
|
||||||
onClick={() => setActiveTab("security")}
|
onClick={() => setActiveTab("roles")}
|
||||||
>
|
>
|
||||||
Zabezpečení a role
|
Role
|
||||||
</button>
|
</button>
|
||||||
|
)}
|
||||||
|
{availableTabs.includes("system") && (
|
||||||
<button
|
<button
|
||||||
className={`admin-btn admin-btn-sm ${activeTab === "system" ? "admin-btn-primary" : "admin-btn-secondary"}`}
|
className={`admin-btn admin-btn-sm ${activeTab === "system" ? "admin-btn-primary" : "admin-btn-secondary"}`}
|
||||||
onClick={() => setActiveTab("system")}
|
onClick={() => setActiveTab("system")}
|
||||||
>
|
>
|
||||||
Systémová nastavení
|
Systémová nastavení
|
||||||
</button>
|
</button>
|
||||||
|
)}
|
||||||
|
{availableTabs.includes("firma") && (
|
||||||
<button
|
<button
|
||||||
className={`admin-btn admin-btn-sm ${activeTab === "firma" ? "admin-btn-primary" : "admin-btn-secondary"}`}
|
className={`admin-btn admin-btn-sm ${activeTab === "firma" ? "admin-btn-primary" : "admin-btn-secondary"}`}
|
||||||
onClick={() => setActiveTab("firma")}
|
onClick={() => setActiveTab("firma")}
|
||||||
>
|
>
|
||||||
Firma
|
Firma
|
||||||
</button>
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Security Settings */}
|
{/* Security Settings */}
|
||||||
{activeTab === "security" && canManage && (
|
{activeTab === "system" && canManageSystem && (
|
||||||
<motion.div
|
<motion.div
|
||||||
className="admin-card"
|
className="admin-card"
|
||||||
initial={{ opacity: 0, y: 12 }}
|
initial={{ opacity: 0, y: 12 }}
|
||||||
@@ -629,7 +647,7 @@ export default function Settings() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Login Security */}
|
{/* Login Security */}
|
||||||
{activeTab === "security" && canManage && (
|
{activeTab === "system" && canManageSystem && (
|
||||||
<motion.div
|
<motion.div
|
||||||
className="admin-card"
|
className="admin-card"
|
||||||
initial={{ opacity: 0, y: 12 }}
|
initial={{ opacity: 0, y: 12 }}
|
||||||
@@ -686,7 +704,7 @@ export default function Settings() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Roles Table */}
|
{/* Roles Table */}
|
||||||
{activeTab === "security" && canManage && (
|
{activeTab === "roles" && (
|
||||||
<motion.div
|
<motion.div
|
||||||
className="admin-card"
|
className="admin-card"
|
||||||
initial={{ opacity: 0, y: 12 }}
|
initial={{ opacity: 0, y: 12 }}
|
||||||
@@ -765,11 +783,7 @@ export default function Settings() {
|
|||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<span className="admin-badge admin-badge-secondary">
|
<span className="admin-badge admin-badge-secondary">
|
||||||
{
|
{role.user_count ?? 0}
|
||||||
users.filter(
|
|
||||||
(u: { role_id: number }) => u.role_id === role.id,
|
|
||||||
).length
|
|
||||||
}
|
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
@@ -799,27 +813,16 @@ export default function Settings() {
|
|||||||
}
|
}
|
||||||
className="admin-btn-icon danger"
|
className="admin-btn-icon danger"
|
||||||
title={
|
title={
|
||||||
users.filter(
|
(role.user_count ?? 0) > 0
|
||||||
(u: { role_id: number }) =>
|
|
||||||
u.role_id === role.id,
|
|
||||||
).length > 0
|
|
||||||
? "Nelze smazat roli s přiřazenými uživateli"
|
? "Nelze smazat roli s přiřazenými uživateli"
|
||||||
: "Smazat"
|
: "Smazat"
|
||||||
}
|
}
|
||||||
aria-label={
|
aria-label={
|
||||||
users.filter(
|
(role.user_count ?? 0) > 0
|
||||||
(u: { role_id: number }) =>
|
|
||||||
u.role_id === role.id,
|
|
||||||
).length > 0
|
|
||||||
? "Nelze smazat roli s přiřazenými uživateli"
|
? "Nelze smazat roli s přiřazenými uživateli"
|
||||||
: "Smazat"
|
: "Smazat"
|
||||||
}
|
}
|
||||||
disabled={
|
disabled={(role.user_count ?? 0) > 0}
|
||||||
users.filter(
|
|
||||||
(u: { role_id: number }) =>
|
|
||||||
u.role_id === role.id,
|
|
||||||
).length > 0
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
width="16"
|
width="16"
|
||||||
@@ -846,7 +849,7 @@ export default function Settings() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* System Settings Tab */}
|
{/* System Settings Tab */}
|
||||||
{activeTab === "system" && canManage && (
|
{activeTab === "system" && (
|
||||||
<>
|
<>
|
||||||
{sysSettingsLoading && !sysFormInitialized ? (
|
{sysSettingsLoading && !sysFormInitialized ? (
|
||||||
<Skeleton
|
<Skeleton
|
||||||
@@ -1052,260 +1055,7 @@ export default function Settings() {
|
|||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
{/* Section 5: Číslování dokladů */}
|
{/* Section 5: Informace o aplikaci */}
|
||||||
<motion.div
|
|
||||||
className="admin-card"
|
|
||||||
initial={{ opacity: 0, y: 12 }}
|
|
||||||
animate={{ opacity: 1, y: 0 }}
|
|
||||||
transition={{ duration: 0.25, delay: 0.28 }}
|
|
||||||
>
|
|
||||||
<div className="admin-card-header">
|
|
||||||
<h2 className="admin-card-title">Číslování dokladů</h2>
|
|
||||||
</div>
|
|
||||||
<div className="admin-card-body">
|
|
||||||
<div className="admin-form">
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
padding: "0.75rem",
|
|
||||||
background: "var(--bg-tertiary)",
|
|
||||||
borderRadius: 8,
|
|
||||||
fontSize: "0.8rem",
|
|
||||||
color: "var(--text-secondary)",
|
|
||||||
marginBottom: "1rem",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<strong>Dostupné zástupné znaky:</strong>{" "}
|
|
||||||
<code>{"{YYYY}"}</code> rok, <code>{"{YY}"}</code> rok (2
|
|
||||||
číslice), <code>{"{PREFIX}"}</code> prefix nabídky,{" "}
|
|
||||||
<code>{"{CODE}"}</code> typový kód, <code>{"{NNN}"}</code>{" "}
|
|
||||||
pořadí (3 číslice), <code>{"{NNNN}"}</code> pořadí (4
|
|
||||||
číslice), <code>{"{NNNNN}"}</code> pořadí (5 číslic)
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{[
|
|
||||||
{
|
|
||||||
label: "Nabídky",
|
|
||||||
patternKey: "offer_number_pattern" as const,
|
|
||||||
defaultPattern: "{YYYY}/{PREFIX}/{NNN}",
|
|
||||||
prefixKey: "quotation_prefix" as const,
|
|
||||||
prefixLabel: "Prefix",
|
|
||||||
codeKey: null,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "Objednávky a projekty",
|
|
||||||
patternKey: "order_number_pattern" as const,
|
|
||||||
defaultPattern: "{YY}{CODE}{NNNN}",
|
|
||||||
prefixKey: null,
|
|
||||||
codeKey: "order_type_code" as const,
|
|
||||||
codeLabel: "Typový kód",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "Faktury",
|
|
||||||
patternKey: "invoice_number_pattern" as const,
|
|
||||||
defaultPattern: "{YY}{CODE}{NNNN}",
|
|
||||||
prefixKey: null,
|
|
||||||
codeKey: "invoice_type_code" as const,
|
|
||||||
codeLabel: "Typový kód",
|
|
||||||
},
|
|
||||||
].map((cfg, idx) => {
|
|
||||||
const pattern =
|
|
||||||
sysForm[cfg.patternKey] || cfg.defaultPattern;
|
|
||||||
const yyyy = String(new Date().getFullYear());
|
|
||||||
const yy = yyyy.slice(-2);
|
|
||||||
const prefix = cfg.prefixKey
|
|
||||||
? sysForm[cfg.prefixKey] || "NA"
|
|
||||||
: "";
|
|
||||||
const code = cfg.codeKey
|
|
||||||
? sysForm[cfg.codeKey] || ""
|
|
||||||
: "";
|
|
||||||
const preview = pattern.replace(
|
|
||||||
/\{(\w+)\}/g,
|
|
||||||
(m: string, k: string) => {
|
|
||||||
if (k === "YYYY") return yyyy;
|
|
||||||
if (k === "YY") return yy;
|
|
||||||
if (k === "PREFIX") return prefix;
|
|
||||||
if (k === "CODE") return code;
|
|
||||||
if (/^N+$/.test(k))
|
|
||||||
return "1".padStart(k.length, "0");
|
|
||||||
return m;
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div key={cfg.patternKey}>
|
|
||||||
{idx > 0 && (
|
|
||||||
<hr
|
|
||||||
style={{
|
|
||||||
border: "none",
|
|
||||||
borderTop: "1px solid var(--border-color)",
|
|
||||||
margin: "1rem 0",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<div
|
|
||||||
className="fw-600 text-md"
|
|
||||||
style={{
|
|
||||||
marginBottom: "0.5rem",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{cfg.label}
|
|
||||||
</div>
|
|
||||||
<div className="admin-form-row">
|
|
||||||
<FormField label="Formát">
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={sysForm[cfg.patternKey]}
|
|
||||||
onChange={(e) =>
|
|
||||||
setSysForm((p) => ({
|
|
||||||
...p,
|
|
||||||
[cfg.patternKey]: e.target.value,
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
className="admin-form-input"
|
|
||||||
placeholder={cfg.defaultPattern}
|
|
||||||
/>
|
|
||||||
</FormField>
|
|
||||||
{cfg.prefixKey && (
|
|
||||||
<FormField label="Prefix">
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={sysForm[cfg.prefixKey]}
|
|
||||||
onChange={(e) =>
|
|
||||||
setSysForm((p) => ({
|
|
||||||
...p,
|
|
||||||
[cfg.prefixKey!]: e.target.value,
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
className="admin-form-input"
|
|
||||||
style={{ maxWidth: 100 }}
|
|
||||||
/>
|
|
||||||
</FormField>
|
|
||||||
)}
|
|
||||||
{cfg.codeKey && (
|
|
||||||
<FormField label={cfg.codeLabel || "Kód"}>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={sysForm[cfg.codeKey]}
|
|
||||||
onChange={(e) =>
|
|
||||||
setSysForm((p) => ({
|
|
||||||
...p,
|
|
||||||
[cfg.codeKey!]: e.target.value,
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
className="admin-form-input"
|
|
||||||
style={{ maxWidth: 100 }}
|
|
||||||
/>
|
|
||||||
</FormField>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<small
|
|
||||||
style={{
|
|
||||||
color: "var(--text-tertiary)",
|
|
||||||
fontSize: "0.8rem",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Ukázka:{" "}
|
|
||||||
<strong style={{ color: "var(--text-primary)" }}>
|
|
||||||
{preview}
|
|
||||||
</strong>
|
|
||||||
</small>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</motion.div>
|
|
||||||
|
|
||||||
{/* Section 6: Měna a DPH */}
|
|
||||||
<motion.div
|
|
||||||
className="admin-card"
|
|
||||||
initial={{ opacity: 0, y: 12 }}
|
|
||||||
animate={{ opacity: 1, y: 0 }}
|
|
||||||
transition={{ duration: 0.25, delay: 0.3 }}
|
|
||||||
>
|
|
||||||
<div className="admin-card-header">
|
|
||||||
<h2 className="admin-card-title">Měna a DPH</h2>
|
|
||||||
</div>
|
|
||||||
<div className="admin-card-body">
|
|
||||||
<div className="admin-form">
|
|
||||||
<div className="admin-form-row">
|
|
||||||
<FormField label="Výchozí měna">
|
|
||||||
<select
|
|
||||||
value={sysForm.default_currency}
|
|
||||||
onChange={(e) =>
|
|
||||||
setSysForm((prev) => ({
|
|
||||||
...prev,
|
|
||||||
default_currency: e.target.value,
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
className="admin-form-input"
|
|
||||||
>
|
|
||||||
{sysForm.available_currencies.map((c) => (
|
|
||||||
<option key={c} value={c}>
|
|
||||||
{c}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</FormField>
|
|
||||||
<FormField label="Výchozí sazba DPH (%)">
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
value={sysForm.default_vat_rate}
|
|
||||||
onChange={(e) =>
|
|
||||||
setSysForm((prev) => ({
|
|
||||||
...prev,
|
|
||||||
default_vat_rate: Number(e.target.value),
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
className="admin-form-input"
|
|
||||||
min={0}
|
|
||||||
step={1}
|
|
||||||
/>
|
|
||||||
</FormField>
|
|
||||||
</div>
|
|
||||||
<div className="admin-form-row">
|
|
||||||
<FormField label="Dostupné měny">
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={sysForm.available_currencies.join(", ")}
|
|
||||||
onChange={(e) =>
|
|
||||||
setSysForm((prev) => ({
|
|
||||||
...prev,
|
|
||||||
available_currencies: e.target.value
|
|
||||||
.split(",")
|
|
||||||
.map((s) => s.trim())
|
|
||||||
.filter(Boolean),
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
className="admin-form-input"
|
|
||||||
placeholder="CZK, EUR, USD"
|
|
||||||
/>
|
|
||||||
</FormField>
|
|
||||||
<FormField label="Dostupné sazby DPH (%)">
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={sysForm.available_vat_rates.join(", ")}
|
|
||||||
onChange={(e) =>
|
|
||||||
setSysForm((prev) => ({
|
|
||||||
...prev,
|
|
||||||
available_vat_rates: e.target.value
|
|
||||||
.split(",")
|
|
||||||
.map((s) => s.trim())
|
|
||||||
.filter(Boolean)
|
|
||||||
.map(Number)
|
|
||||||
.filter((n) => !isNaN(n)),
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
className="admin-form-input"
|
|
||||||
placeholder="0, 12, 21"
|
|
||||||
/>
|
|
||||||
</FormField>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</motion.div>
|
|
||||||
|
|
||||||
{/* Section 6: Informace o aplikaci */}
|
|
||||||
<motion.div
|
<motion.div
|
||||||
className="admin-card"
|
className="admin-card"
|
||||||
initial={{ opacity: 0, y: 12 }}
|
initial={{ opacity: 0, y: 12 }}
|
||||||
@@ -1615,7 +1365,7 @@ export default function Settings() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Firma tab */}
|
{/* Firma tab */}
|
||||||
{activeTab === "firma" && canManage && <CompanySettings embedded />}
|
{activeTab === "firma" && <CompanySettings embedded />}
|
||||||
|
|
||||||
{/* Create/Edit Modal */}
|
{/* Create/Edit Modal */}
|
||||||
<AnimatePresence>
|
<AnimatePresence>
|
||||||
|
|||||||
@@ -54,3 +54,25 @@ export function requirePermission(...permissionNames: string[]) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function requireAnyPermission(...permissionNames: string[]) {
|
||||||
|
return async (
|
||||||
|
request: FastifyRequest,
|
||||||
|
reply: FastifyReply,
|
||||||
|
): Promise<void> => {
|
||||||
|
await requireAuth(request, reply);
|
||||||
|
if (reply.sent) return;
|
||||||
|
|
||||||
|
const authData = request.authData!;
|
||||||
|
|
||||||
|
// Admin has all permissions
|
||||||
|
if (authData.roleName === "admin") return;
|
||||||
|
|
||||||
|
const hasAny = permissionNames.some((p) =>
|
||||||
|
authData.permissions.includes(p),
|
||||||
|
);
|
||||||
|
if (!hasAny) {
|
||||||
|
return error(reply, "Nedostatečná oprávnění", 403);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import prisma from "../../config/database";
|
|||||||
import {
|
import {
|
||||||
requireAuth,
|
requireAuth,
|
||||||
requirePermission,
|
requirePermission,
|
||||||
|
requireAnyPermission,
|
||||||
optionalAuth,
|
optionalAuth,
|
||||||
} from "../../middleware/auth";
|
} from "../../middleware/auth";
|
||||||
import { logAudit } from "../../services/audit";
|
import { logAudit } from "../../services/audit";
|
||||||
@@ -333,7 +334,7 @@ export default async function companySettingsRoutes(
|
|||||||
// GET /api/admin/company-settings/system-info
|
// GET /api/admin/company-settings/system-info
|
||||||
fastify.get(
|
fastify.get(
|
||||||
"/system-info",
|
"/system-info",
|
||||||
{ preHandler: requirePermission("settings.company") },
|
{ preHandler: requirePermission("settings.system") },
|
||||||
async (request, reply) => {
|
async (request, reply) => {
|
||||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||||
const pkg = require("../../../package.json") as { version: string };
|
const pkg = require("../../../package.json") as { version: string };
|
||||||
@@ -404,7 +405,7 @@ export default async function companySettingsRoutes(
|
|||||||
|
|
||||||
fastify.put(
|
fastify.put(
|
||||||
"/",
|
"/",
|
||||||
{ preHandler: requirePermission("settings.company") },
|
{ preHandler: requireAnyPermission("settings.company", "settings.system") },
|
||||||
async (request, reply) => {
|
async (request, reply) => {
|
||||||
const parsed = parseBody(UpdateCompanySettingsSchema, request.body);
|
const parsed = parseBody(UpdateCompanySettingsSchema, request.body);
|
||||||
if ("error" in parsed) return error(reply, parsed.error, 400);
|
if ("error" in parsed) return error(reply, parsed.error, 400);
|
||||||
|
|||||||
@@ -14,18 +14,29 @@ export default async function rolesRoutes(
|
|||||||
"/",
|
"/",
|
||||||
{ preHandler: requirePermission("settings.roles") },
|
{ preHandler: requirePermission("settings.roles") },
|
||||||
async (request, reply) => {
|
async (request, reply) => {
|
||||||
const roles = await prisma.roles.findMany({
|
const [roles, userCounts] = await Promise.all([
|
||||||
|
prisma.roles.findMany({
|
||||||
include: {
|
include: {
|
||||||
role_permissions: {
|
role_permissions: {
|
||||||
include: { permissions: true },
|
include: { permissions: true },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
orderBy: { id: "asc" },
|
orderBy: { id: "asc" },
|
||||||
});
|
}),
|
||||||
|
prisma.users.groupBy({
|
||||||
|
by: ["role_id"],
|
||||||
|
_count: { id: true },
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const countMap = new Map(
|
||||||
|
userCounts.map((u) => [u.role_id, Number(u._count.id)]),
|
||||||
|
);
|
||||||
|
|
||||||
const data = roles.map((r) => ({
|
const data = roles.map((r) => ({
|
||||||
...r,
|
...r,
|
||||||
permissions: r.role_permissions.map((rp) => rp.permissions),
|
permissions: r.role_permissions.map((rp) => rp.permissions),
|
||||||
|
user_count: countMap.get(r.id) || 0,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
return success(reply, data);
|
return success(reply, data);
|
||||||
@@ -38,7 +49,7 @@ export default async function rolesRoutes(
|
|||||||
{ preHandler: requirePermission("settings.roles") },
|
{ preHandler: requirePermission("settings.roles") },
|
||||||
async (_request, reply) => {
|
async (_request, reply) => {
|
||||||
const permissions = await prisma.permissions.findMany({
|
const permissions = await prisma.permissions.findMany({
|
||||||
orderBy: [{ module: "asc" }, { id: "asc"}]
|
orderBy: [{ module: "asc" }, { id: "asc" }],
|
||||||
});
|
});
|
||||||
return success(reply, permissions);
|
return success(reply, permissions);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -207,7 +207,7 @@ export default async function totpRoutes(
|
|||||||
fastify.post(
|
fastify.post(
|
||||||
"/required",
|
"/required",
|
||||||
{
|
{
|
||||||
preHandler: [requireAuth, requirePermission("settings.company")],
|
preHandler: [requireAuth, requirePermission("settings.system")],
|
||||||
bodyLimit: 10240,
|
bodyLimit: 10240,
|
||||||
},
|
},
|
||||||
async (request, reply) => {
|
async (request, reply) => {
|
||||||
|
|||||||
Reference in New Issue
Block a user