Files
app/src/admin/pages/CompanySettings.tsx

1543 lines
51 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 {
companySettingsOptions,
type CompanySettingsData,
} from "../lib/queries/settings";
import { bankAccountsOptions, type BankAccount } from "../lib/queries/common";
import { motion } from "framer-motion";
import Box from "@mui/material/Box";
import Typography from "@mui/material/Typography";
import IconButton from "@mui/material/IconButton";
import apiFetch from "../utils/api";
import { useApiMutation } from "../lib/queries/mutations";
import {
Button,
Card,
DataTable,
ConfirmDialog,
Field,
TextField,
Select,
CheckboxField,
StatusChip,
FileUpload,
LoadingState,
type DataColumn,
} from "../ui";
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 PlusIcon = (
<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>
);
const EditIcon = (
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<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>
);
const RemoveIcon = (
<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>
);
const UpIcon = (
<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>
);
const DownIcon = (
<svg
width="12"
height="12"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<path d="M6 9l6 6 6-6" />
</svg>
);
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;
issued_order_number_pattern: string;
issued_order_type_code: string;
project_number_pattern: string;
project_type_code: string;
quotation_prefix: string;
order_type_code: string;
invoice_type_code: string;
warehouse_receipt_prefix: string;
warehouse_receipt_number_pattern: string;
warehouse_issue_prefix: string;
warehouse_issue_number_pattern: string;
warehouse_inventory_prefix: string;
warehouse_inventory_number_pattern: 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: "",
issued_order_number_pattern: "",
issued_order_type_code: "",
project_number_pattern: "",
project_type_code: "",
quotation_prefix: "",
order_type_code: "",
invoice_type_code: "",
warehouse_receipt_prefix: "",
warehouse_receipt_number_pattern: "",
warehouse_issue_prefix: "",
warehouse_issue_number_pattern: "",
warehouse_inventory_prefix: "",
warehouse_inventory_number_pattern: "",
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 saveSettingsMutation = useApiMutation<
Record<string, unknown>,
{ message?: string }
>({
url: () => `${API_BASE}/company-settings`,
method: () => "PUT",
invalidate: [
"settings",
"company-settings",
"attendance",
"offers",
"orders",
"invoices",
],
});
const bankMutation = useApiMutation<
BankForm & { id?: number },
{ message?: string }
>({
url: (input) =>
input.id != null
? `${API_BASE}/bank-accounts/${input.id}`
: `${API_BASE}/bank-accounts`,
method: (input) => (input.id != null ? "PUT" : "POST"),
invalidate: ["settings", "bank-accounts", "invoices"],
});
const bankDeleteMutation = useApiMutation<number, { message?: string }>({
url: (id) => `${API_BASE}/bank-accounts/${id}`,
method: () => "DELETE",
invalidate: ["settings", "bank-accounts", "invoices"],
});
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 || "",
issued_order_number_pattern:
settingsData.issued_order_number_pattern || "",
issued_order_type_code: settingsData.issued_order_type_code || "",
project_number_pattern: settingsData.project_number_pattern || "",
project_type_code: settingsData.project_type_code || "",
quotation_prefix: settingsData.quotation_prefix || "",
order_type_code: settingsData.order_type_code || "",
invoice_type_code: settingsData.invoice_type_code || "",
warehouse_receipt_prefix: settingsData.warehouse_receipt_prefix || "",
warehouse_receipt_number_pattern:
settingsData.warehouse_receipt_number_pattern || "",
warehouse_issue_prefix: settingsData.warehouse_issue_prefix || "",
warehouse_issue_number_pattern:
settingsData.warehouse_issue_number_pattern || "",
warehouse_inventory_prefix: settingsData.warehouse_inventory_prefix || "",
warehouse_inventory_number_pattern:
settingsData.warehouse_inventory_number_pattern || "",
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 result = await bankMutation.mutateAsync({
...bankForm,
id: editingBank ?? undefined,
});
alert.success(result?.message || "Uloženo");
resetBankForm();
} catch (e) {
alert.error(e instanceof Error ? e.message : "Chyba připojení");
} finally {
setBankSaving(false);
}
};
const handleBankDelete = (id: number) => {
setBankDeleteConfirm({ isOpen: true, id });
};
const confirmBankDelete = async () => {
if (bankDeleteConfirm.id == null) return;
try {
const result = await bankDeleteMutation.mutateAsync(bankDeleteConfirm.id);
alert.success(result?.message || "Smazáno");
if (editingBank === bankDeleteConfirm.id) resetBankForm();
} catch (e) {
alert.error(e instanceof Error ? e.message : "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 result = await saveSettingsMutation.mutateAsync(payload);
alert.success(result?.message || "Nastavení bylo uloženo");
} catch (e) {
alert.error(
e instanceof Error ? e.message : "Nepodařilo se uložit nastavení",
);
} finally {
setSaving(false);
}
};
const handleLogoUpload = async (
file: File,
variant: "light" | "dark" = "light",
) => {
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);
}
};
const updateField = (field: keyof CompanyForm, value: string | number) => {
setForm((prev) => ({ ...prev, [field]: value }));
};
if (!embedded && !canEditCompany) return <Forbidden />;
if (settingsLoading) {
return <LoadingState />;
}
const fullFieldOrder = getFullFieldOrder();
const bankColumns: DataColumn<BankAccount>[] = [
{
key: "account_name",
header: "Název",
render: (acc) => acc.account_name,
},
{
key: "bank_name",
header: "Banka",
render: (acc) => acc.bank_name,
},
{
key: "account_number",
header: "Číslo účtu",
mono: true,
render: (acc) => acc.account_number,
},
{
key: "iban",
header: "IBAN",
mono: true,
render: (acc) => acc.iban,
},
{
key: "bic",
header: "BIC/SWIFT",
mono: true,
render: (acc) => acc.bic,
},
{
key: "currency",
header: "Měna",
render: (acc) => acc.currency,
},
{
key: "is_default",
header: "Výchozí",
width: 70,
align: "right",
render: (acc) =>
acc.is_default ? (
<Box component="span" sx={{ color: "primary.main", fontWeight: 600 }}>
</Box>
) : (
""
),
},
{
key: "actions",
header: "",
width: 80,
align: "right",
render: (acc) => (
<Box sx={{ display: "flex", gap: 0.5, justifyContent: "flex-end" }}>
<IconButton
size="small"
onClick={() => startEditBank(acc)}
title="Upravit"
aria-label="Upravit"
>
{EditIcon}
</IconButton>
<IconButton
size="small"
color="error"
onClick={() => handleBankDelete(acc.id)}
title="Smazat"
aria-label="Smazat"
>
{RemoveIcon}
</IconButton>
</Box>
),
},
];
return (
<Box>
{!embedded && (
<motion.div
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25 }}
>
<Box
sx={{
display: "flex",
alignItems: "flex-start",
justifyContent: "space-between",
flexWrap: "wrap",
gap: 2,
mb: 3,
}}
>
<Box>
<Typography variant="h4">Nastavení firmy</Typography>
<Typography
variant="body2"
color="text.secondary"
sx={{ mt: 0.5 }}
>
Firemní údaje a bankovní účty
</Typography>
</Box>
<Button onClick={handleSave} disabled={saving}>
{saving ? "Ukládání..." : "Uložit nastavení"}
</Button>
</Box>
</motion.div>
)}
<Box
sx={{
display: "grid",
gridTemplateColumns: { xs: "1fr", lg: "1fr 1fr" },
gap: 3,
}}
>
{/* Company Info */}
{(canEditCompany || !embedded) && (
<motion.div
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, delay: 0.06 }}
>
<Card>
<Typography variant="h6" sx={{ mb: 2 }}>
Firemní údaje
</Typography>
<Field label="Název firmy">
<TextField
value={form.company_name}
onChange={(e) => updateField("company_name", e.target.value)}
/>
</Field>
<Box
sx={{
display: "grid",
gridTemplateColumns: { xs: "1fr", sm: "1fr 1fr" },
gap: 2,
}}
>
<Field label="Ulice">
<TextField
value={form.street}
onChange={(e) => updateField("street", e.target.value)}
/>
</Field>
<Field label="Město">
<TextField
value={form.city}
onChange={(e) => updateField("city", e.target.value)}
/>
</Field>
</Box>
<Box
sx={{
display: "grid",
gridTemplateColumns: { xs: "1fr", sm: "1fr 1fr" },
gap: 2,
}}
>
<Field label="PSČ">
<TextField
value={form.postal_code}
onChange={(e) => updateField("postal_code", e.target.value)}
/>
</Field>
<Field label="Země">
<TextField
value={form.country}
onChange={(e) => updateField("country", e.target.value)}
/>
</Field>
</Box>
<Box
sx={{
display: "grid",
gridTemplateColumns: { xs: "1fr", sm: "1fr 1fr" },
gap: 2,
}}
>
<Field label="IČO">
<TextField
value={form.company_id}
onChange={(e) => updateField("company_id", e.target.value)}
/>
</Field>
<Field label="DIČ">
<TextField
value={form.vat_id}
onChange={(e) => updateField("vat_id", e.target.value)}
/>
</Field>
</Box>
<Box>
<Typography
variant="body2"
sx={{
display: "block",
mb: 0.5,
fontWeight: 600,
color: "text.secondary",
}}
>
Vlastní pole
</Typography>
{customFields.map((field, idx) => (
<Box key={field._key} sx={{ mb: 1 }}>
<Box
sx={{
display: "grid",
gridTemplateColumns: { xs: "1fr", sm: "1fr 1fr" },
gap: 2,
alignItems: "flex-start",
}}
>
<TextField
label={idx === 0 ? "Název" : undefined}
value={field.name}
onChange={(e) => {
const updated = [...customFields];
updated[idx] = {
...updated[idx],
name: e.target.value,
};
setCustomFields(updated);
}}
placeholder="Např. Tel."
/>
<Box
sx={{
display: "flex",
gap: 0.5,
alignItems: idx === 0 ? "flex-end" : "center",
}}
>
<TextField
label={idx === 0 ? "Hodnota" : undefined}
value={field.value}
onChange={(e) => {
const updated = [...customFields];
updated[idx] = {
...updated[idx],
value: e.target.value,
};
setCustomFields(updated);
}}
sx={{ flex: 1 }}
/>
<IconButton
size="small"
color="error"
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),
);
}}
title="Odebrat pole"
aria-label="Odebrat pole"
>
{RemoveIcon}
</IconButton>
</Box>
</Box>
<Box sx={{ mt: 0.5 }}>
<CheckboxField
label={
<Typography
variant="body2"
sx={{ fontSize: "0.8rem" }}
>
Zobrazit název v PDF
</Typography>
}
checked={field.showLabel !== false}
onChange={(checked) => {
const updated = [...customFields];
updated[idx] = {
...updated[idx],
showLabel: checked,
};
setCustomFields(updated);
}}
/>
</Box>
</Box>
))}
<Button
variant="outlined"
color="inherit"
size="small"
startIcon={PlusIcon}
onClick={() =>
setCustomFields([
...customFields,
{
name: "",
value: "",
showLabel: true,
_key: `cf-${Date.now()}`,
},
])
}
sx={{ mt: 0.5 }}
>
Přidat pole
</Button>
</Box>
</Card>
</motion.div>
)}
{/* Bank Accounts */}
{(canManageBanking || !embedded) && (
<motion.div
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, delay: 0.08 }}
>
<Card>
<Typography variant="h6" sx={{ mb: 2 }}>
Bankovní účty
</Typography>
{bankLoading ? (
<LoadingState />
) : (
<>
{bankAccountsList.length > 0 && (
<Box sx={{ mb: 2 }}>
<DataTable<BankAccount>
columns={bankColumns}
rows={bankAccountsList}
rowKey={(acc) => acc.id}
rowSx={(acc) =>
editingBank === acc.id
? { bgcolor: "action.selected" }
: {}
}
/>
</Box>
)}
<Box
sx={{
bgcolor: "action.hover",
borderRadius: 2,
p: 2,
}}
>
<Typography
variant="subtitle2"
color="text.secondary"
sx={{ mb: 1.5 }}
>
{editingBank !== null
? "Upravit účet"
: "Přidat nový účet"}
</Typography>
<Box
sx={{
display: "grid",
gridTemplateColumns: { xs: "1fr", sm: "1fr 1fr" },
gap: 2,
}}
>
<Field label="Název účtu" required>
<TextField
value={bankForm.account_name}
onChange={(e) =>
setBankForm((f) => ({
...f,
account_name: e.target.value,
}))
}
placeholder="Např. Hlavní CZK účet"
/>
</Field>
<Field label="Název banky">
<TextField
value={bankForm.bank_name}
onChange={(e) =>
setBankForm((f) => ({
...f,
bank_name: e.target.value,
}))
}
placeholder="Např. MONETA Money Bank, a.s."
/>
</Field>
<Field label="Číslo účtu">
<TextField
value={bankForm.account_number}
onChange={(e) =>
setBankForm((f) => ({
...f,
account_number: e.target.value,
}))
}
placeholder="123456789/0600"
/>
</Field>
<Field label="Měna">
<Select
value={bankForm.currency}
onChange={(v) =>
setBankForm((f) => ({
...f,
currency: v,
}))
}
options={form.available_currencies.map((c) => ({
value: c,
label: c,
}))}
/>
</Field>
<Field label="IBAN">
<TextField
value={bankForm.iban}
onChange={(e) =>
setBankForm((f) => ({
...f,
iban: e.target.value,
}))
}
placeholder="CZ65 0800 0000 1920 0014 5399"
/>
</Field>
<Field label="BIC / SWIFT">
<TextField
value={bankForm.bic}
onChange={(e) =>
setBankForm((f) => ({
...f,
bic: e.target.value,
}))
}
placeholder="GIBACZPX"
/>
</Field>
</Box>
<CheckboxField
label="Výchozí účet (použije se automaticky při vytváření faktury)"
checked={bankForm.is_default}
onChange={(checked) =>
setBankForm((f) => ({
...f,
is_default: checked,
}))
}
/>
<Box sx={{ display: "flex", gap: 1, mt: 1 }}>
<Button
size="small"
startIcon={editingBank === null ? PlusIcon : undefined}
onClick={handleBankSave}
disabled={bankSaving}
>
{bankSaving
? "Ukládání..."
: editingBank !== null
? "Uložit změny"
: "Přidat účet"}
</Button>
{editingBank !== null && (
<Button
size="small"
variant="outlined"
color="inherit"
onClick={resetBankForm}
>
Zrušit
</Button>
)}
</Box>
</Box>
</>
)}
</Card>
</motion.div>
)}
{/* PDF Field Order */}
{(canEditCompany || !embedded) && (
<motion.div
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, delay: 0.08 }}
>
<Card>
<Typography variant="h6" sx={{ mb: 2 }}>
Pořadí polí dodavatele v PDF
</Typography>
<Typography
variant="caption"
color="text.secondary"
sx={{ display: "block", mb: 1.5 }}
>
Určuje pořadí řádků v adresním bloku dodavatele na PDF nabídce.
</Typography>
<Box sx={{ display: "flex", flexDirection: "column", gap: 0.5 }}>
{fullFieldOrder.map((key, index) => (
<Box
key={key}
sx={{
display: "flex",
alignItems: "center",
gap: 1,
px: 1,
py: 0.5,
border: 1,
borderColor: "divider",
borderRadius: 1,
}}
>
<Box sx={{ display: "flex", flexDirection: "column" }}>
<IconButton
size="small"
onClick={() => moveField(index, -1)}
disabled={index === 0}
title="Nahoru"
aria-label="Nahoru"
sx={{ p: 0.25 }}
>
{UpIcon}
</IconButton>
<IconButton
size="small"
onClick={() => moveField(index, 1)}
disabled={index === fullFieldOrder.length - 1}
title="Dolů"
aria-label="Dolů"
sx={{ p: 0.25 }}
>
{DownIcon}
</IconButton>
</Box>
<Typography
variant="body2"
sx={{
color: key.startsWith("custom_")
? "primary.main"
: "text.primary",
fontWeight: key.startsWith("custom_") ? 600 : 400,
}}
>
{getFieldDisplayName(key)}
</Typography>
</Box>
))}
</Box>
</Card>
</motion.div>
)}
{/* Document Numbering */}
{(canEditCompany || !embedded) && (
<motion.div
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, delay: 0.1 }}
>
<Card>
<Typography variant="h6" sx={{ mb: 2 }}>
Číslování dokladů
</Typography>
<Box
sx={{
p: 1.5,
bgcolor: "action.hover",
borderRadius: 1,
fontSize: "0.8rem",
color: "text.secondary",
mb: 2,
}}
>
<strong>Dostupné zástupné znaky:</strong>{" "}
<code>{"{YYYY}"}</code> rok, <code>{"{YY}"}</code> rok (2
číslice), <code>{"{PREFIX}"}</code> prefix (nabídky / sklad),{" "}
<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)
</Box>
{[
{
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 přijaté",
patternKey: "order_number_pattern" as const,
defaultPattern: "{YY}{CODE}{NNNN}",
prefixKey: null,
codeKey: "order_type_code" as const,
codeLabel: "Typový kód",
// Mirror the server generator's code fallback so the preview
// matches the real generated number when the field is empty.
defaultCode: "71",
},
{
label: "Objednávky vydané",
patternKey: "issued_order_number_pattern" as const,
defaultPattern: "{YY}{CODE}{NNNN}",
prefixKey: null,
codeKey: "issued_order_type_code" as const,
codeLabel: "Typový kód",
defaultCode: "72",
},
{
label: "Projekty",
patternKey: "project_number_pattern" as const,
defaultPattern: "{YY}{CODE}{NNNN}",
prefixKey: null,
codeKey: "project_type_code" as const,
codeLabel: "Typový kód",
defaultCode: "73",
},
{
label: "Faktury",
patternKey: "invoice_number_pattern" as const,
defaultPattern: "{YY}{CODE}{NNNN}",
prefixKey: null,
codeKey: "invoice_type_code" as const,
codeLabel: "Typový kód",
defaultCode: "81",
},
{
label: "Sklad — Příjmy",
patternKey: "warehouse_receipt_number_pattern" as const,
defaultPattern: "{PREFIX}-{YYYY}-{NNN}",
prefixKey: "warehouse_receipt_prefix" as const,
prefixLabel: "Prefix",
codeKey: null,
},
{
label: "Sklad — Výdeje",
patternKey: "warehouse_issue_number_pattern" as const,
defaultPattern: "{PREFIX}-{YYYY}-{NNN}",
prefixKey: "warehouse_issue_prefix" as const,
prefixLabel: "Prefix",
codeKey: null,
},
{
label: "Sklad — Inventarizace",
patternKey: "warehouse_inventory_number_pattern" as const,
defaultPattern: "{PREFIX}-{YYYY}-{NNN}",
prefixKey: "warehouse_inventory_prefix" as const,
prefixLabel: "Prefix",
codeKey: null,
},
].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] ||
(cfg as { defaultCode?: string }).defaultCode ||
""
: "";
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 (
<Box key={cfg.patternKey}>
{idx > 0 && (
<Box
sx={{
borderTop: 1,
borderColor: "divider",
my: 2,
}}
/>
)}
<Typography variant="body1" sx={{ fontWeight: 600, mb: 1 }}>
{cfg.label}
</Typography>
<Box
sx={{
display: "flex",
flexWrap: "wrap",
gap: 2,
alignItems: "flex-start",
}}
>
<Box sx={{ flex: "1 1 200px" }}>
<Field label="Formát">
<TextField
value={form[cfg.patternKey]}
onChange={(e) =>
updateField(cfg.patternKey, e.target.value)
}
placeholder={cfg.defaultPattern}
/>
</Field>
</Box>
{cfg.prefixKey && (
<Box sx={{ width: 120 }}>
<Field label={cfg.prefixLabel || "Prefix"}>
<TextField
value={form[cfg.prefixKey]}
onChange={(e) =>
updateField(cfg.prefixKey, e.target.value)
}
/>
</Field>
</Box>
)}
{cfg.codeKey && (
<Box sx={{ width: 120 }}>
<Field label={cfg.codeLabel || "Kód"}>
<TextField
value={form[cfg.codeKey]}
onChange={(e) =>
updateField(cfg.codeKey, e.target.value)
}
/>
</Field>
</Box>
)}
</Box>
<Typography variant="caption" color="text.secondary">
Ukázka:{" "}
<Box component="strong" sx={{ color: "text.primary" }}>
{preview}
</Box>
</Typography>
</Box>
);
})}
</Card>
</motion.div>
)}
{/* Currency & VAT */}
{(canEditCompany || !embedded) && (
<motion.div
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, delay: 0.12 }}
>
<Card>
<Typography variant="h6" sx={{ mb: 2 }}>
Měna a DPH
</Typography>
<Box
sx={{
display: "grid",
gridTemplateColumns: { xs: "1fr", sm: "1fr 1fr" },
gap: 2,
}}
>
<Field label="Výchozí měna">
<Select
value={form.default_currency}
onChange={(v) => updateField("default_currency", v)}
options={form.available_currencies.map((c) => ({
value: c,
label: c,
}))}
/>
</Field>
<Field label="Výchozí sazba DPH (%)">
<TextField
type="number"
value={form.default_vat_rate}
onChange={(e) =>
updateField("default_vat_rate", Number(e.target.value))
}
slotProps={{ htmlInput: { min: 0, step: 1 } }}
/>
</Field>
<Field label="Dostupné měny">
<TextField
value={form.available_currencies.join(", ")}
onChange={(e) =>
setForm((prev) => ({
...prev,
available_currencies: e.target.value
.split(",")
.map((s) => s.trim())
.filter(Boolean),
}))
}
placeholder="CZK, EUR, USD"
/>
</Field>
<Field label="Dostupné sazby DPH (%)">
<TextField
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)),
}))
}
placeholder="0, 12, 21"
/>
</Field>
</Box>
</Card>
</motion.div>
)}
{/* Logo */}
{(canEditCompany || !embedded) && (
<motion.div
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, delay: 0.14 }}
>
<Card>
<Typography variant="h6" sx={{ mb: 2 }}>
Logo
</Typography>
<Box
sx={{
display: "grid",
gridTemplateColumns: { xs: "1fr", sm: "1fr 1fr" },
gap: 2,
}}
>
<Box>
<Typography
variant="body2"
sx={{
display: "block",
mb: 0.5,
fontWeight: 600,
color: "text.secondary",
}}
>
Logo (světlý režim)
</Typography>
{logoUrl && (
<Box
sx={{
mb: 1,
p: 1,
border: 1,
borderColor: "divider",
borderRadius: 1,
bgcolor: "background.paper",
display: "inline-block",
}}
>
<Box
component="img"
src={logoUrl}
alt="Logo (světlý režim)"
sx={{ maxWidth: 200, maxHeight: 80, display: "block" }}
/>
</Box>
)}
<FileUpload
files={[]}
accept="image/*"
buttonLabel={uploadingLogo ? "Nahrávání..." : "Nahrát logo"}
disabled={uploadingLogo}
onFilesChange={(files) => {
if (files[0]) handleLogoUpload(files[0], "light");
}}
/>
<Typography
variant="caption"
color="text.secondary"
sx={{ display: "block", mt: 0.5 }}
>
PNG, JPEG, GIF nebo WebP, max 5 MB
</Typography>
</Box>
<Box>
<Typography
variant="body2"
sx={{
display: "block",
mb: 0.5,
fontWeight: 600,
color: "text.secondary",
}}
>
Logo (tmavý režim)
</Typography>
{logoUrlDark && (
<Box
sx={{
mb: 1,
p: 1,
border: 1,
borderColor: "divider",
borderRadius: 1,
bgcolor: "background.paper",
display: "inline-block",
}}
>
<Box
component="img"
src={logoUrlDark}
alt="Logo (tmavý režim)"
sx={{ maxWidth: 200, maxHeight: 80, display: "block" }}
/>
</Box>
)}
<FileUpload
files={[]}
accept="image/*"
buttonLabel={
uploadingLogoDark ? "Nahrávání..." : "Nahrát logo"
}
disabled={uploadingLogoDark}
onFilesChange={(files) => {
if (files[0]) handleLogoUpload(files[0], "dark");
}}
/>
<Typography
variant="caption"
color="text.secondary"
sx={{ display: "block", mt: 0.5 }}
>
PNG, JPEG, GIF nebo WebP, max 5 MB
</Typography>
</Box>
</Box>
</Card>
</motion.div>
)}
</Box>
{embedded && canEditCompany && (
<motion.div
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, delay: 0.2 }}
>
<Button
onClick={handleSave}
fullWidth
sx={{ mt: 2 }}
disabled={saving}
>
{saving ? "Ukládání..." : "Uložit nastavení firmy"}
</Button>
</motion.div>
)}
<ConfirmDialog
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"
confirmVariant="danger"
/>
</Box>
);
}