955 lines
34 KiB
TypeScript
955 lines
34 KiB
TypeScript
import { useState, useRef, type ReactNode } from "react";
|
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
|
import { useAlert } from "../context/AlertContext";
|
|
import { useAuth } from "../context/AuthContext";
|
|
import { motion, AnimatePresence } from "framer-motion";
|
|
import ConfirmModal from "../components/ConfirmModal";
|
|
import FormField from "../components/FormField";
|
|
import Forbidden from "../components/Forbidden";
|
|
import RichEditor from "../components/RichEditor";
|
|
import useModalLock from "../hooks/useModalLock";
|
|
import { offerTemplatesOptions } from "../lib/queries/offers";
|
|
import { Skeleton } from "boneyard-js/react";
|
|
import OffersTemplatesFixture from "../fixtures/OffersTemplatesFixture";
|
|
|
|
import apiFetch from "../utils/api";
|
|
|
|
const API_BASE = "/api/admin";
|
|
|
|
interface ItemTemplate {
|
|
id: number;
|
|
name: string;
|
|
description: string;
|
|
default_price: number;
|
|
category: string;
|
|
}
|
|
|
|
interface ScopeSection {
|
|
_key: string;
|
|
title: string;
|
|
title_cz: string;
|
|
content: string;
|
|
}
|
|
|
|
interface ScopeTemplate {
|
|
id: number;
|
|
name: string;
|
|
sections?: ScopeSection[];
|
|
}
|
|
|
|
interface ItemForm {
|
|
name: string;
|
|
description: string;
|
|
default_price: number;
|
|
category: string;
|
|
}
|
|
|
|
interface ScopeForm {
|
|
name: string;
|
|
sections: ScopeSection[];
|
|
}
|
|
|
|
export default function OffersTemplates() {
|
|
const { hasPermission } = useAuth();
|
|
const [activeTab, setActiveTab] = useState<"items" | "scopes">("items");
|
|
|
|
if (!hasPermission("settings.manage")) return <Forbidden />;
|
|
|
|
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">Šablony</h1>
|
|
<p className="admin-page-subtitle">
|
|
Šablony položek a rozsahu projektu
|
|
</p>
|
|
</div>
|
|
</motion.div>
|
|
|
|
<div className="admin-tabs mb-4">
|
|
<button
|
|
className={`admin-tab ${activeTab === "items" ? "active" : ""}`}
|
|
onClick={() => setActiveTab("items")}
|
|
>
|
|
Šablony položek
|
|
</button>
|
|
<button
|
|
className={`admin-tab ${activeTab === "scopes" ? "active" : ""}`}
|
|
onClick={() => setActiveTab("scopes")}
|
|
>
|
|
Šablony rozsahu
|
|
</button>
|
|
</div>
|
|
|
|
{activeTab === "items" ? <ItemTemplatesTab /> : <ScopeTemplatesTab />}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// --- Item Templates Tab ---
|
|
|
|
function ItemTemplatesTab() {
|
|
const alert = useAlert();
|
|
const queryClient = useQueryClient();
|
|
const { data: templates = [], isPending } = useQuery(
|
|
offerTemplatesOptions("items"),
|
|
) as { data: ItemTemplate[]; isPending: boolean };
|
|
const [showModal, setShowModal] = useState(false);
|
|
const [editingTemplate, setEditingTemplate] = useState<ItemTemplate | null>(
|
|
null,
|
|
);
|
|
const [saving, setSaving] = useState(false);
|
|
const [form, setForm] = useState<ItemForm>({
|
|
name: "",
|
|
description: "",
|
|
default_price: 0,
|
|
category: "",
|
|
});
|
|
const [deleteConfirm, setDeleteConfirm] = useState<{
|
|
show: boolean;
|
|
template: ItemTemplate | null;
|
|
}>({ show: false, template: null });
|
|
const [deleting, setDeleting] = useState(false);
|
|
|
|
useModalLock(showModal);
|
|
|
|
const openCreate = () => {
|
|
setEditingTemplate(null);
|
|
setForm({ name: "", description: "", default_price: 0, category: "" });
|
|
setShowModal(true);
|
|
};
|
|
|
|
const openEdit = (t: ItemTemplate) => {
|
|
setEditingTemplate(t);
|
|
setForm({
|
|
name: t.name || "",
|
|
description: t.description || "",
|
|
default_price: t.default_price || 0,
|
|
category: t.category || "",
|
|
});
|
|
setShowModal(true);
|
|
};
|
|
|
|
const handleSubmit = async () => {
|
|
if (!form.name.trim()) {
|
|
alert.error("Název šablony je povinný");
|
|
return;
|
|
}
|
|
setSaving(true);
|
|
try {
|
|
const body = editingTemplate ? { ...form, id: editingTemplate.id } : form;
|
|
const response = await apiFetch(
|
|
`${API_BASE}/offers-templates?action=item`,
|
|
{
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify(body),
|
|
},
|
|
);
|
|
const result = await response.json();
|
|
if (result.success) {
|
|
setShowModal(false);
|
|
await new Promise((r) => setTimeout(r, 300));
|
|
alert.success(result.message);
|
|
queryClient.invalidateQueries({ queryKey: ["offer-templates"] });
|
|
} else {
|
|
alert.error(result.error);
|
|
}
|
|
} catch {
|
|
alert.error("Chyba připojení");
|
|
} finally {
|
|
setSaving(false);
|
|
}
|
|
};
|
|
|
|
const handleDelete = async () => {
|
|
if (!deleteConfirm.template) return;
|
|
setDeleting(true);
|
|
try {
|
|
const response = await apiFetch(
|
|
`${API_BASE}/offers-templates?action=item&id=${deleteConfirm.template.id}`,
|
|
{ method: "DELETE" },
|
|
);
|
|
const result = await response.json();
|
|
if (result.success) {
|
|
setDeleteConfirm({ show: false, template: null });
|
|
alert.success(result.message);
|
|
queryClient.invalidateQueries({ queryKey: ["offer-templates"] });
|
|
} else {
|
|
alert.error(result.error);
|
|
}
|
|
} catch {
|
|
alert.error("Chyba připojení");
|
|
} finally {
|
|
setDeleting(false);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<Skeleton
|
|
name="offers-templates"
|
|
loading={isPending}
|
|
fixture={<OffersTemplatesFixture />}
|
|
>
|
|
<>
|
|
<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 flex-between">
|
|
<h3 className="admin-card-title">
|
|
Šablony položek ({templates.length})
|
|
</h3>
|
|
<button
|
|
onClick={openCreate}
|
|
className="admin-btn admin-btn-primary admin-btn-sm"
|
|
>
|
|
<svg
|
|
width="16"
|
|
height="16"
|
|
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
|
|
</button>
|
|
</div>
|
|
<div className="admin-card-body">
|
|
{templates.length === 0 ? (
|
|
<div className="admin-empty-state">
|
|
<p>Zatím žádné šablony položek.</p>
|
|
</div>
|
|
) : (
|
|
<div className="admin-table-responsive">
|
|
<table className="admin-table">
|
|
<thead>
|
|
<tr>
|
|
<th>Název</th>
|
|
<th>Popis</th>
|
|
<th>Cena</th>
|
|
<th>Kategorie</th>
|
|
<th>Akce</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{templates.map((t) => (
|
|
<tr key={t.id}>
|
|
<td className="fw-500">{t.name}</td>
|
|
<td style={{ color: "var(--text-secondary)" }}>
|
|
{t.description || "—"}
|
|
</td>
|
|
<td>{Number(t.default_price).toFixed(2)}</td>
|
|
<td style={{ color: "var(--text-secondary)" }}>
|
|
{t.category || "—"}
|
|
</td>
|
|
<td>
|
|
<div className="admin-table-actions">
|
|
<button
|
|
onClick={() => openEdit(t)}
|
|
className="admin-btn-icon"
|
|
title="Upravit"
|
|
aria-label="Upravit"
|
|
>
|
|
<svg
|
|
width="18"
|
|
height="18"
|
|
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
|
|
onClick={() =>
|
|
setDeleteConfirm({ show: true, template: t })
|
|
}
|
|
className="admin-btn-icon danger"
|
|
title="Smazat"
|
|
aria-label="Smazat"
|
|
>
|
|
<svg
|
|
width="18"
|
|
height="18"
|
|
viewBox="0 0 24 24"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
strokeWidth="2"
|
|
>
|
|
<polyline points="3 6 5 6 21 6" />
|
|
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</motion.div>
|
|
|
|
{/* Item Template Modal */}
|
|
<AnimatePresence>
|
|
{showModal && (
|
|
<motion.div
|
|
className="admin-modal-overlay"
|
|
initial={{ opacity: 0 }}
|
|
animate={{ opacity: 1 }}
|
|
exit={{ opacity: 0 }}
|
|
transition={{ duration: 0.2 }}
|
|
>
|
|
<div
|
|
className="admin-modal-backdrop"
|
|
onClick={() => setShowModal(false)}
|
|
/>
|
|
<motion.div
|
|
className="admin-modal"
|
|
initial={{ opacity: 0, scale: 0.95, y: 20 }}
|
|
animate={{ opacity: 1, scale: 1, y: 0 }}
|
|
exit={{ opacity: 0, scale: 0.95, y: 20 }}
|
|
transition={{ duration: 0.2 }}
|
|
>
|
|
<div className="admin-modal-header">
|
|
<h2 className="admin-modal-title">
|
|
{editingTemplate
|
|
? "Upravit šablonu"
|
|
: "Nová šablona položky"}
|
|
</h2>
|
|
</div>
|
|
<div className="admin-modal-body">
|
|
<div className="admin-form">
|
|
<FormField label="Název" required>
|
|
<input
|
|
type="text"
|
|
value={form.name}
|
|
onChange={(e) =>
|
|
setForm((p) => ({ ...p, name: e.target.value }))
|
|
}
|
|
className="admin-form-input"
|
|
/>
|
|
</FormField>
|
|
<FormField label="Popis">
|
|
<textarea
|
|
value={form.description}
|
|
onChange={(e) =>
|
|
setForm((p) => ({
|
|
...p,
|
|
description: e.target.value,
|
|
}))
|
|
}
|
|
className="admin-form-input"
|
|
rows={2}
|
|
/>
|
|
</FormField>
|
|
<div className="admin-form-row">
|
|
<FormField label="Výchozí cena">
|
|
<input
|
|
type="number"
|
|
value={form.default_price}
|
|
onChange={(e) =>
|
|
setForm((p) => ({
|
|
...p,
|
|
default_price: e.target.value,
|
|
}))
|
|
}
|
|
className="admin-form-input"
|
|
step="0.01"
|
|
/>
|
|
</FormField>
|
|
<FormField label="Kategorie">
|
|
<input
|
|
type="text"
|
|
value={form.category}
|
|
onChange={(e) =>
|
|
setForm((p) => ({ ...p, category: e.target.value }))
|
|
}
|
|
className="admin-form-input"
|
|
/>
|
|
</FormField>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div className="admin-modal-footer">
|
|
<button
|
|
type="button"
|
|
onClick={() => setShowModal(false)}
|
|
className="admin-btn admin-btn-secondary"
|
|
disabled={saving}
|
|
>
|
|
Zrušit
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={handleSubmit}
|
|
className="admin-btn admin-btn-primary"
|
|
disabled={saving}
|
|
>
|
|
{saving && (
|
|
<>
|
|
<div className="admin-spinner admin-spinner-sm" />
|
|
Ukládání...
|
|
</>
|
|
)}
|
|
{!saving && (editingTemplate ? "Uložit" : "Vytvořit")}
|
|
</button>
|
|
</div>
|
|
</motion.div>
|
|
</motion.div>
|
|
)}
|
|
</AnimatePresence>
|
|
|
|
<ConfirmModal
|
|
isOpen={deleteConfirm.show}
|
|
onClose={() => setDeleteConfirm({ show: false, template: null })}
|
|
onConfirm={handleDelete}
|
|
title="Smazat šablonu"
|
|
message={`Opravdu chcete smazat šablonu "${deleteConfirm.template?.name}"?`}
|
|
confirmText="Smazat"
|
|
cancelText="Zrušit"
|
|
type="danger"
|
|
loading={deleting}
|
|
/>
|
|
</>
|
|
</Skeleton>
|
|
);
|
|
}
|
|
|
|
// --- Scope Templates Tab ---
|
|
|
|
function ScopeTemplatesTab() {
|
|
const alert = useAlert();
|
|
const queryClient = useQueryClient();
|
|
const { data: templates = [], isPending } = useQuery(
|
|
offerTemplatesOptions(),
|
|
) as { data: ScopeTemplate[]; isPending: boolean };
|
|
const [showModal, setShowModal] = useState(false);
|
|
const [editingTemplate, setEditingTemplate] = useState<ScopeTemplate | null>(
|
|
null,
|
|
);
|
|
const [saving, setSaving] = useState(false);
|
|
const [form, setForm] = useState<ScopeForm>({ name: "", sections: [] });
|
|
const sectionKeyCounter = useRef(0);
|
|
const [deleteConfirm, setDeleteConfirm] = useState<{
|
|
show: boolean;
|
|
template: ScopeTemplate | null;
|
|
}>({ show: false, template: null });
|
|
const [deleting, setDeleting] = useState(false);
|
|
|
|
useModalLock(showModal);
|
|
|
|
const openCreate = () => {
|
|
setEditingTemplate(null);
|
|
setForm({
|
|
name: "",
|
|
sections: [
|
|
{
|
|
_key: `sc-${++sectionKeyCounter.current}`,
|
|
title: "",
|
|
title_cz: "",
|
|
content: "",
|
|
},
|
|
],
|
|
});
|
|
setShowModal(true);
|
|
};
|
|
|
|
const openEdit = async (t: ScopeTemplate) => {
|
|
try {
|
|
const response = await apiFetch(`${API_BASE}/offers-templates/${t.id}`);
|
|
const result = await response.json();
|
|
if (result.success) {
|
|
setEditingTemplate(result.data);
|
|
const templateSections =
|
|
result.data.scope_template_sections || result.data.sections || [];
|
|
setForm({
|
|
name: result.data.name || "",
|
|
sections: templateSections.length
|
|
? templateSections.map(
|
|
(s: {
|
|
title?: string;
|
|
title_cz?: string;
|
|
content?: string;
|
|
}) => ({
|
|
_key: `sc-${++sectionKeyCounter.current}`,
|
|
title: s.title || "",
|
|
title_cz: s.title_cz || "",
|
|
content: s.content || "",
|
|
}),
|
|
)
|
|
: [
|
|
{
|
|
_key: `sc-${++sectionKeyCounter.current}`,
|
|
title: "",
|
|
title_cz: "",
|
|
content: "",
|
|
},
|
|
],
|
|
});
|
|
setShowModal(true);
|
|
}
|
|
} catch {
|
|
alert.error("Nepodařilo se načíst detail šablony");
|
|
}
|
|
};
|
|
|
|
const addSection = () => {
|
|
setForm((prev) => ({
|
|
...prev,
|
|
sections: [
|
|
...prev.sections,
|
|
{
|
|
_key: `sc-${++sectionKeyCounter.current}`,
|
|
title: "",
|
|
title_cz: "",
|
|
content: "",
|
|
},
|
|
],
|
|
}));
|
|
};
|
|
|
|
const removeSection = (index: number) => {
|
|
setForm((prev) => ({
|
|
...prev,
|
|
sections: prev.sections.filter((_, i) => i !== index),
|
|
}));
|
|
};
|
|
|
|
const updateSection = (index: number, field: string, value: string) => {
|
|
setForm((prev) => ({
|
|
...prev,
|
|
sections: prev.sections.map((s, i) =>
|
|
i === index ? { ...s, [field]: value } : s,
|
|
),
|
|
}));
|
|
};
|
|
|
|
const moveSection = (index: number, direction: number) => {
|
|
setForm((prev) => {
|
|
const newSections = [...prev.sections];
|
|
const targetIndex = index + direction;
|
|
if (targetIndex < 0 || targetIndex >= newSections.length) return prev;
|
|
[newSections[index], newSections[targetIndex]] = [
|
|
newSections[targetIndex],
|
|
newSections[index],
|
|
];
|
|
return { ...prev, sections: newSections };
|
|
});
|
|
};
|
|
|
|
const handleSubmit = async () => {
|
|
if (!form.name.trim()) {
|
|
alert.error("Název šablony je povinný");
|
|
return;
|
|
}
|
|
setSaving(true);
|
|
try {
|
|
const url = editingTemplate
|
|
? `${API_BASE}/offers-templates/${editingTemplate.id}`
|
|
: `${API_BASE}/offers-templates`;
|
|
const method = editingTemplate ? "PUT" : "POST";
|
|
const response = await apiFetch(url, {
|
|
method,
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify(form),
|
|
});
|
|
const result = await response.json();
|
|
if (result.success) {
|
|
setShowModal(false);
|
|
await new Promise((r) => setTimeout(r, 300));
|
|
alert.success(result.message);
|
|
queryClient.invalidateQueries({ queryKey: ["offer-templates"] });
|
|
} else {
|
|
alert.error(result.error);
|
|
}
|
|
} catch {
|
|
alert.error("Chyba připojení");
|
|
} finally {
|
|
setSaving(false);
|
|
}
|
|
};
|
|
|
|
const handleDelete = async () => {
|
|
if (!deleteConfirm.template) return;
|
|
setDeleting(true);
|
|
try {
|
|
const response = await apiFetch(
|
|
`${API_BASE}/offers-templates/${deleteConfirm.template.id}`,
|
|
{ method: "DELETE" },
|
|
);
|
|
const result = await response.json();
|
|
if (result.success) {
|
|
setDeleteConfirm({ show: false, template: null });
|
|
alert.success(result.message);
|
|
queryClient.invalidateQueries({ queryKey: ["offer-templates"] });
|
|
} else {
|
|
alert.error(result.error);
|
|
}
|
|
} catch {
|
|
alert.error("Chyba připojení");
|
|
} finally {
|
|
setDeleting(false);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<Skeleton
|
|
name="offers-templates"
|
|
loading={isPending}
|
|
fixture={<OffersTemplatesFixture />}
|
|
>
|
|
<>
|
|
<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 flex-between">
|
|
<h3 className="admin-card-title">
|
|
Šablony rozsahu ({templates.length})
|
|
</h3>
|
|
<button
|
|
onClick={openCreate}
|
|
className="admin-btn admin-btn-primary admin-btn-sm"
|
|
>
|
|
<svg
|
|
width="16"
|
|
height="16"
|
|
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
|
|
</button>
|
|
</div>
|
|
<div className="admin-card-body">
|
|
{templates.length === 0 ? (
|
|
<div className="admin-empty-state">
|
|
<p>Zatím žádné šablony rozsahu.</p>
|
|
</div>
|
|
) : (
|
|
<div className="admin-table-responsive">
|
|
<table className="admin-table">
|
|
<thead>
|
|
<tr>
|
|
<th>Název</th>
|
|
<th>Akce</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{templates.map((t) => (
|
|
<tr key={t.id}>
|
|
<td className="fw-500">{t.name}</td>
|
|
<td>
|
|
<div className="admin-table-actions">
|
|
<button
|
|
onClick={() => openEdit(t)}
|
|
className="admin-btn-icon"
|
|
title="Upravit"
|
|
aria-label="Upravit"
|
|
>
|
|
<svg
|
|
width="18"
|
|
height="18"
|
|
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
|
|
onClick={() =>
|
|
setDeleteConfirm({ show: true, template: t })
|
|
}
|
|
className="admin-btn-icon danger"
|
|
title="Smazat"
|
|
aria-label="Smazat"
|
|
>
|
|
<svg
|
|
width="18"
|
|
height="18"
|
|
viewBox="0 0 24 24"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
strokeWidth="2"
|
|
>
|
|
<polyline points="3 6 5 6 21 6" />
|
|
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</motion.div>
|
|
|
|
{/* Scope Template Modal (large) */}
|
|
<AnimatePresence>
|
|
{showModal && (
|
|
<motion.div
|
|
className="admin-modal-overlay"
|
|
initial={{ opacity: 0 }}
|
|
animate={{ opacity: 1 }}
|
|
exit={{ opacity: 0 }}
|
|
transition={{ duration: 0.2 }}
|
|
>
|
|
<div
|
|
className="admin-modal-backdrop"
|
|
onClick={() => setShowModal(false)}
|
|
/>
|
|
<motion.div
|
|
className="admin-modal admin-modal-lg"
|
|
initial={{ opacity: 0, scale: 0.95, y: 20 }}
|
|
animate={{ opacity: 1, scale: 1, y: 0 }}
|
|
exit={{ opacity: 0, scale: 0.95, y: 20 }}
|
|
transition={{ duration: 0.2 }}
|
|
>
|
|
<div className="admin-modal-header">
|
|
<h2 className="admin-modal-title">
|
|
{editingTemplate
|
|
? "Upravit šablonu rozsahu"
|
|
: "Nová šablona rozsahu"}
|
|
</h2>
|
|
</div>
|
|
<div className="admin-modal-body">
|
|
<div className="admin-form">
|
|
<FormField label="Název šablony" required>
|
|
<input
|
|
type="text"
|
|
value={form.name}
|
|
onChange={(e) =>
|
|
setForm((p) => ({ ...p, name: e.target.value }))
|
|
}
|
|
className="admin-form-input"
|
|
/>
|
|
</FormField>
|
|
|
|
<div className="admin-form-group">
|
|
<label className="admin-form-label mb-2">Sekce</label>
|
|
<div className="admin-scope-list">
|
|
{form.sections.map((section, index) => (
|
|
<div
|
|
key={section._key}
|
|
className="admin-scope-section"
|
|
>
|
|
<div className="admin-scope-section-header">
|
|
<span className="admin-scope-number">
|
|
{index + 1}.
|
|
</span>
|
|
<span className="admin-scope-title">
|
|
{section.title ||
|
|
section.title_cz ||
|
|
`Sekce ${index + 1}`}
|
|
</span>
|
|
<div className="admin-scope-actions">
|
|
<button
|
|
type="button"
|
|
onClick={() => moveSection(index, -1)}
|
|
disabled={index === 0}
|
|
className="admin-btn-icon"
|
|
title="Posunout nahoru"
|
|
aria-label="Posunout nahoru"
|
|
>
|
|
<svg
|
|
width="14"
|
|
height="14"
|
|
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={() => moveSection(index, 1)}
|
|
disabled={index === form.sections.length - 1}
|
|
className="admin-btn-icon"
|
|
title="Posunout dolů"
|
|
aria-label="Posunout dolů"
|
|
>
|
|
<svg
|
|
width="14"
|
|
height="14"
|
|
viewBox="0 0 24 24"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
strokeWidth="2"
|
|
>
|
|
<path d="M6 9l6 6 6-6" />
|
|
</svg>
|
|
</button>
|
|
{form.sections.length > 1 && (
|
|
<button
|
|
type="button"
|
|
onClick={() => removeSection(index)}
|
|
className="admin-btn-icon danger"
|
|
title="Odebrat"
|
|
aria-label="Odebrat"
|
|
>
|
|
<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>
|
|
</div>
|
|
<div className="admin-form">
|
|
<div className="admin-form-row">
|
|
<FormField
|
|
label={
|
|
<>
|
|
<span className="offers-lang-badge">
|
|
EN
|
|
</span>{" "}
|
|
Název sekce
|
|
</>
|
|
}
|
|
>
|
|
<input
|
|
type="text"
|
|
value={section.title}
|
|
onChange={(e) =>
|
|
updateSection(
|
|
index,
|
|
"title",
|
|
e.target.value,
|
|
)
|
|
}
|
|
className="admin-form-input"
|
|
placeholder="Název sekce (anglicky)"
|
|
/>
|
|
</FormField>
|
|
<FormField
|
|
label={
|
|
<>
|
|
<span className="offers-lang-badge offers-lang-badge-cz">
|
|
CZ
|
|
</span>{" "}
|
|
Název sekce
|
|
</>
|
|
}
|
|
>
|
|
<input
|
|
type="text"
|
|
value={section.title_cz}
|
|
onChange={(e) =>
|
|
updateSection(
|
|
index,
|
|
"title_cz",
|
|
e.target.value,
|
|
)
|
|
}
|
|
className="admin-form-input"
|
|
placeholder="Název sekce (česky)"
|
|
/>
|
|
</FormField>
|
|
</div>
|
|
<FormField label="Obsah">
|
|
<RichEditor
|
|
value={section.content}
|
|
onChange={(val) =>
|
|
updateSection(index, "content", val)
|
|
}
|
|
placeholder="Obsah sekce..."
|
|
minHeight="150px"
|
|
/>
|
|
</FormField>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
<div style={{ marginTop: "0.75rem" }}>
|
|
<button
|
|
type="button"
|
|
onClick={addSection}
|
|
className="admin-btn admin-btn-secondary admin-btn-sm"
|
|
>
|
|
+ Přidat sekci
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div className="admin-modal-footer">
|
|
<button
|
|
type="button"
|
|
onClick={() => setShowModal(false)}
|
|
className="admin-btn admin-btn-secondary"
|
|
disabled={saving}
|
|
>
|
|
Zrušit
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={handleSubmit}
|
|
className="admin-btn admin-btn-primary"
|
|
disabled={saving}
|
|
>
|
|
{saving && (
|
|
<>
|
|
<div className="admin-spinner admin-spinner-sm" />
|
|
Ukládání...
|
|
</>
|
|
)}
|
|
{!saving && (editingTemplate ? "Uložit" : "Vytvořit")}
|
|
</button>
|
|
</div>
|
|
</motion.div>
|
|
</motion.div>
|
|
)}
|
|
</AnimatePresence>
|
|
|
|
<ConfirmModal
|
|
isOpen={deleteConfirm.show}
|
|
onClose={() => setDeleteConfirm({ show: false, template: null })}
|
|
onConfirm={handleDelete}
|
|
title="Smazat šablonu"
|
|
message={`Opravdu chcete smazat šablonu "${deleteConfirm.template?.name}"?`}
|
|
confirmText="Smazat"
|
|
cancelText="Zrušit"
|
|
type="danger"
|
|
loading={deleting}
|
|
/>
|
|
</>
|
|
</Skeleton>
|
|
);
|
|
}
|