- Replace hand-coded skeleton CSS/JSX with boneyard-js auto-generated bones - Remove skeleton.css and @keyframes shimmer from base.css - Add <Skeleton> wrappers with fixtures to all 25+ page components - Generate 20 bone captures via boneyard CLI (CDP auth-gated capture) - Refactor data fetching from useEffect+useState to TanStack Query - Extract query hooks into src/admin/lib/queries/ and apiAdapter - Add usePaginatedQuery hook replacing useApiCall/useListData - Fix parseFloat || 0 anti-pattern in OfferDetail and OffersTemplates inputs - Fix customer_id mandatory validation on offer creation - Fix leave-requests comma-separated status filter (Prisma enum in: []) - Add cross-entity cache invalidation for orders/offers/invoices/projects - Make rate limits configurable via env vars (RATE_LIMIT_MAX, RATE_LIMIT_REFRESH, etc.) - Add boneyard.config.json with routes and breakpoints Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1188 lines
41 KiB
TypeScript
1188 lines
41 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 } from "../lib/queries/settings";
|
|
import { bankAccountsOptions } 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;
|
|
}
|
|
|
|
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({
|
|
embedded,
|
|
}: { embedded?: boolean } = {}) {
|
|
const alert = useAlert();
|
|
const { hasPermission } = useAuth();
|
|
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: "",
|
|
});
|
|
const [customFields, setCustomFields] = useState<CustomField[]>([]);
|
|
const [fieldOrder, setFieldOrder] = useState<string[]>([
|
|
...DEFAULT_FIELD_ORDER,
|
|
]);
|
|
const [availableCurrencies, setAvailableCurrencies] = useState<string[]>([
|
|
"CZK",
|
|
"EUR",
|
|
"USD",
|
|
"GBP",
|
|
]);
|
|
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 as unknown as BankAccount[])
|
|
: [];
|
|
|
|
// Populate form state when settings data arrives
|
|
useEffect(() => {
|
|
if (!settingsData) return;
|
|
const d = settingsData as Record<string, unknown>;
|
|
setForm({
|
|
company_name: (d.company_name as string) || "",
|
|
street: (d.street as string) || "",
|
|
city: (d.city as string) || "",
|
|
postal_code: (d.postal_code as string) || "",
|
|
country: (d.country as string) || "",
|
|
company_id: (d.company_id as string) || "",
|
|
vat_id: (d.vat_id as string) || "",
|
|
});
|
|
const cf: CustomField[] =
|
|
Array.isArray(d.custom_fields) && d.custom_fields.length > 0
|
|
? (d.custom_fields as CustomField[]).map((f, i) => ({
|
|
...f,
|
|
showLabel: f.showLabel !== false,
|
|
_key: f._key || `cf-${Date.now()}-${i}`,
|
|
}))
|
|
: [];
|
|
setCustomFields(cf);
|
|
if (
|
|
Array.isArray(d.supplier_field_order) &&
|
|
d.supplier_field_order.length > 0
|
|
) {
|
|
setFieldOrder(d.supplier_field_order as string[]);
|
|
} else {
|
|
setFieldOrder([...DEFAULT_FIELD_ORDER]);
|
|
}
|
|
if (
|
|
Array.isArray(d.available_currencies) &&
|
|
d.available_currencies.length > 0
|
|
) {
|
|
setAvailableCurrencies(d.available_currencies as string[]);
|
|
}
|
|
if (d.has_logo) {
|
|
fetchLogo("light");
|
|
}
|
|
if (d.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"] });
|
|
} 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"] });
|
|
} 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"] });
|
|
} 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"] });
|
|
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 && !hasPermission("settings.manage")) 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 */}
|
|
<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 */}
|
|
<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"
|
|
>
|
|
{availableCurrencies.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 */}
|
|
<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="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 && (
|
|
<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>
|
|
);
|
|
}
|