1232 lines
42 KiB
TypeScript
1232 lines
42 KiB
TypeScript
import { useState, useEffect, useCallback, useRef } from "react";
|
|
import { useAlert } from "../context/AlertContext";
|
|
import { useAuth } from "../context/AuthContext";
|
|
import Forbidden from "../components/Forbidden";
|
|
import FormField from "../components/FormField";
|
|
import { motion } from "framer-motion";
|
|
|
|
import apiFetch from "../utils/api";
|
|
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Č",
|
|
};
|
|
|
|
const currentYear = new Date().getFullYear().toString().slice(-2);
|
|
|
|
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;
|
|
quotation_prefix: string;
|
|
default_currency: string;
|
|
default_vat_rate: number;
|
|
order_type_code: string;
|
|
invoice_type_code: string;
|
|
}
|
|
|
|
interface BankAccount {
|
|
id: number;
|
|
account_name: string;
|
|
bank_name: string;
|
|
account_number: string;
|
|
iban: string;
|
|
bic: string;
|
|
currency: string;
|
|
is_default: boolean;
|
|
}
|
|
|
|
interface BankForm {
|
|
account_name: string;
|
|
bank_name: string;
|
|
account_number: string;
|
|
iban: string;
|
|
bic: string;
|
|
currency: string;
|
|
is_default: boolean;
|
|
}
|
|
|
|
export default function CompanySettings() {
|
|
const alert = useAlert();
|
|
const { hasPermission } = useAuth();
|
|
const [loading, setLoading] = useState(true);
|
|
const [saving, setSaving] = useState(false);
|
|
const [uploadingLogo, setUploadingLogo] = useState(false);
|
|
const [logoUrl, setLogoUrl] = useState<string | null>(null);
|
|
const logoUrlRef = useRef<string | null>(null);
|
|
const [form, setForm] = useState<CompanyForm>({
|
|
company_name: "",
|
|
street: "",
|
|
city: "",
|
|
postal_code: "",
|
|
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);
|
|
const [fieldOrder, setFieldOrder] = useState<string[]>([
|
|
...DEFAULT_FIELD_ORDER,
|
|
]);
|
|
const [bankAccounts, setBankAccounts] = useState<BankAccount[]>([]);
|
|
const [bankLoading, setBankLoading] = useState(true);
|
|
const [bankSaving, setBankSaving] = useState(false);
|
|
const [editingBank, setEditingBank] = useState<number | null>(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 () => {
|
|
try {
|
|
const resp = await apiFetch(`${API_BASE}/company-settings/logo`);
|
|
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;
|
|
});
|
|
}
|
|
} catch {
|
|
// ignore - no logo
|
|
}
|
|
}, []);
|
|
|
|
const fetchData = useCallback(async () => {
|
|
try {
|
|
const response = await apiFetch(`${API_BASE}/company-settings`);
|
|
if (response.status === 401) return;
|
|
const result = await response.json();
|
|
if (result.success) {
|
|
const d = result.data;
|
|
setForm({
|
|
company_name: d.company_name || "",
|
|
street: d.street || "",
|
|
city: d.city || "",
|
|
postal_code: d.postal_code || "",
|
|
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
|
|
? d.custom_fields.map(
|
|
(f: { name: string; value: string; showLabel?: boolean }) => ({
|
|
...f,
|
|
_key: `cf-${++customFieldKeyCounter.current}`,
|
|
}),
|
|
)
|
|
: [];
|
|
setCustomFields(cf);
|
|
if (
|
|
Array.isArray(d.supplier_field_order) &&
|
|
d.supplier_field_order.length > 0
|
|
) {
|
|
setFieldOrder(d.supplier_field_order);
|
|
} else {
|
|
setFieldOrder([...DEFAULT_FIELD_ORDER]);
|
|
}
|
|
if (d.has_logo) {
|
|
fetchLogo();
|
|
}
|
|
} else {
|
|
alert.error(result.error || "Nepodařilo se načíst nastavení");
|
|
}
|
|
} catch {
|
|
alert.error("Chyba připojení");
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}, [alert, fetchLogo]);
|
|
|
|
const fetchBankAccounts = useCallback(async () => {
|
|
try {
|
|
const response = await apiFetch(`${API_BASE}/bank-accounts`);
|
|
if (response.status === 401) return;
|
|
const result = await response.json();
|
|
if (result.success) {
|
|
setBankAccounts(result.data);
|
|
}
|
|
} catch {
|
|
// ignore
|
|
} finally {
|
|
setBankLoading(false);
|
|
}
|
|
}, []);
|
|
|
|
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();
|
|
fetchBankAccounts();
|
|
} else {
|
|
alert.error(result.error || "Chyba při ukládání");
|
|
}
|
|
} catch {
|
|
alert.error("Chyba připojení");
|
|
} finally {
|
|
setBankSaving(false);
|
|
}
|
|
};
|
|
|
|
const handleBankDelete = async (id: number) => {
|
|
if (!confirm("Opravdu smazat tento bankovní účet?")) return;
|
|
try {
|
|
const response = await apiFetch(`${API_BASE}/bank-accounts/${id}`, {
|
|
method: "DELETE",
|
|
});
|
|
const result = await response.json();
|
|
if (result.success) {
|
|
alert.success(result.message);
|
|
if (editingBank === id) resetBankForm();
|
|
fetchBankAccounts();
|
|
} else {
|
|
alert.error(result.error || "Chyba při mazání");
|
|
}
|
|
} catch {
|
|
alert.error("Chyba připojení");
|
|
}
|
|
};
|
|
|
|
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,
|
|
});
|
|
};
|
|
|
|
useEffect(() => {
|
|
fetchData();
|
|
fetchBankAccounts();
|
|
}, [fetchData, fetchBankAccounts]);
|
|
|
|
// Cleanup blob URL on unmount
|
|
useEffect(() => {
|
|
return () => {
|
|
if (logoUrlRef.current) URL.revokeObjectURL(logoUrlRef.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");
|
|
} 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>) => {
|
|
const file = e.target.files?.[0];
|
|
if (!file) return;
|
|
|
|
setUploadingLogo(true);
|
|
try {
|
|
const formData = new FormData();
|
|
formData.append("logo", file);
|
|
|
|
const response = await apiFetch(`${API_BASE}/company-settings/logo`, {
|
|
method: "POST",
|
|
body: formData,
|
|
});
|
|
const result = await response.json();
|
|
if (result.success) {
|
|
alert.success(result.message || "Logo bylo nahráno");
|
|
fetchLogo();
|
|
} else {
|
|
alert.error(result.error || "Nepodařilo se nahrát logo");
|
|
}
|
|
} catch {
|
|
alert.error("Chyba připojení");
|
|
} finally {
|
|
setUploadingLogo(false);
|
|
e.target.value = "";
|
|
}
|
|
};
|
|
|
|
const updateField = (field: keyof CompanyForm, value: string | number) => {
|
|
setForm((prev) => ({ ...prev, [field]: value }));
|
|
};
|
|
|
|
if (!hasPermission("offers.settings")) return <Forbidden />;
|
|
|
|
if (loading) {
|
|
return (
|
|
<div className="admin-skeleton" style={{ padding: 0, gap: "1.5rem" }}>
|
|
<div
|
|
className="admin-skeleton-row"
|
|
style={{ justifyContent: "space-between" }}
|
|
>
|
|
<div>
|
|
<div
|
|
className="admin-skeleton-line h-8"
|
|
style={{ width: "200px", marginBottom: "0.5rem" }}
|
|
/>
|
|
<div className="admin-skeleton-line" style={{ width: "140px" }} />
|
|
</div>
|
|
<div
|
|
className="admin-skeleton-line h-10"
|
|
style={{ width: "120px", borderRadius: "8px" }}
|
|
/>
|
|
</div>
|
|
<div
|
|
style={{
|
|
display: "grid",
|
|
gridTemplateColumns: "repeat(3, 1fr)",
|
|
gap: "1.25rem",
|
|
}}
|
|
>
|
|
{[0, 1, 2, 3, 4, 5].map((i) => (
|
|
<div key={i} className="admin-card">
|
|
<div className="admin-skeleton" style={{ gap: "1.25rem" }}>
|
|
<div
|
|
className="admin-skeleton-line h-8"
|
|
style={{ width: "60%" }}
|
|
/>
|
|
{[0, 1, 2].map((j) => (
|
|
<div key={j} className="admin-skeleton-row">
|
|
<div className="admin-skeleton-line w-1/3" />
|
|
<div className="admin-skeleton-line w-1/2" />
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
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>
|
|
<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}
|
|
>
|
|
{saving ? (
|
|
<>
|
|
<div className="admin-spinner admin-spinner-sm" />
|
|
Ukládání...
|
|
</>
|
|
) : (
|
|
"Uložit nastavení"
|
|
)}
|
|
</button>
|
|
</motion.div>
|
|
|
|
<div className="offers-settings-grid">
|
|
{/* Company Info */}
|
|
<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-${++customFieldKeyCounter.current}`,
|
|
},
|
|
])
|
|
}
|
|
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 */}
|
|
<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 ? (
|
|
<div className="admin-skeleton" style={{ gap: "1rem" }}>
|
|
{[0, 1, 2].map((i) => (
|
|
<div key={i} className="admin-skeleton-row">
|
|
<div className="admin-skeleton-line w-1/3" />
|
|
<div className="admin-skeleton-line w-1/4" />
|
|
<div className="admin-skeleton-line w-1/4" />
|
|
</div>
|
|
))}
|
|
</div>
|
|
) : (
|
|
<>
|
|
{bankAccounts.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>
|
|
{bankAccounts.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"
|
|
>
|
|
<option value="CZK">CZK</option>
|
|
<option value="EUR">EUR</option>
|
|
<option value="USD">USD</option>
|
|
<option value="GBP">GBP</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 */}
|
|
<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>
|
|
|
|
{/* Logo */}
|
|
<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">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
|
|
</>
|
|
)}
|
|
<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 (%)">
|
|
<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"
|
|
/>
|
|
</FormField>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</motion.div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|