- 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>
1771 lines
59 KiB
TypeScript
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>
|
|
);
|
|
}
|