Files
app/src/admin/pages/OfferDetail.tsx
BOHA 6b31b2f74b feat: system settings, dynamic logos, template numbering, permission consolidation
- System settings page with tabs: Security, System, Firma
- Configurable attendance rules (break thresholds, rounding) from DB
- Configurable document numbering with template patterns ({YYYY}/{PREFIX}/{NNN})
- Dynamic logo upload (light/dark variants) served from DB instead of static files
- Email settings (SMTP from/name, alert/leave emails) configurable in UI
- Currency and VAT rate lists configurable, used across all modules
- Permissions simplified: offers.settings + settings.roles + settings.security → settings.manage
- Leaflet bundled locally, removed unpkg.com from CSP
- Silent catch blocks fixed with proper logging
- console.log replaced with app.log.info in server.ts
- Schema renamed: company-settings.schema → settings.schema
- App info section: version, Node.js, uptime, memory, DB status, NAS status

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 10:15:47 +01:00

1771 lines
59 KiB
TypeScript

import {
useState,
useEffect,
useCallback,
useRef,
type ChangeEvent,
} from "react";
import { useAlert } from "../context/AlertContext";
import { useAuth } from "../context/AuthContext";
import { useParams, useNavigate, Link } from "react-router-dom";
import { motion, AnimatePresence } from "framer-motion";
import {
DndContext,
closestCenter,
KeyboardSensor,
PointerSensor,
TouchSensor,
useSensor,
useSensors,
type DragEndEvent,
} from "@dnd-kit/core";
import {
SortableContext,
verticalListSortingStrategy,
useSortable,
arrayMove,
} from "@dnd-kit/sortable";
import {
restrictToVerticalAxis,
restrictToParentElement,
} from "@dnd-kit/modifiers";
import { CSS } from "@dnd-kit/utilities";
import ConfirmModal from "../components/ConfirmModal";
import FormField from "../components/FormField";
import Forbidden from "../components/Forbidden";
import AdminDatePicker from "../components/AdminDatePicker";
import RichEditor from "../components/RichEditor";
import useModalLock from "../hooks/useModalLock";
import useDebounce from "../hooks/useDebounce";
import apiFetch from "../utils/api";
import { formatCurrency } from "../utils/formatters";
const API_BASE = "/api/admin";
const DRAFT_KEY = "boha_offer_draft";
interface OfferItem {
_key: string;
id?: number;
description: string;
item_description: string;
quantity: number;
unit: string;
unit_price: number;
is_included_in_total: boolean;
}
let _itemKeyCounter = 0;
const nextItemKey = () => `item-${++_itemKeyCounter}`;
interface ScopeSection {
title: string;
title_cz: string;
content: string;
}
interface OfferForm {
quotation_number: string;
project_code: string;
customer_id: number | null;
customer_name: string;
created_at: string;
valid_until: string;
currency: string;
language: string;
vat_rate: number;
apply_vat: boolean;
exchange_rate: string;
scope_title: string;
scope_description: string;
}
interface Customer {
id: number;
name: string;
city?: string;
}
interface OrderInfo {
id: number;
order_number: string;
}
const emptyForm: OfferForm = {
quotation_number: "",
project_code: "",
customer_id: null,
customer_name: "",
created_at: new Date().toISOString().split("T")[0],
valid_until: "",
currency: "CZK",
language: "EN",
vat_rate: 21,
apply_vat: false,
exchange_rate: "",
scope_title: "",
scope_description: "",
};
const emptyScopeSection = (): ScopeSection => ({
title: "",
title_cz: "",
content: "",
});
const emptyItem = (): OfferItem => ({
_key: nextItemKey(),
description: "",
item_description: "",
quantity: 1,
unit: "ks",
unit_price: 0,
is_included_in_total: true,
});
function SortableItemRow({
item,
index,
currency,
readOnly,
canDelete,
onUpdate,
onRemove,
}: {
item: OfferItem;
index: number;
currency: string;
readOnly: boolean;
canDelete: boolean;
onUpdate: (field: string, value: unknown) => void;
onRemove: () => void;
}) {
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({ id: item._key, disabled: readOnly });
const style = {
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.5 : 1,
background: isDragging ? "var(--bg-secondary)" : undefined,
position: "relative" as const,
zIndex: isDragging ? 10 : undefined,
};
const lineTotal =
(Number(item.quantity) || 0) * (Number(item.unit_price) || 0);
return (
<tr ref={setNodeRef} style={style}>
{!readOnly && (
<td style={{ width: "2rem" }}>
<button
type="button"
className="admin-drag-handle"
{...attributes}
{...listeners}
title="Přetáhnout"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor">
<circle cx="9" cy="5" r="1.5" />
<circle cx="15" cy="5" r="1.5" />
<circle cx="9" cy="12" r="1.5" />
<circle cx="15" cy="12" r="1.5" />
<circle cx="9" cy="19" r="1.5" />
<circle cx="15" cy="19" r="1.5" />
</svg>
</button>
</td>
)}
<td style={{ textAlign: "center", color: "var(--text-tertiary)" }}>
{index + 1}
</td>
<td style={{ verticalAlign: "top" }}>
<div
style={{ display: "flex", flexDirection: "column", gap: "0.25rem" }}
>
<input
type="text"
value={item.description}
onChange={(e) => onUpdate("description", e.target.value)}
className="admin-form-input"
placeholder="Název položky"
readOnly={readOnly}
style={{ fontWeight: 500 }}
/>
<input
type="text"
value={item.item_description}
onChange={(e) => onUpdate("item_description", e.target.value)}
className="admin-form-input"
placeholder="Podrobný popis (volitelný)"
readOnly={readOnly}
style={{ fontSize: "0.8rem", opacity: 0.8 }}
/>
</div>
</td>
<td>
<input
type="number"
value={item.quantity}
onChange={(e) =>
onUpdate("quantity", parseFloat(e.target.value) || 0)
}
className="admin-form-input"
step="1"
readOnly={readOnly}
/>
</td>
<td>
<input
type="text"
value={item.unit}
onChange={(e) => onUpdate("unit", e.target.value)}
className="admin-form-input"
readOnly={readOnly}
/>
</td>
<td>
<input
type="number"
value={item.unit_price}
onChange={(e) =>
onUpdate("unit_price", parseFloat(e.target.value) || 0)
}
className="admin-form-input"
step="0.01"
readOnly={readOnly}
/>
</td>
<td style={{ textAlign: "center" }}>
<input
type="checkbox"
checked={item.is_included_in_total}
onChange={(e) => onUpdate("is_included_in_total", e.target.checked)}
disabled={readOnly}
/>
</td>
<td
className="admin-mono"
style={{ textAlign: "right", fontWeight: 600 }}
>
{formatCurrency(lineTotal, currency)}
</td>
{!readOnly && (
<td>
<button
onClick={onRemove}
className="admin-btn-icon danger"
title="Odebrat"
disabled={!canDelete}
>
<svg
width="16"
height="16"
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>
</td>
)}
</tr>
);
}
export default function OfferDetail() {
const { id } = useParams();
const isEdit = Boolean(id);
const alert = useAlert();
const { hasPermission } = useAuth();
const navigate = useNavigate();
const dndSensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
useSensor(TouchSensor, {
activationConstraint: { delay: 200, tolerance: 5 },
}),
useSensor(KeyboardSensor),
);
const [loading, setLoading] = useState(isEdit);
const [saving, setSaving] = useState(false);
const [errors, setErrors] = useState<Record<string, string | undefined>>({});
const [form, setForm] = useState<OfferForm>(emptyForm);
const [items, setItems] = useState<OfferItem[]>([emptyItem()]);
const [sections, setSections] = useState<ScopeSection[]>([]);
const [scopeTemplates, setScopeTemplates] = useState<
Array<{
id: number;
name: string;
description?: string;
scope_template_sections?: Array<{
title?: string;
title_cz?: string;
content?: string;
}>;
}>
>([]);
const [customers, setCustomers] = useState<Customer[]>([]);
const [customerSearch, setCustomerSearch] = useState("");
const [showCustomerDropdown, setShowCustomerDropdown] = useState(false);
const [orderInfo, setOrderInfo] = useState<OrderInfo | null>(null);
const [offerStatus, setOfferStatus] = useState<string>("");
const [deleteConfirm, setDeleteConfirm] = useState(false);
const [deleting, setDeleting] = useState(false);
const [creatingOrder, setCreatingOrder] = useState(false);
const [showOrderModal, setShowOrderModal] = useState(false);
const [invalidateConfirm, setInvalidateConfirm] = useState(false);
const [invalidatingOffer, setInvalidatingOffer] = useState(false);
const [customerOrderNumber, setCustomerOrderNumber] = useState("");
const [orderAttachment, setOrderAttachment] = useState<File | null>(null);
const [pdfLoading, setPdfLoading] = useState(false);
const [companySettings, setCompanySettings] = useState<{
default_currency: string;
default_vat_rate: number;
available_currencies: string[];
available_vat_rates: number[];
} | null>(null);
const [lockedBy, setLockedBy] = useState<{
user_id: number;
username: string;
full_name: string;
} | null>(null);
const heartbeatRef = useRef<ReturnType<typeof setInterval> | null>(null);
useModalLock(showOrderModal);
useEffect(() => {
apiFetch(`${API_BASE}/company-settings`)
.then((r) => r.json())
.then((d) => {
if (d.success) setCompanySettings(d.data);
})
.catch(() => {});
}, []);
useEffect(() => {
if (companySettings && !isEdit) {
setForm((prev) => ({
...prev,
currency:
prev.currency === "EUR"
? companySettings.default_currency || "CZK"
: prev.currency,
vat_rate:
prev.vat_rate === 21
? (companySettings.default_vat_rate ?? 21)
: prev.vat_rate,
}));
}
}, [companySettings, isEdit]);
const isInvalidated = offerStatus === "invalidated";
const isLockedByOther = !!lockedBy;
const isExpiredNotInvalidated =
isEdit &&
!isInvalidated &&
!orderInfo &&
form.valid_until &&
new Date(form.valid_until) < new Date(new Date().toDateString());
const fetchDetail = useCallback(async () => {
if (!id) return;
try {
const response = await apiFetch(`${API_BASE}/offers/${id}`);
if (response.status === 401) return;
const result = await response.json();
if (result.success) {
const d = result.data;
setForm({
quotation_number: d.quotation_number || "",
project_code: d.project_code || "",
customer_id: d.customer_id || null,
customer_name: d.customer_name || "",
created_at: d.created_at ? String(d.created_at).substring(0, 10) : "",
valid_until: d.valid_until
? String(d.valid_until).substring(0, 10)
: "",
currency: d.currency || companySettings?.default_currency || "CZK",
language: d.language || "EN",
vat_rate: d.vat_rate ?? companySettings?.default_vat_rate ?? 21,
apply_vat: !!d.apply_vat,
exchange_rate: d.exchange_rate || "",
scope_title: d.scope_title || "",
scope_description: d.scope_description || "",
});
setItems(
d.items?.length
? d.items.map((it: any) => ({ ...it, _key: nextItemKey() }))
: [emptyItem()],
);
setSections(
d.sections?.length
? d.sections.map((s: any) => ({
title: s.title || "",
title_cz: s.title_cz || "",
content: s.content || "",
}))
: [],
);
setOfferStatus(d.status || "");
setOrderInfo(d.order || null);
setLockedBy(d.locked_by || null);
// Try to acquire lock if not locked by someone else and not invalidated
if (
!d.locked_by &&
d.status !== "invalidated" &&
hasPermission("offers.edit")
) {
apiFetch(`${API_BASE}/offers/${id}/lock`, { method: "POST" }).catch(
() => {},
);
}
} else {
alert.error(result.error || "Nepodařilo se načíst nabídku");
navigate("/offers");
}
} catch {
alert.error("Chyba připojení");
navigate("/offers");
} finally {
setLoading(false);
}
}, [id, alert, navigate, hasPermission, companySettings]);
// Heartbeat to keep lock alive + cleanup on unmount
useEffect(() => {
if (!isEdit || !id || isLockedByOther || isInvalidated) return;
heartbeatRef.current = setInterval(() => {
apiFetch(`${API_BASE}/offers/${id}/heartbeat`, { method: "POST" }).catch(
() => {},
);
}, 10 * 1000); // every 10 seconds
return () => {
if (heartbeatRef.current) clearInterval(heartbeatRef.current);
// Release lock on unmount
apiFetch(`${API_BASE}/offers/${id}/unlock`, { method: "POST" }).catch(
() => {},
);
};
}, [isEdit, id, isLockedByOther, isInvalidated]);
useEffect(() => {
if (isEdit) fetchDetail();
}, [isEdit, fetchDetail]);
useEffect(() => {
const loadCustomers = async () => {
try {
const res = await apiFetch(`${API_BASE}/customers`);
if (res.status === 401) return;
const data = await res.json();
if (data.success)
setCustomers(
Array.isArray(data.data) ? data.data : data.data?.customers || [],
);
} catch {
/* silent */
}
};
const loadScopeTemplates = async () => {
try {
const res = await apiFetch(`${API_BASE}/offers-templates`);
if (res.status === 401) return;
const data = await res.json();
if (data.success && Array.isArray(data.data)) {
setScopeTemplates(data.data);
}
} catch {
/* silent */
}
};
loadCustomers();
loadScopeTemplates();
}, []);
// Close dropdown on outside click
useEffect(() => {
const handleClickOutside = () => setShowCustomerDropdown(false);
if (showCustomerDropdown) {
document.addEventListener("click", handleClickOutside);
return () => document.removeEventListener("click", handleClickOutside);
}
}, [showCustomerDropdown]);
useEffect(() => {
if (isEdit) return;
const fetchNextNumber = async () => {
try {
const res = await apiFetch(`${API_BASE}/offers/next-number`);
if (res.status === 401) return;
const data = await res.json();
if (data.success) {
setForm((prev) => ({
...prev,
quotation_number: data.data?.next_number || data.data?.number || "",
}));
}
} catch {
/* silent */
}
};
fetchNextNumber();
}, [isEdit]);
// Restore draft from localStorage on mount (create mode only)
const draftRestoredRef = useRef(false);
useEffect(() => {
if (isEdit || draftRestoredRef.current) return;
draftRestoredRef.current = true;
try {
const raw = localStorage.getItem(DRAFT_KEY);
if (!raw) return;
const draft = JSON.parse(raw);
if (draft && draft.form) {
setForm((prev) => ({
...prev,
project_code: draft.form.project_code || prev.project_code,
customer_name: draft.form.customer_name || prev.customer_name,
created_at: draft.form.created_at || prev.created_at,
valid_until: draft.form.valid_until || prev.valid_until,
currency: draft.form.currency || prev.currency,
}));
if (draft.form.customer_id) {
setForm((prev) => ({ ...prev, customer_id: draft.form.customer_id }));
}
}
if (draft && Array.isArray(draft.items) && draft.items.length > 0) {
setItems(draft.items);
}
if (draft && Array.isArray(draft.sections) && draft.sections.length > 0) {
setSections(draft.sections);
}
} catch {
/* ignore corrupt data */
}
}, [isEdit]);
// Auto-save draft to localStorage (create mode only)
const draftPayload = JSON.stringify({ form, items, sections });
const debouncedDraft = useDebounce(draftPayload, 1500);
useEffect(() => {
if (isEdit) return;
try {
const draft = {
form: {
project_code: form.project_code,
customer_id: form.customer_id,
customer_name: form.customer_name,
created_at: form.created_at,
valid_until: form.valid_until,
currency: form.currency,
},
items,
sections,
savedAt: new Date().toISOString(),
};
localStorage.setItem(DRAFT_KEY, JSON.stringify(draft));
} catch {
/* localStorage full or unavailable */
}
}, [debouncedDraft]); // eslint-disable-line react-hooks/exhaustive-deps
const updateForm = (field: keyof OfferForm, value: unknown) => {
setForm((prev) => ({ ...prev, [field]: value }));
setErrors((prev) => ({ ...prev, [field]: undefined }));
};
const selectCustomer = (c: Customer) => {
setForm((prev) => ({ ...prev, customer_id: c.id, customer_name: c.name }));
setErrors((prev) => ({ ...prev, customer_id: undefined }));
setCustomerSearch("");
setShowCustomerDropdown(false);
};
const clearCustomer = () => {
setForm((prev) => ({ ...prev, customer_id: null, customer_name: "" }));
};
const updateItem = (
index: number,
field: keyof OfferItem,
value: unknown,
) => {
setItems((prev) =>
prev.map((item, i) => (i === index ? { ...item, [field]: value } : item)),
);
};
const addItem = () => setItems((prev) => [...prev, emptyItem()]);
const removeItem = (index: number) => {
setItems((prev) => prev.filter((_, i) => i !== index));
};
const subtotal = items.reduce((sum, item) => {
if (item.is_included_in_total) {
return (
sum + (Number(item.quantity) || 0) * (Number(item.unit_price) || 0)
);
}
return sum;
}, 0);
const vatAmount = form.apply_vat ? subtotal * (form.vat_rate / 100) : 0;
const total = subtotal + vatAmount;
const filteredCustomers = customerSearch
? customers.filter((c) =>
c.name.toLowerCase().includes(customerSearch.toLowerCase()),
)
: customers;
const handleSave = async () => {
const newErrors: Record<string, string> = {};
if (!form.created_at) newErrors.created_at = "Datum je povinné";
if (!form.valid_until) newErrors.valid_until = "Platnost je povinná";
if (items.length === 0) newErrors.items = "Přidejte alespoň jednu položku";
setErrors(newErrors);
if (Object.keys(newErrors).length > 0) return;
setSaving(true);
try {
const url = isEdit ? `${API_BASE}/offers/${id}` : `${API_BASE}/offers`;
const response = await apiFetch(url, {
method: isEdit ? "PUT" : "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
...form,
items: items.map((item, i) => ({ ...item, position: i })),
sections: sections.map((s, i) => ({ ...s, position: i })),
}),
});
const result = await response.json();
if (result.success) {
const offerId = isEdit ? id : result.data?.id;
if (offerId) {
await apiFetch(`${API_BASE}/offers-pdf/${offerId}?save=1`).catch(
() => {},
);
}
alert.success(
result.message ||
(isEdit ? "Nabídka byla aktualizována" : "Nabídka byla vytvořena"),
);
if (!isEdit) {
try {
localStorage.removeItem(DRAFT_KEY);
} catch {
/* ignore */
}
}
if (!isEdit && result.data?.id) {
navigate(`/offers/${result.data.id}`);
}
} else {
alert.error(result.error || "Nepodařilo se uložit nabídku");
}
} catch {
alert.error("Chyba připojení");
} finally {
setSaving(false);
}
};
const handleCreateOrder = async () => {
if (!customerOrderNumber.trim()) {
alert.error("Číslo objednávky zákazníka je povinné");
return;
}
setCreatingOrder(true);
try {
let fetchOptions: RequestInit;
if (orderAttachment) {
// With attachment: send as multipart/form-data
const formData = new FormData();
formData.append("quotationId", String(id));
formData.append("customerOrderNumber", customerOrderNumber.trim());
formData.append("attachment", orderAttachment);
fetchOptions = { method: "POST", body: formData };
} else {
// Without attachment: send as JSON (avoids multipart content-type issues)
fetchOptions = {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
quotationId: id,
customerOrderNumber: customerOrderNumber.trim(),
}),
};
}
const response = await apiFetch(`${API_BASE}/orders`, fetchOptions);
const result = await response.json();
if (result.success) {
setShowOrderModal(false);
alert.success(result.message || "Objednávka byla vytvořena");
navigate(`/orders/${result.data.order_id}`);
} else {
alert.error(result.error || "Nepodařilo se vytvořit objednávku");
}
} catch {
alert.error("Chyba připojení");
} finally {
setCreatingOrder(false);
}
};
const handleInvalidateOffer = async () => {
setInvalidatingOffer(true);
try {
const response = await apiFetch(`${API_BASE}/offers/${id}/invalidate`, {
method: "POST",
});
const result = await response.json();
if (result.success) {
setInvalidateConfirm(false);
setOfferStatus("invalidated");
alert.success(result.message || "Nabídka byla zneplatněna");
} else {
alert.error(result.error || "Nepodařilo se zneplatnit nabídku");
}
} catch {
alert.error("Chyba připojení");
} finally {
setInvalidatingOffer(false);
}
};
const handleDelete = async () => {
setDeleting(true);
try {
const response = await apiFetch(`${API_BASE}/offers/${id}`, {
method: "DELETE",
});
const result = await response.json();
if (result.success) {
alert.success(result.message || "Nabídka byla smazána");
navigate("/offers");
} else {
alert.error(result.error || "Nepodařilo se smazat nabídku");
}
} catch {
alert.error("Chyba připojení");
} finally {
setDeleting(false);
setDeleteConfirm(false);
}
};
const handlePdf = async () => {
if (!isEdit || pdfLoading) return;
setPdfLoading(true);
try {
const response = await apiFetch(`${API_BASE}/offers/${id}/file`);
if (response.status === 401) return;
if (!response.ok) {
alert.error("PDF soubor nenalezen — uložte nabídku pro vygenerování");
return;
}
const blob = await response.blob();
const url = URL.createObjectURL(blob);
window.open(url, "_blank");
setTimeout(() => URL.revokeObjectURL(url), 60000);
} catch {
alert.error("Chyba při generování PDF");
} finally {
setPdfLoading(false);
}
};
const getRequiredPerm = () => {
if (!isEdit) return "offers.create";
return isInvalidated ? "offers.view" : "offers.edit";
};
const requiredPerm = getRequiredPerm();
if (!hasPermission(requiredPerm)) 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 className="flex-row-gap">
<div
className="admin-skeleton-line"
style={{ width: "32px", height: "32px", borderRadius: "8px" }}
/>
<div
className="admin-skeleton-line h-8"
style={{ width: "200px" }}
/>
</div>
<div className="admin-skeleton-row gap-2">
<div
className="admin-skeleton-line h-10"
style={{ width: "100px", borderRadius: "8px" }}
/>
<div
className="admin-skeleton-line h-10"
style={{ width: "100px", borderRadius: "8px" }}
/>
</div>
</div>
<div className="admin-card">
<div className="admin-skeleton" style={{ gap: "1.25rem" }}>
{[0, 1, 2, 3].map((i) => (
<div key={i} className="admin-skeleton-row">
<div className="admin-skeleton-line w-1/4" />
<div className="admin-skeleton-line w-1/2" />
</div>
))}
</div>
</div>
</div>
);
}
return (
<div>
{/* Header */}
<motion.div
className="admin-page-header"
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25 }}
>
<div className="flex-row gap-4">
<Link
to="/offers"
className="admin-btn-icon"
title="Zpět"
aria-label="Zpět"
>
<svg
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<path d="M19 12H5M12 19l-7-7 7-7" />
</svg>
</Link>
<div>
<h1 className="admin-page-title">
{isEdit ? `Nabídka ${form.quotation_number}` : "Nová nabídka"}
{isInvalidated && (
<span
className="admin-badge admin-badge-danger"
style={{
marginLeft: "0.75rem",
verticalAlign: "middle",
fontSize: "0.75rem",
}}
>
Zneplatněna
</span>
)}
</h1>
</div>
</div>
<div className="admin-page-actions">
{isEdit && hasPermission("offers.export") && (
<button
onClick={handlePdf}
className="admin-btn admin-btn-secondary"
style={{
display: "inline-flex",
alignItems: "center",
gap: "0.4rem",
}}
disabled={pdfLoading}
>
{pdfLoading ? (
<div className="admin-spinner admin-spinner-sm" />
) : (
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
<polyline points="14 2 14 8 20 8" />
</svg>
)}
Zobrazit nabídku
</button>
)}
{isEdit &&
!isInvalidated &&
hasPermission("orders.create") &&
!orderInfo && (
<button
onClick={() => {
setCustomerOrderNumber("");
setOrderAttachment(null);
setShowOrderModal(true);
}}
className="admin-btn admin-btn-secondary"
>
Vytvořit objednávku
</button>
)}
{isEdit && orderInfo && (
<Link
to={`/orders/${orderInfo.id}`}
className="admin-btn admin-btn-secondary"
>
Objednávka {orderInfo.order_number}
</Link>
)}
{isExpiredNotInvalidated && hasPermission("offers.edit") && (
<button
onClick={() => setInvalidateConfirm(true)}
className="admin-btn admin-btn-secondary"
>
Zneplatnit
</button>
)}
{!isInvalidated && !isLockedByOther && (
<button
onClick={handleSave}
className="admin-btn admin-btn-primary"
disabled={saving}
>
{saving ? (
<>
<div className="admin-spinner admin-spinner-sm" />
Ukládání...
</>
) : (
"Uložit"
)}
</button>
)}
{isEdit && hasPermission("offers.delete") && (
<button
onClick={() => setDeleteConfirm(true)}
className="admin-btn admin-btn-primary"
>
Smazat
</button>
)}
</div>
</motion.div>
{/* Lock banner */}
{isLockedByOther && (
<div
style={{
background: "color-mix(in srgb, var(--warning) 15%, transparent)",
border: "1px solid var(--warning)",
borderRadius: "var(--border-radius-sm)",
padding: "0.75rem 1rem",
display: "flex",
alignItems: "center",
gap: "0.5rem",
marginBottom: "1rem",
fontSize: "0.875rem",
color: "var(--warning)",
}}
>
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<rect x="3" y="11" width="18" height="11" rx="2" ry="2" />
<path d="M7 11V7a5 5 0 0 1 10 0v4" />
</svg>
<span>
Nabídku právě upravuje <strong>{lockedBy!.full_name}</strong>.
Můžete ji pouze prohlížet.
</span>
</div>
)}
{/* Quotation Form */}
<motion.div
className={`offers-editor-section${isInvalidated || isLockedByOther ? " offers-readonly" : ""}`}
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, delay: 0.06 }}
>
<h3 className="admin-card-title">Základní údaje</h3>
<div className="admin-form">
<div className="offers-form-row-3">
<FormField label="Číslo nabídky">
<input
type="text"
value={form.quotation_number}
onChange={(e) =>
setForm((prev) => ({
...prev,
quotation_number: e.target.value,
}))
}
className="admin-form-input"
/>
</FormField>
<FormField label="Kód projektu">
<input
type="text"
value={form.project_code}
onChange={(e) => updateForm("project_code", e.target.value)}
className="admin-form-input"
placeholder="Volitelný kód projektu"
readOnly={isInvalidated || isLockedByOther}
/>
</FormField>
<FormField label="Zákazník" error={errors.customer_id}>
{form.customer_id ? (
<div className="offers-customer-selected">
<span>{form.customer_name}</span>
{!isInvalidated && !isLockedByOther && (
<button
type="button"
onClick={clearCustomer}
className="admin-btn-icon"
title="Odebrat zákazníka"
>
<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
className="offers-customer-select"
onClick={(e) => e.stopPropagation()}
>
<input
type="text"
value={customerSearch}
onChange={(e) => {
setCustomerSearch(e.target.value);
setShowCustomerDropdown(true);
}}
onFocus={() => setShowCustomerDropdown(true)}
className="admin-form-input"
placeholder="Hledat zákazníka..."
readOnly={isInvalidated || isLockedByOther}
/>
{showCustomerDropdown && !isInvalidated && (
<div className="offers-customer-dropdown">
{filteredCustomers.length === 0 ? (
<div className="offers-customer-dropdown-empty">
Žádní zákazníci
</div>
) : (
filteredCustomers.slice(0, 20).map((c) => (
<div
key={c.id}
className="offers-customer-dropdown-item"
onMouseDown={() => selectCustomer(c)}
>
<div>{c.name}</div>
{c.city && <div>{c.city}</div>}
</div>
))
)}
</div>
)}
</div>
)}
</FormField>
</div>
<div className="admin-form-row">
<FormField
label="Datum vytvoření"
error={errors.created_at}
required
>
{isInvalidated || isLockedByOther ? (
<input
type="text"
value={form.created_at}
className="admin-form-input"
readOnly
/>
) : (
<AdminDatePicker
mode="date"
value={form.created_at}
onChange={(val: string) => {
updateForm("created_at", val);
setErrors((prev) => ({ ...prev, created_at: undefined }));
}}
/>
)}
</FormField>
<FormField label="Platnost do" error={errors.valid_until} required>
{isInvalidated || isLockedByOther ? (
<input
type="text"
value={form.valid_until}
className="admin-form-input"
readOnly
/>
) : (
<AdminDatePicker
mode="date"
value={form.valid_until}
onChange={(val: string) => {
updateForm("valid_until", val);
setErrors((prev) => ({ ...prev, valid_until: undefined }));
}}
/>
)}
</FormField>
</div>
<div className="admin-form-row">
<FormField label="Měna">
<select
value={form.currency}
onChange={(e) => updateForm("currency", e.target.value)}
className="admin-form-select"
disabled={isInvalidated || isLockedByOther}
>
{(
companySettings?.available_currencies || [
"CZK",
"EUR",
"USD",
"GBP",
]
).map((c) => (
<option key={c} value={c}>
{c}
</option>
))}
</select>
</FormField>
<FormField label="Jazyk nabídky">
<select
value={form.language}
onChange={(e) => updateForm("language", e.target.value)}
className="admin-form-select"
disabled={isInvalidated || isLockedByOther}
>
<option value="EN">English</option>
<option value="CZ">Čeština</option>
</select>
</FormField>
</div>
<div className="offers-form-row-3">
<FormField label="Sazba DPH (%)">
<div className="flex-row-gap">
<select
value={form.vat_rate}
onChange={(e) =>
updateForm("vat_rate", parseFloat(e.target.value) || 0)
}
className="admin-form-select flex-1"
disabled={isInvalidated || isLockedByOther}
>
{(
companySettings?.available_vat_rates || [0, 10, 12, 15, 21]
).map((r) => (
<option key={r} value={r}>
{r}%
</option>
))}
</select>
<label
className="admin-form-checkbox"
style={{ whiteSpace: "nowrap" }}
>
<input
type="checkbox"
checked={form.apply_vat}
onChange={(e) => updateForm("apply_vat", e.target.checked)}
disabled={isInvalidated || isLockedByOther}
/>
<span>Účtovat DPH</span>
</label>
</div>
</FormField>
<FormField label="Směnný kurz">
<input
type="number"
value={form.exchange_rate}
onChange={(e) => updateForm("exchange_rate", e.target.value)}
className="admin-form-input"
placeholder="Volitelný"
step="0.0001"
readOnly={isInvalidated || isLockedByOther}
/>
</FormField>
</div>
</div>
</motion.div>
{/* Items Section with drag-and-drop */}
<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-body">
<div className="admin-card-header flex-between">
<h3 className="admin-card-title">Položky</h3>
{!isInvalidated && !isLockedByOther && (
<button
onClick={addItem}
className="admin-btn admin-btn-secondary admin-btn-sm"
>
+ Přidat položku
</button>
)}
</div>
{errors.items && (
<p style={{ color: "var(--color-danger)", fontSize: "0.85rem" }}>
{errors.items}
</p>
)}
<div className="admin-table-responsive">
<DndContext
sensors={dndSensors}
collisionDetection={closestCenter}
modifiers={[restrictToVerticalAxis, restrictToParentElement]}
onDragEnd={(event: DragEndEvent) => {
const { active, over } = event;
if (!over || active.id === over.id) return;
setItems((prev) => {
const oldIndex = prev.findIndex(
(i) => i._key === String(active.id),
);
const newIndex = prev.findIndex(
(i) => i._key === String(over.id),
);
if (oldIndex === -1 || newIndex === -1) return prev;
return arrayMove(prev, oldIndex, newIndex);
});
}}
>
<SortableContext
items={items.map((i) => i._key)}
strategy={verticalListSortingStrategy}
>
<table className="admin-table">
<thead>
<tr>
{!isInvalidated && !isLockedByOther && (
<th style={{ width: "2rem" }} />
)}
<th style={{ width: "2.5rem", textAlign: "center" }}>
#
</th>
<th>Popis</th>
<th style={{ width: "5rem" }}>Množství</th>
<th style={{ width: "5rem" }}>Jednotka</th>
<th style={{ width: "7rem" }}>Cena/ks</th>
<th style={{ width: "4rem", textAlign: "center" }}>
V ceně
</th>
<th style={{ width: "7rem", textAlign: "right" }}>
Celkem
</th>
{!isInvalidated && !isLockedByOther && (
<th style={{ width: "3rem" }} />
)}
</tr>
</thead>
<tbody>
{items.map((item, index) => (
<SortableItemRow
key={item._key}
item={item}
index={index}
currency={form.currency}
readOnly={isInvalidated || isLockedByOther}
canDelete={items.length > 1}
onUpdate={(field, value) =>
updateItem(index, field, value)
}
onRemove={() => removeItem(index)}
/>
))}
</tbody>
</table>
</SortableContext>
</DndContext>
</div>
{/* Totals */}
<div className="offers-totals-summary">
<div className="offers-totals-row">
<span>Mezisoučet:</span>
<span>{formatCurrency(subtotal, form.currency)}</span>
</div>
{form.apply_vat && (
<div className="offers-totals-row">
<span>DPH ({form.vat_rate}%):</span>
<span>{formatCurrency(vatAmount, form.currency)}</span>
</div>
)}
<div className="offers-totals-row offers-totals-total">
<span>Celkem:</span>
<span>{formatCurrency(total, form.currency)}</span>
</div>
</div>
</div>
</motion.div>
{/* Scope/Range Section */}
<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-body">
<div className="admin-card-header flex-between">
<h3 className="admin-card-title">Rozsah projektu</h3>
{!isInvalidated && !isLockedByOther && (
<div
style={{ display: "flex", gap: "0.5rem", alignItems: "center" }}
>
{scopeTemplates.length > 0 && (
<select
className="admin-form-select"
style={{ width: "auto", minWidth: "160px" }}
defaultValue=""
onChange={(e) => {
const templateId = Number(e.target.value);
if (!templateId) return;
const template = scopeTemplates.find(
(t) => t.id === templateId,
);
if (template?.scope_template_sections?.length) {
const newSections =
template.scope_template_sections.map((s: any) => ({
title: s.title || "",
title_cz: s.title_cz || "",
content: s.content || "",
}));
setSections((prev) => [...prev, ...newSections]);
if (template.description) {
setForm((prev) => ({
...prev,
scope_description:
template.description || prev.scope_description,
}));
}
alert.success(`Načtena šablona "${template.name}"`);
}
e.target.value = "";
}}
>
<option value="">Ze šablony...</option>
{scopeTemplates.map((t) => (
<option key={t.id} value={t.id}>
{t.name}
</option>
))}
</select>
)}
<button
onClick={() =>
setSections((prev) => [...prev, emptyScopeSection()])
}
className="admin-btn admin-btn-secondary admin-btn-sm"
>
+ Přidat sekci
</button>
</div>
)}
</div>
{sections.length === 0 ? (
<div className="admin-empty-state" style={{ padding: "2rem" }}>
<p style={{ color: "var(--text-tertiary)" }}>
Žádné sekce rozsahu. Klikněte na "Přidat sekci" nebo vyberte
šablonu.
</p>
</div>
) : (
<div
style={{
display: "flex",
flexDirection: "column",
gap: "1.5rem",
marginTop: "1rem",
}}
>
{sections.map((section, idx) => (
<div
key={idx}
style={{
border: "1px solid var(--border-primary)",
borderRadius: "8px",
padding: "1rem",
background: "var(--bg-secondary)",
}}
>
<div
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
marginBottom: "0.75rem",
}}
>
<span
style={{
fontWeight: 600,
fontSize: "0.9rem",
color: "var(--text-secondary)",
}}
>
Sekce {idx + 1}
{(form.language === "CZ"
? section.title_cz
: section.title) && (
<span style={{ fontWeight: 400, marginLeft: "0.5rem" }}>
{" "}
{form.language === "CZ"
? section.title_cz || section.title
: section.title}
</span>
)}
</span>
{!isInvalidated && !isLockedByOther && (
<div style={{ display: "flex", gap: "0.25rem" }}>
{idx > 0 && (
<button
onClick={() =>
setSections((prev) => {
const arr = [...prev];
[arr[idx - 1], arr[idx]] = [
arr[idx],
arr[idx - 1],
];
return arr;
})
}
className="admin-btn-icon"
title="Posunout nahoru"
>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<polyline points="18 15 12 9 6 15" />
</svg>
</button>
)}
{idx < sections.length - 1 && (
<button
onClick={() =>
setSections((prev) => {
const arr = [...prev];
[arr[idx], arr[idx + 1]] = [
arr[idx + 1],
arr[idx],
];
return arr;
})
}
className="admin-btn-icon"
title="Posunout dolů"
>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<polyline points="6 9 12 15 18 9" />
</svg>
</button>
)}
<button
onClick={() =>
setSections((prev) =>
prev.filter((_, i) => i !== idx),
)
}
className="admin-btn-icon danger"
title="Odebrat sekci"
>
<svg
width="16"
height="16"
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-row">
<FormField
label={
<>
<span className="offers-lang-badge">EN</span>Název
sekce
</>
}
>
<input
type="text"
value={section.title}
onChange={(e) =>
setSections((prev) =>
prev.map((s, i) =>
i === idx ? { ...s, title: e.target.value } : s,
),
)
}
className="admin-form-input"
placeholder="Název sekce (anglicky)"
readOnly={isInvalidated || isLockedByOther}
/>
</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) =>
setSections((prev) =>
prev.map((s, i) =>
i === idx
? { ...s, title_cz: e.target.value }
: s,
),
)
}
className="admin-form-input"
placeholder="Název sekce (česky)"
readOnly={isInvalidated || isLockedByOther}
/>
</FormField>
</div>
<div style={{ marginTop: "0.5rem" }}>
<label className="admin-form-label">Obsah</label>
<RichEditor
value={section.content}
onChange={(val) =>
setSections((prev) =>
prev.map((s, i) =>
i === idx ? { ...s, content: val } : s,
),
)
}
placeholder="Obsah sekce..."
minHeight="120px"
readOnly={isInvalidated || isLockedByOther}
/>
</div>
</div>
))}
</div>
)}
</div>
</motion.div>
{/* Order modal */}
<AnimatePresence>
{showOrderModal && (
<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={() => !creatingOrder && setShowOrderModal(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">Vytvořit objednávku</h2>
</div>
<div className="admin-modal-body">
<div className="admin-form">
<FormField label="Číslo objednávky zákazníka" required>
<input
type="text"
value={customerOrderNumber}
onChange={(e: ChangeEvent<HTMLInputElement>) =>
setCustomerOrderNumber(e.target.value)
}
onKeyDown={(e) =>
e.key === "Enter" &&
!creatingOrder &&
handleCreateOrder()
}
className="admin-form-input"
placeholder="Např. PO-2026-001"
autoFocus
/>
</FormField>
<FormField label="Příloha (PDF)">
{orderAttachment ? (
<div className="flex-row gap-2">
<span style={{ fontSize: "0.875rem" }}>
{orderAttachment.name}{" "}
<span className="text-tertiary">
({(orderAttachment.size / 1024).toFixed(0)} KB)
</span>
</span>
<button
type="button"
onClick={() => setOrderAttachment(null)}
className="admin-btn-icon"
title="Odebrat"
style={{ marginLeft: "auto" }}
>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<path d="M18 6L6 18M6 6l12 12" />
</svg>
</button>
</div>
) : (
<label
className="admin-btn admin-btn-secondary admin-btn-sm"
style={{
cursor: "pointer",
display: "inline-flex",
alignItems: "center",
gap: "0.4rem",
}}
>
Vybrat soubor
<input
type="file"
accept="application/pdf"
onChange={(e: ChangeEvent<HTMLInputElement>) =>
setOrderAttachment(e.target.files?.[0] || null)
}
style={{ display: "none" }}
/>
</label>
)}
<small
className="admin-form-hint"
style={{ marginTop: "0.25rem" }}
>
Max 10 MB
</small>
</FormField>
</div>
</div>
<div className="admin-modal-footer">
<button
onClick={() => setShowOrderModal(false)}
className="admin-btn admin-btn-secondary"
disabled={creatingOrder}
>
Zrušit
</button>
<button
onClick={handleCreateOrder}
className="admin-btn admin-btn-primary"
disabled={creatingOrder || !customerOrderNumber.trim()}
>
{creatingOrder ? "Vytváření..." : "Vytvořit"}
</button>
</div>
</motion.div>
</motion.div>
)}
</AnimatePresence>
<ConfirmModal
isOpen={invalidateConfirm}
onClose={() => setInvalidateConfirm(false)}
onConfirm={handleInvalidateOffer}
title="Zneplatnit nabídku"
message={`Opravdu chcete zneplatnit nabídku "${form.quotation_number}"? Nabídka bude pouze pro čtení a nepůjde upravovat.`}
confirmText="Zneplatnit"
cancelText="Zrušit"
type="danger"
loading={invalidatingOffer}
/>
<ConfirmModal
isOpen={deleteConfirm}
onClose={() => setDeleteConfirm(false)}
onConfirm={handleDelete}
title="Smazat nabídku"
message={`Opravdu chcete smazat nabídku "${form.quotation_number}"? Budou smazány i všechny položky a sekce. Tato akce je nevratná.`}
confirmText="Smazat"
cancelText="Zrušit"
type="danger"
loading={deleting}
/>
</div>
);
}