Files
app/src/admin/pages/CompanySettings.tsx
BOHA 66b98e2765 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>
2026-05-18 14:03:31 +02:00

1472 lines
54 KiB
TypeScript

import { useState, useEffect, useCallback, useRef } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useAlert } from "../context/AlertContext";
import { useAuth } from "../context/AuthContext";
import Forbidden from "../components/Forbidden";
import FormField from "../components/FormField";
import ConfirmModal from "../components/ConfirmModal";
import {
companySettingsOptions,
type CompanySettingsData,
} from "../lib/queries/settings";
import { bankAccountsOptions, type BankAccount } from "../lib/queries/common";
import { motion } from "framer-motion";
import apiFetch from "../utils/api";
import { Skeleton } from "boneyard-js/react";
import CompanySettingsFixture from "../fixtures/CompanySettingsFixture";
const API_BASE = "/api/admin";
const DEFAULT_FIELD_ORDER = [
"street",
"city_postal",
"country",
"company_id",
"vat_id",
];
const FIELD_LABELS: Record<string, string> = {
street: "Ulice",
city_postal: "Město + PSČ",
country: "Země",
company_id: "IČO",
vat_id: "DIČ",
};
interface CustomField {
name: string;
value: string;
showLabel: boolean;
_key: string;
}
interface CompanyForm {
company_name: string;
street: string;
city: string;
postal_code: string;
country: string;
company_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 {
account_name: string;
bank_name: string;
account_number: string;
iban: string;
bic: string;
currency: string;
is_default: boolean;
}
export default function CompanySettings({
embedded,
}: { embedded?: boolean } = {}) {
const alert = useAlert();
const { hasPermission } = useAuth();
const canEditCompany = hasPermission("settings.company");
const canManageBanking = hasPermission("settings.banking");
const queryClient = useQueryClient();
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: "",
city: "",
postal_code: "",
country: "",
company_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 [fieldOrder, setFieldOrder] = useState<string[]>([
...DEFAULT_FIELD_ORDER,
]);
const [bankSaving, setBankSaving] = useState(false);
const [editingBank, setEditingBank] = useState<number | null>(null);
const [bankDeleteConfirm, setBankDeleteConfirm] = useState<{
isOpen: boolean;
id: number | null;
}>({ isOpen: false, id: null });
const [bankForm, setBankForm] = useState<BankForm>({
account_name: "",
bank_name: "",
account_number: "",
iban: "",
bic: "",
currency: "CZK",
is_default: false,
});
const getFullFieldOrder = useCallback((): string[] => {
const allBuiltIn = [...DEFAULT_FIELD_ORDER];
const order = [...fieldOrder].filter((k) => k !== "company_name");
for (const f of allBuiltIn) {
if (!order.includes(f)) order.push(f);
}
for (let i = 0; i < customFields.length; i++) {
const key = `custom_${i}`;
if (!order.includes(key)) order.push(key);
}
return order.filter((key) => {
if (key.startsWith("custom_")) {
const idx = parseInt(key.split("_")[1]);
return idx < customFields.length;
}
return true;
});
}, [fieldOrder, customFields]);
const moveField = (index: number, direction: number) => {
const order = getFullFieldOrder();
const newIndex = index + direction;
if (newIndex < 0 || newIndex >= order.length) return;
const updated = [...order];
[updated[index], updated[newIndex]] = [updated[newIndex], updated[index]];
setFieldOrder(updated);
};
const getFieldDisplayName = (key: string): string => {
if (FIELD_LABELS[key]) return FIELD_LABELS[key];
if (key.startsWith("custom_")) {
const idx = parseInt(key.split("_")[1]);
const cf = customFields[idx];
if (cf)
return cf.name
? `${cf.name}: ${cf.value || "..."}`
: cf.value || `Vlastní pole ${idx + 1}`;
}
return key;
};
const fetchLogo = useCallback(async (variant: "light" | "dark" = "light") => {
try {
const resp = await apiFetch(
`${API_BASE}/company-settings/logo?variant=${variant}`,
);
if (resp.ok) {
const blob = await resp.blob();
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
}
}, []);
// ── TanStack Query: company settings ──
const { data: settingsData, isPending: settingsLoading } = useQuery(
companySettingsOptions(),
);
// ── TanStack Query: bank accounts ──
const { data: bankAccountsData, isPending: bankLoading } = useQuery(
bankAccountsOptions(),
);
const bankAccountsList: BankAccount[] = Array.isArray(bankAccountsData)
? bankAccountsData
: [];
// Populate form state when settings data arrives
useEffect(() => {
if (!settingsData) return;
setForm({
company_name: settingsData.company_name || "",
street: settingsData.street || "",
city: settingsData.city || "",
postal_code: settingsData.postal_code || "",
country: settingsData.country || "",
company_id: settingsData.company_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[] =
Array.isArray(settingsData.custom_fields) &&
settingsData.custom_fields.length > 0
? settingsData.custom_fields.map((f, i) => ({
...f,
showLabel: f.showLabel !== false,
_key: (f as CustomField)._key || `cf-${Date.now()}-${i}`,
}))
: [];
setCustomFields(cf);
if (
Array.isArray(settingsData.supplier_field_order) &&
settingsData.supplier_field_order.length > 0
) {
setFieldOrder(settingsData.supplier_field_order);
} else {
setFieldOrder([...DEFAULT_FIELD_ORDER]);
}
if (settingsData.has_logo) {
fetchLogo("light");
}
if (settingsData.has_logo_dark) {
fetchLogo("dark");
}
}, [settingsData, fetchLogo]);
const resetBankForm = () => {
setEditingBank(null);
setBankForm({
account_name: "",
bank_name: "",
account_number: "",
iban: "",
bic: "",
currency: "CZK",
is_default: false,
});
};
const handleBankSave = async () => {
if (!bankForm.account_name.trim()) {
alert.error("Název účtu je povinný");
return;
}
setBankSaving(true);
try {
const isEdit = editingBank !== null;
const url = isEdit
? `${API_BASE}/bank-accounts/${editingBank}`
: `${API_BASE}/bank-accounts`;
const response = await apiFetch(url, {
method: isEdit ? "PUT" : "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(bankForm),
});
const result = await response.json();
if (result.success) {
alert.success(result.message);
resetBankForm();
queryClient.invalidateQueries({ queryKey: ["bank-accounts"] });
queryClient.invalidateQueries({ queryKey: ["invoices"] });
} else {
alert.error(result.error || "Chyba při ukládání");
}
} catch {
alert.error("Chyba připojení");
} finally {
setBankSaving(false);
}
};
const handleBankDelete = (id: number) => {
setBankDeleteConfirm({ isOpen: true, id });
};
const confirmBankDelete = async () => {
if (bankDeleteConfirm.id == null) return;
try {
const response = await apiFetch(
`${API_BASE}/bank-accounts/${bankDeleteConfirm.id}`,
{
method: "DELETE",
},
);
const result = await response.json();
if (result.success) {
alert.success(result.message);
if (editingBank === bankDeleteConfirm.id) resetBankForm();
queryClient.invalidateQueries({ queryKey: ["bank-accounts"] });
queryClient.invalidateQueries({ queryKey: ["invoices"] });
} else {
alert.error(result.error || "Chyba při mazání");
}
} catch {
alert.error("Chyba připojení");
} finally {
setBankDeleteConfirm({ isOpen: false, id: null });
}
};
const startEditBank = (account: BankAccount) => {
setEditingBank(account.id);
setBankForm({
account_name: account.account_name || "",
bank_name: account.bank_name || "",
account_number: account.account_number || "",
iban: account.iban || "",
bic: account.bic || "",
currency: account.currency || "CZK",
is_default: !!account.is_default,
});
};
// Cleanup blob URLs on unmount
useEffect(() => {
return () => {
if (logoUrlRef.current) URL.revokeObjectURL(logoUrlRef.current);
if (logoUrlDarkRef.current) URL.revokeObjectURL(logoUrlDarkRef.current);
};
}, []);
const handleSave = async () => {
setSaving(true);
try {
const payload = {
...form,
custom_fields: customFields.filter(
(f) => f.name.trim() || f.value.trim(),
),
supplier_field_order: getFullFieldOrder(),
};
const response = await apiFetch(`${API_BASE}/company-settings`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
const result = await response.json();
if (result.success) {
alert.success(result.message || "Nastavení bylo uloženo");
queryClient.invalidateQueries({ queryKey: ["company-settings"] });
queryClient.invalidateQueries({ queryKey: ["offers"] });
queryClient.invalidateQueries({ queryKey: ["orders"] });
queryClient.invalidateQueries({ queryKey: ["invoices"] });
} else {
alert.error(result.error || "Nepodařilo se uložit nastavení");
}
} catch {
alert.error("Chyba připojení");
} finally {
setSaving(false);
}
};
const handleLogoUpload = async (
e: React.ChangeEvent<HTMLInputElement>,
variant: "light" | "dark" = "light",
) => {
const file = e.target.files?.[0];
if (!file) return;
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?variant=${variant}`,
{
method: "POST",
body: formData,
},
);
const result = await response.json();
if (result.success) {
alert.success(result.message || "Logo bylo nahráno");
queryClient.invalidateQueries({ queryKey: ["company-settings"] });
queryClient.invalidateQueries({ queryKey: ["offers"] });
queryClient.invalidateQueries({ queryKey: ["orders"] });
queryClient.invalidateQueries({ queryKey: ["invoices"] });
fetchLogo(variant);
} else {
alert.error(result.error || "Nepodařilo se nahrát logo");
}
} catch {
alert.error("Chyba připojení");
} finally {
setUploading(false);
e.target.value = "";
}
};
const updateField = (field: keyof CompanyForm, value: string | number) => {
setForm((prev) => ({ ...prev, [field]: value }));
};
if (!embedded && !canEditCompany) return <Forbidden />;
if (settingsLoading) {
return (
<Skeleton
name="company-settings"
loading={settingsLoading}
fixture={<CompanySettingsFixture />}
>
<div />
</Skeleton>
);
}
const fullFieldOrder = getFullFieldOrder();
const renderBankButtonContent = (): React.ReactNode => {
if (bankSaving) {
return (
<>
<div className="admin-spinner admin-spinner-sm" />
Ukládání...
</>
);
}
if (editingBank !== null) return "Uložit změny";
return (
<>
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<line x1="12" y1="5" x2="12" y2="19" />
<line x1="5" y1="12" x2="19" y2="12" />
</svg>
Přidat účet
</>
);
};
return (
<div>
{!embedded && (
<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 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="admin-settings-grid">
{/* Company Info */}
{(canEditCompany || !embedded) && (
<motion.div
className="admin-card"
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, delay: 0.06 }}
>
<div className="admin-card-header">
<h3 className="admin-card-title">Firemní údaje</h3>
</div>
<div className="admin-card-body">
<div className="admin-form">
<FormField label="Název firmy">
<input
type="text"
value={form.company_name}
onChange={(e) =>
updateField("company_name", e.target.value)
}
className="admin-form-input"
/>
</FormField>
<div className="admin-form-row">
<FormField label="Ulice">
<input
type="text"
value={form.street}
onChange={(e) => updateField("street", e.target.value)}
className="admin-form-input"
/>
</FormField>
<FormField label="Město">
<input
type="text"
value={form.city}
onChange={(e) => updateField("city", e.target.value)}
className="admin-form-input"
/>
</FormField>
</div>
<div className="admin-form-row">
<FormField label="PSČ">
<input
type="text"
value={form.postal_code}
onChange={(e) =>
updateField("postal_code", e.target.value)
}
className="admin-form-input"
/>
</FormField>
<FormField label="Země">
<input
type="text"
value={form.country}
onChange={(e) => updateField("country", e.target.value)}
className="admin-form-input"
/>
</FormField>
</div>
<div className="admin-form-row">
<FormField label="IČO">
<input
type="text"
value={form.company_id}
onChange={(e) =>
updateField("company_id", e.target.value)
}
className="admin-form-input"
/>
</FormField>
<FormField label="DIČ">
<input
type="text"
value={form.vat_id}
onChange={(e) => updateField("vat_id", e.target.value)}
className="admin-form-input"
/>
</FormField>
</div>
<div style={{ marginTop: 4 }}>
<label
className="admin-form-label"
style={{ display: "block", marginBottom: 4 }}
>
Vlastní pole
</label>
{customFields.map((field, idx) => (
<div key={field._key} style={{ marginBottom: 8 }}>
<div
className="admin-form-row"
style={{ marginBottom: 0, alignItems: "flex-end" }}
>
<FormField
label={idx === 0 ? "Název" : "\u00A0"}
style={{ flex: 1 }}
>
<input
type="text"
value={field.name}
onChange={(e) => {
const updated = [...customFields];
updated[idx] = {
...updated[idx],
name: e.target.value,
};
setCustomFields(updated);
}}
className="admin-form-input"
placeholder="Např. Tel."
/>
</FormField>
<FormField
label={idx === 0 ? "Hodnota" : "\u00A0"}
style={{ flex: 1 }}
>
<div
style={{
display: "flex",
gap: 4,
alignItems: "center",
}}
>
<input
type="text"
value={field.value}
onChange={(e) => {
const updated = [...customFields];
updated[idx] = {
...updated[idx],
value: e.target.value,
};
setCustomFields(updated);
}}
className="admin-form-input"
style={{ flex: 1 }}
/>
<button
type="button"
onClick={() => {
const key = `custom_${idx}`;
setFieldOrder((prev) =>
prev
.filter((k) => k !== key)
.map((k) => {
if (k.startsWith("custom_")) {
const ki = parseInt(k.split("_")[1]);
if (ki > idx) return `custom_${ki - 1}`;
}
return k;
}),
);
setCustomFields(
customFields.filter((_, i) => i !== idx),
);
}}
className="admin-btn-icon danger"
title="Odebrat pole"
aria-label="Odebrat pole"
>
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</svg>
</button>
</div>
</FormField>
</div>
<label
className="admin-form-checkbox"
style={{ marginTop: 4 }}
>
<input
type="checkbox"
checked={field.showLabel !== false}
onChange={(e) => {
const updated = [...customFields];
updated[idx] = {
...updated[idx],
showLabel: e.target.checked,
};
setCustomFields(updated);
}}
/>
<span style={{ fontSize: "0.8rem" }}>
Zobrazit název v PDF
</span>
</label>
</div>
))}
<button
type="button"
onClick={() =>
setCustomFields([
...customFields,
{
name: "",
value: "",
showLabel: true,
_key: `cf-${Date.now()}`,
},
])
}
className="admin-btn admin-btn-secondary"
style={{ marginTop: 4, fontSize: "0.85rem" }}
>
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<line x1="12" y1="5" x2="12" y2="19" />
<line x1="5" y1="12" x2="19" y2="12" />
</svg>
Přidat pole
</button>
</div>
</div>
</div>
</motion.div>
)}
{/* Bank Accounts */}
{(canManageBanking || !embedded) && (
<motion.div
className="admin-card"
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, delay: 0.08 }}
>
<div className="admin-card-header">
<h3 className="admin-card-title">Bankovní účty</h3>
</div>
<div className="admin-card-body">
{bankLoading ? (
<Skeleton
name="company-settings-bank"
loading={bankLoading}
fixture={<CompanySettingsFixture />}
>
<div />
</Skeleton>
) : (
<>
{bankAccountsList.length > 0 && (
<div className="admin-table-responsive mb-4">
<table className="admin-table">
<thead>
<tr>
<th>Název</th>
<th>Banka</th>
<th>Číslo účtu</th>
<th>IBAN</th>
<th>BIC/SWIFT</th>
<th>Měna</th>
<th style={{ width: 70 }}>Výchozí</th>
<th style={{ width: 80 }}></th>
</tr>
</thead>
<tbody>
{bankAccountsList.map((acc) => (
<tr
key={acc.id}
style={
editingBank === acc.id
? { background: "var(--bg-tertiary)" }
: undefined
}
>
<td>{acc.account_name}</td>
<td>{acc.bank_name}</td>
<td className="admin-mono">
{acc.account_number}
</td>
<td className="admin-mono">{acc.iban}</td>
<td className="admin-mono">{acc.bic}</td>
<td>{acc.currency}</td>
<td className="text-center">
{acc.is_default ? (
<span className="text-accent fw-600"></span>
) : (
"\u2013"
)}
</td>
<td>
<div style={{ display: "flex", gap: 4 }}>
<button
type="button"
onClick={() => startEditBank(acc)}
className="admin-btn-icon"
title="Upravit"
aria-label="Upravit"
>
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
</svg>
</button>
<button
type="button"
onClick={() => handleBankDelete(acc.id)}
className="admin-btn-icon danger"
title="Smazat"
aria-label="Smazat"
>
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</svg>
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
<div
style={{
background: "var(--bg-tertiary)",
borderRadius: "var(--border-radius)",
padding: 16,
}}
>
<h4
className="text-secondary"
style={{ margin: "0 0 12px", fontSize: "0.9rem" }}
>
{editingBank !== null
? "Upravit účet"
: "Přidat nový účet"}
</h4>
<div className="admin-form">
<div className="admin-form-row">
<FormField label="Název účtu" required>
<input
type="text"
value={bankForm.account_name}
onChange={(e) =>
setBankForm((f) => ({
...f,
account_name: e.target.value,
}))
}
className="admin-form-input"
placeholder="Např. Hlavní CZK účet"
/>
</FormField>
<FormField label="Název banky">
<input
type="text"
value={bankForm.bank_name}
onChange={(e) =>
setBankForm((f) => ({
...f,
bank_name: e.target.value,
}))
}
className="admin-form-input"
placeholder="Např. MONETA Money Bank, a.s."
/>
</FormField>
</div>
<div className="admin-form-row">
<FormField label="Číslo účtu">
<input
type="text"
value={bankForm.account_number}
onChange={(e) =>
setBankForm((f) => ({
...f,
account_number: e.target.value,
}))
}
className="admin-form-input"
placeholder="123456789/0600"
/>
</FormField>
<FormField label="Měna">
<select
value={bankForm.currency}
onChange={(e) =>
setBankForm((f) => ({
...f,
currency: e.target.value,
}))
}
className="admin-form-select"
>
{form.available_currencies.map((c) => (
<option key={c} value={c}>
{c}
</option>
))}
</select>
</FormField>
</div>
<div className="admin-form-row">
<FormField label="IBAN">
<input
type="text"
value={bankForm.iban}
onChange={(e) =>
setBankForm((f) => ({
...f,
iban: e.target.value,
}))
}
className="admin-form-input"
placeholder="CZ65 0800 0000 1920 0014 5399"
/>
</FormField>
<FormField label="BIC / SWIFT">
<input
type="text"
value={bankForm.bic}
onChange={(e) =>
setBankForm((f) => ({
...f,
bic: e.target.value,
}))
}
className="admin-form-input"
placeholder="GIBACZPX"
/>
</FormField>
</div>
<label className="admin-form-checkbox">
<input
type="checkbox"
checked={bankForm.is_default}
onChange={(e) =>
setBankForm((f) => ({
...f,
is_default: e.target.checked,
}))
}
/>
<span>
Výchozí účet (použije se automaticky při vytváření
faktury)
</span>
</label>
<div style={{ display: "flex", gap: 8, marginTop: 8 }}>
<button
type="button"
onClick={handleBankSave}
className="admin-btn admin-btn-primary"
disabled={bankSaving}
style={{ fontSize: "0.85rem" }}
>
{renderBankButtonContent()}
</button>
{editingBank !== null && (
<button
type="button"
onClick={resetBankForm}
className="admin-btn admin-btn-secondary"
style={{ fontSize: "0.85rem" }}
>
Zrušit
</button>
)}
</div>
</div>
</div>
</>
)}
</div>
</motion.div>
)}
{/* PDF Field Order */}
{(canEditCompany || !embedded) && (
<motion.div
className="admin-card"
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, delay: 0.08 }}
>
<div className="admin-card-header">
<h3 className="admin-card-title">Pořadí polí dodavatele v PDF</h3>
</div>
<div className="admin-card-body">
<small
className="admin-form-hint"
style={{ display: "block", marginBottom: 12 }}
>
Určuje pořadí řádků v adresním bloku dodavatele na PDF nabídce.
</small>
<div className="admin-reorder-list">
{fullFieldOrder.map((key, index) => (
<div key={key} className="admin-reorder-item">
<div className="admin-reorder-arrows">
<button
type="button"
onClick={() => moveField(index, -1)}
disabled={index === 0}
className="admin-btn-icon"
title="Nahoru"
aria-label="Nahoru"
>
<svg
width="12"
height="12"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<path d="M18 15l-6-6-6 6" />
</svg>
</button>
<button
type="button"
onClick={() => moveField(index, 1)}
disabled={index === fullFieldOrder.length - 1}
className="admin-btn-icon"
title="Dolů"
aria-label="Dolů"
>
<svg
width="12"
height="12"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<path d="M6 9l6 6 6-6" />
</svg>
</button>
</div>
<span
className={`admin-reorder-label${key.startsWith("custom_") ? " accent" : ""}`}
>
{getFieldDisplayName(key)}
</span>
</div>
))}
</div>
</div>
</motion.div>
)}
{/* 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
className="admin-card"
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
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">
<h3 className="admin-card-title">Logo</h3>
</div>
<div className="admin-card-body">
<div className="admin-form-row">
<div className="admin-logo-section">
<label
className="admin-form-label"
style={{ display: "block", marginBottom: 4 }}
>
Logo (světlý režim)
</label>
{logoUrl && (
<div className="admin-logo-preview">
<img src={logoUrl} alt="Logo (světlý režim)" />
</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
</>
)}
<input
type="file"
accept="image/*"
onChange={(e) => handleLogoUpload(e, "light")}
style={{ display: "none" }}
disabled={uploadingLogo}
/>
</label>
<small className="admin-form-hint">
PNG, JPEG, GIF nebo WebP, max 5 MB
</small>
</div>
<div className="admin-logo-section">
<label
className="admin-form-label"
style={{ display: "block", marginBottom: 4 }}
>
Logo (tmavý režim)
</label>
{logoUrlDark && (
<div className="admin-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 && canEditCompany && (
<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>
)}
<ConfirmModal
isOpen={bankDeleteConfirm.isOpen}
onClose={() => setBankDeleteConfirm({ isOpen: false, id: null })}
onConfirm={confirmBankDelete}
title="Smazat bankovní účet"
message="Opravdu chcete smazat tento bankovní účet?"
confirmText="Smazat"
cancelText="Zrušit"
type="danger"
/>
</div>
);
}