- Split admin.css (3228 lines) into 12 focused files: variables, base, forms, buttons, layout, components, tables, skeleton, datepicker, filemanager, pagination, responsive - Extracted shared styles from offers.css and dashboard.css into components.css and forms.css (offers-* → admin-* prefix) - Standardized naming: dash-kpi-* → admin-kpi-*, session-* → dash-session-*, rich-editor → admin-rich-editor - Deleted duplicate offers-tabs (using admin-tabs everywhere) - Deduplicated DatePicker and FileManager CSS (~360 lines removed) - Added 16 utility classes to base.css (font sizes, widths, gaps, margins) - Deleted empty admin.css Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
906 lines
30 KiB
TypeScript
906 lines
30 KiB
TypeScript
import {
|
|
useState,
|
|
useEffect,
|
|
useCallback,
|
|
useMemo,
|
|
type ReactNode,
|
|
} from "react";
|
|
import DOMPurify from "dompurify";
|
|
import { useAlert } from "../context/AlertContext";
|
|
import { useAuth } from "../context/AuthContext";
|
|
import { useParams, useNavigate, Link } from "react-router-dom";
|
|
import { motion } from "framer-motion";
|
|
import ConfirmModal from "../components/ConfirmModal";
|
|
import FormField from "../components/FormField";
|
|
import Forbidden from "../components/Forbidden";
|
|
|
|
import apiFetch from "../utils/api";
|
|
import { formatCurrency, formatDate } from "../utils/formatters";
|
|
|
|
const API_BASE = "/api/admin";
|
|
|
|
const STATUS_LABELS: Record<string, string> = {
|
|
prijata: "Přijatá",
|
|
v_realizaci: "V realizaci",
|
|
dokoncena: "Dokončená",
|
|
stornovana: "Stornována",
|
|
};
|
|
|
|
const STATUS_CLASSES: Record<string, string> = {
|
|
prijata: "admin-badge-order-prijata",
|
|
v_realizaci: "admin-badge-order-realizace",
|
|
dokoncena: "admin-badge-order-dokoncena",
|
|
stornovana: "admin-badge-order-stornovana",
|
|
};
|
|
|
|
const TRANSITION_LABELS: Record<string, string> = {
|
|
v_realizaci: "Zahájit realizaci",
|
|
dokoncena: "Dokončit",
|
|
};
|
|
|
|
const TRANSITION_CLASSES: Record<string, string> = {
|
|
v_realizaci: "admin-btn admin-btn-primary",
|
|
dokoncena: "admin-btn admin-btn-primary",
|
|
};
|
|
|
|
interface OrderItem {
|
|
id?: number;
|
|
description: string;
|
|
item_description?: string;
|
|
quantity: number;
|
|
unit: string;
|
|
unit_price: number;
|
|
is_included_in_total: number | boolean;
|
|
}
|
|
|
|
interface OrderSection {
|
|
id?: number;
|
|
title: string;
|
|
title_cz?: string;
|
|
content: string;
|
|
}
|
|
|
|
interface Invoice {
|
|
id: number;
|
|
invoice_number: string;
|
|
}
|
|
|
|
interface Project {
|
|
id: number;
|
|
project_number: string;
|
|
name: string;
|
|
has_nas_folder?: boolean;
|
|
}
|
|
|
|
interface OrderData {
|
|
id: number;
|
|
order_number: string;
|
|
quotation_id: number;
|
|
quotation_number: string;
|
|
project_code?: string;
|
|
customer_name: string;
|
|
customer_order_number: string;
|
|
currency: string;
|
|
created_at: string;
|
|
status: string;
|
|
notes: string;
|
|
attachment_name?: string;
|
|
apply_vat: number | boolean;
|
|
vat_rate: number;
|
|
language?: string;
|
|
items: OrderItem[];
|
|
sections: OrderSection[];
|
|
scope_title?: string;
|
|
scope_description?: string;
|
|
valid_transitions?: string[];
|
|
invoice?: Invoice;
|
|
project?: Project;
|
|
}
|
|
|
|
export default function OrderDetail() {
|
|
const { id } = useParams();
|
|
const alert = useAlert();
|
|
const { hasPermission } = useAuth();
|
|
const navigate = useNavigate();
|
|
|
|
const [loading, setLoading] = useState(true);
|
|
const [order, setOrder] = useState<OrderData | null>(null);
|
|
const [notes, setNotes] = useState("");
|
|
const [saving, setSaving] = useState(false);
|
|
const [statusChanging, setStatusChanging] = useState<string | null>(null);
|
|
const [statusConfirm, setStatusConfirm] = useState<{
|
|
show: boolean;
|
|
status: string | null;
|
|
}>({ show: false, status: null });
|
|
const [editingNumber, setEditingNumber] = useState(false);
|
|
const [orderNumber, setOrderNumber] = useState("");
|
|
const [savingNumber, setSavingNumber] = useState(false);
|
|
const [attachmentLoading, setAttachmentLoading] = useState(false);
|
|
const [deleteConfirm, setDeleteConfirm] = useState(false);
|
|
const [deleting, setDeleting] = useState(false);
|
|
const [deleteFiles, setDeleteFiles] = useState(false);
|
|
|
|
const fetchDetail = useCallback(async () => {
|
|
try {
|
|
const response = await apiFetch(`${API_BASE}/orders/${id}`);
|
|
if (response.status === 401) return;
|
|
const result = await response.json();
|
|
if (result.success) {
|
|
setOrder(result.data);
|
|
setNotes(result.data.notes || "");
|
|
} else {
|
|
alert.error(result.error || "Nepodařilo se načíst objednávku");
|
|
navigate("/orders");
|
|
}
|
|
} catch {
|
|
alert.error("Chyba připojení");
|
|
navigate("/orders");
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}, [id, alert, navigate]);
|
|
|
|
useEffect(() => {
|
|
fetchDetail();
|
|
}, [fetchDetail]);
|
|
|
|
const totals = useMemo(() => {
|
|
if (!order?.items) return { subtotal: 0, vatAmount: 0, total: 0 };
|
|
const subtotal = order.items.reduce((sum, item) => {
|
|
if (Number(item.is_included_in_total)) {
|
|
return (
|
|
sum + (Number(item.quantity) || 0) * (Number(item.unit_price) || 0)
|
|
);
|
|
}
|
|
return sum;
|
|
}, 0);
|
|
const vatAmount = Number(order.apply_vat)
|
|
? subtotal * ((Number(order.vat_rate) || 0) / 100)
|
|
: 0;
|
|
return { subtotal, vatAmount, total: subtotal + vatAmount };
|
|
}, [order]);
|
|
|
|
if (!hasPermission("orders.view")) return <Forbidden />;
|
|
|
|
const handleStatusChange = async () => {
|
|
if (!statusConfirm.status) return;
|
|
setStatusChanging(statusConfirm.status);
|
|
setStatusConfirm({ show: false, status: null });
|
|
try {
|
|
const response = await apiFetch(`${API_BASE}/orders/${id}`, {
|
|
method: "PUT",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ status: statusConfirm.status }),
|
|
});
|
|
const result = await response.json();
|
|
if (result.success) {
|
|
alert.success(result.message || "Stav byl změněn");
|
|
fetchDetail();
|
|
} else {
|
|
alert.error(result.error || "Nepodařilo se změnit stav");
|
|
}
|
|
} catch {
|
|
alert.error("Chyba připojení");
|
|
} finally {
|
|
setStatusChanging(null);
|
|
}
|
|
};
|
|
|
|
const handleStartEditNumber = () => {
|
|
if (!order) return;
|
|
setOrderNumber(order.order_number);
|
|
setEditingNumber(true);
|
|
};
|
|
|
|
const handleSaveNumber = async () => {
|
|
if (!order) return;
|
|
const trimmed = orderNumber.trim();
|
|
if (!trimmed) return;
|
|
if (trimmed === order.order_number) {
|
|
setEditingNumber(false);
|
|
return;
|
|
}
|
|
setSavingNumber(true);
|
|
try {
|
|
const response = await apiFetch(`${API_BASE}/orders/${id}`, {
|
|
method: "PUT",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ order_number: trimmed }),
|
|
});
|
|
const result = await response.json();
|
|
if (result.success) {
|
|
alert.success("Číslo objednávky bylo změněno");
|
|
setEditingNumber(false);
|
|
fetchDetail();
|
|
} else {
|
|
alert.error(result.error || "Nepodařilo se změnit číslo");
|
|
}
|
|
} catch {
|
|
alert.error("Chyba připojení");
|
|
} finally {
|
|
setSavingNumber(false);
|
|
}
|
|
};
|
|
|
|
const handleSaveNotes = async () => {
|
|
setSaving(true);
|
|
try {
|
|
const response = await apiFetch(`${API_BASE}/orders/${id}`, {
|
|
method: "PUT",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ notes: notes }),
|
|
});
|
|
const result = await response.json();
|
|
if (result.success) {
|
|
alert.success("Poznámky byly uloženy");
|
|
} else {
|
|
alert.error(result.error || "Nepodařilo se uložit poznámky");
|
|
}
|
|
} catch {
|
|
alert.error("Chyba připojení");
|
|
} finally {
|
|
setSaving(false);
|
|
}
|
|
};
|
|
|
|
const handleViewAttachment = async () => {
|
|
const newWindow = window.open("", "_blank");
|
|
setAttachmentLoading(true);
|
|
try {
|
|
const response = await apiFetch(`${API_BASE}/orders/${id}/attachment`);
|
|
if (!response.ok) {
|
|
newWindow?.close();
|
|
alert.error("Nepodařilo se stáhnout přílohu");
|
|
return;
|
|
}
|
|
const blob = await response.blob();
|
|
const url = URL.createObjectURL(blob);
|
|
if (newWindow) newWindow.location.href = url;
|
|
setTimeout(() => URL.revokeObjectURL(url), 60000);
|
|
} catch {
|
|
newWindow?.close();
|
|
alert.error("Chyba připojení");
|
|
} finally {
|
|
setAttachmentLoading(false);
|
|
}
|
|
};
|
|
|
|
const handleDelete = async () => {
|
|
setDeleting(true);
|
|
try {
|
|
const response = await apiFetch(`${API_BASE}/orders/${id}`, {
|
|
method: "DELETE",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ delete_files: deleteFiles }),
|
|
});
|
|
const result = await response.json();
|
|
if (result.success) {
|
|
alert.success(result.message || "Objednávka byla smazána");
|
|
navigate("/orders");
|
|
} else {
|
|
alert.error(result.error || "Nepodařilo se smazat objednávku");
|
|
}
|
|
} catch {
|
|
alert.error("Chyba připojení");
|
|
} finally {
|
|
setDeleting(false);
|
|
setDeleteConfirm(false);
|
|
}
|
|
};
|
|
|
|
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>
|
|
);
|
|
}
|
|
|
|
if (!order) return null;
|
|
|
|
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="/orders"
|
|
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 flex-row-gap">
|
|
{editingNumber ? (
|
|
<span
|
|
style={{
|
|
display: "inline-flex",
|
|
alignItems: "center",
|
|
gap: "0.5rem",
|
|
}}
|
|
>
|
|
Objednávka
|
|
<input
|
|
type="text"
|
|
value={orderNumber}
|
|
onChange={(e) => setOrderNumber(e.target.value)}
|
|
onKeyDown={(e) => {
|
|
if (e.key === "Enter") handleSaveNumber();
|
|
if (e.key === "Escape") setEditingNumber(false);
|
|
}}
|
|
className="admin-form-input"
|
|
style={{
|
|
width: "10rem",
|
|
fontSize: "1rem",
|
|
padding: "0.25rem 0.5rem",
|
|
height: "auto",
|
|
}}
|
|
autoFocus
|
|
disabled={savingNumber}
|
|
/>
|
|
<button
|
|
onClick={handleSaveNumber}
|
|
className="admin-btn-icon"
|
|
title="Uložit"
|
|
aria-label="Uložit"
|
|
disabled={savingNumber}
|
|
>
|
|
<svg
|
|
width="18"
|
|
height="18"
|
|
viewBox="0 0 24 24"
|
|
fill="none"
|
|
stroke="var(--accent-color)"
|
|
strokeWidth="2"
|
|
>
|
|
<polyline points="20 6 9 17 4 12" />
|
|
</svg>
|
|
</button>
|
|
<button
|
|
onClick={() => setEditingNumber(false)}
|
|
className="admin-btn-icon"
|
|
title="Zrušit"
|
|
aria-label="Zrušit"
|
|
disabled={savingNumber}
|
|
>
|
|
<svg
|
|
width="18"
|
|
height="18"
|
|
viewBox="0 0 24 24"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
strokeWidth="2"
|
|
>
|
|
<path d="M18 6L6 18M6 6l12 12" />
|
|
</svg>
|
|
</button>
|
|
</span>
|
|
) : (
|
|
<span
|
|
style={{
|
|
display: "inline-flex",
|
|
alignItems: "center",
|
|
gap: "0.5rem",
|
|
}}
|
|
>
|
|
Objednávka {order.order_number}
|
|
{hasPermission("orders.edit") && (
|
|
<button
|
|
onClick={handleStartEditNumber}
|
|
className="admin-btn-icon"
|
|
title="Změnit číslo"
|
|
aria-label="Změnit číslo"
|
|
style={{ opacity: 0.5 }}
|
|
>
|
|
<svg
|
|
width="16"
|
|
height="16"
|
|
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>
|
|
)}
|
|
</span>
|
|
)}
|
|
<span
|
|
className={`admin-badge ${STATUS_CLASSES[order.status] || ""}`}
|
|
>
|
|
{STATUS_LABELS[order.status] || order.status}
|
|
</span>
|
|
</h1>
|
|
</div>
|
|
</div>
|
|
<div className="admin-page-actions">
|
|
{order.invoice ? (
|
|
<Link
|
|
to={`/invoices/${order.invoice.id}`}
|
|
className="admin-btn admin-btn-secondary"
|
|
>
|
|
<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>
|
|
Faktura {order.invoice.invoice_number}
|
|
</Link>
|
|
) : (
|
|
hasPermission("invoices.create") &&
|
|
order.status === "dokoncena" && (
|
|
<Link
|
|
to={`/invoices/new?fromOrder=${order.id}`}
|
|
className="admin-btn admin-btn-secondary"
|
|
>
|
|
<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>
|
|
Vytvořit fakturu
|
|
</Link>
|
|
)
|
|
)}
|
|
{hasPermission("orders.edit") &&
|
|
order.valid_transitions?.filter((s) => s !== "stornovana").length! >
|
|
0 &&
|
|
order
|
|
.valid_transitions!.filter((s) => s !== "stornovana")
|
|
.map((status) => (
|
|
<button
|
|
key={status}
|
|
onClick={() => setStatusConfirm({ show: true, status })}
|
|
className={
|
|
TRANSITION_CLASSES[status] ||
|
|
"admin-btn admin-btn-secondary"
|
|
}
|
|
disabled={statusChanging === status}
|
|
>
|
|
{statusChanging === status ? (
|
|
<div className="admin-spinner admin-spinner-sm" />
|
|
) : (
|
|
TRANSITION_LABELS[status] || status
|
|
)}
|
|
</button>
|
|
))}
|
|
{hasPermission("orders.delete") && (
|
|
<button
|
|
onClick={() => setDeleteConfirm(true)}
|
|
className="admin-btn admin-btn-primary"
|
|
>
|
|
Smazat
|
|
</button>
|
|
)}
|
|
</div>
|
|
</motion.div>
|
|
|
|
{/* Info card */}
|
|
<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-body">
|
|
<h3 className="admin-card-title">Informace</h3>
|
|
<div className="admin-form-row mb-2">
|
|
<FormField label="Nabídka">
|
|
<div>
|
|
<Link
|
|
to={`/offers/${order.quotation_id}`}
|
|
className="link-accent"
|
|
>
|
|
{order.quotation_number}
|
|
</Link>
|
|
{order.project_code && (
|
|
<span
|
|
className="text-tertiary"
|
|
style={{ marginLeft: "0.5rem" }}
|
|
>
|
|
({order.project_code})
|
|
</span>
|
|
)}
|
|
</div>
|
|
</FormField>
|
|
<FormField label="Projekt">
|
|
<div>
|
|
{order.project ? (
|
|
<Link
|
|
to={`/projects/${order.project.id}`}
|
|
className="link-accent"
|
|
>
|
|
{order.project.project_number} — {order.project.name}
|
|
</Link>
|
|
) : (
|
|
"—"
|
|
)}
|
|
</div>
|
|
</FormField>
|
|
</div>
|
|
<div className="admin-form-row admin-form-row-3 mb-2">
|
|
<FormField label="Zákazník">
|
|
<div className="fw-500">{order.customer_name || "—"}</div>
|
|
</FormField>
|
|
<FormField label="Číslo obj. zákazníka">
|
|
<div>{order.customer_order_number || "—"}</div>
|
|
</FormField>
|
|
<FormField label="Měna">
|
|
<div>{order.currency}</div>
|
|
</FormField>
|
|
</div>
|
|
<div className="admin-form-row admin-form-row-3 mb-2">
|
|
<FormField label="Datum vytvoření">
|
|
<div>{formatDate(order.created_at)}</div>
|
|
</FormField>
|
|
<FormField label="Příloha">
|
|
<div>
|
|
{order.attachment_name ? (
|
|
<button
|
|
onClick={handleViewAttachment}
|
|
className="admin-btn admin-btn-secondary admin-btn-sm"
|
|
style={{
|
|
display: "inline-flex",
|
|
alignItems: "center",
|
|
gap: "0.4rem",
|
|
}}
|
|
disabled={attachmentLoading}
|
|
>
|
|
{attachmentLoading ? (
|
|
<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>
|
|
)}
|
|
{order.attachment_name}
|
|
</button>
|
|
) : (
|
|
"—"
|
|
)}
|
|
</div>
|
|
</FormField>
|
|
</div>
|
|
</div>
|
|
</motion.div>
|
|
|
|
{/* Items (read-only) */}
|
|
<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">
|
|
<h3 className="admin-card-title">Položky</h3>
|
|
{order.items?.length > 0 ? (
|
|
<div className="admin-table-responsive">
|
|
<table className="admin-table">
|
|
<thead>
|
|
<tr>
|
|
<th style={{ width: "2.5rem", textAlign: "center" }}>#</th>
|
|
<th>Popis</th>
|
|
<th style={{ width: "5.5rem", textAlign: "center" }}>
|
|
Množství
|
|
</th>
|
|
<th style={{ width: "5.5rem", textAlign: "center" }}>
|
|
Jednotka
|
|
</th>
|
|
<th
|
|
style={{
|
|
width: "8rem",
|
|
textAlign: "right",
|
|
whiteSpace: "nowrap",
|
|
}}
|
|
>
|
|
Jedn. cena
|
|
</th>
|
|
<th style={{ width: "4rem", textAlign: "center" }}>
|
|
V ceně
|
|
</th>
|
|
<th
|
|
style={{
|
|
width: "9rem",
|
|
textAlign: "right",
|
|
whiteSpace: "nowrap",
|
|
}}
|
|
>
|
|
Celkem
|
|
</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{order.items.map((item, index) => {
|
|
const lineTotal =
|
|
(Number(item.quantity) || 0) *
|
|
(Number(item.unit_price) || 0);
|
|
return (
|
|
<tr key={item.id || index}>
|
|
<td
|
|
style={{
|
|
color: "var(--text-tertiary)",
|
|
textAlign: "center",
|
|
fontWeight: 500,
|
|
}}
|
|
>
|
|
{index + 1}
|
|
</td>
|
|
<td>
|
|
<div className="fw-500">
|
|
{item.description || "—"}
|
|
</div>
|
|
{item.item_description && (
|
|
<div
|
|
style={{
|
|
fontSize: "0.8rem",
|
|
color: "var(--text-tertiary)",
|
|
marginTop: "0.25rem",
|
|
}}
|
|
>
|
|
{item.item_description}
|
|
</div>
|
|
)}
|
|
</td>
|
|
<td style={{ textAlign: "center" }}>{item.quantity}</td>
|
|
<td style={{ textAlign: "center" }}>
|
|
{item.unit || "—"}
|
|
</td>
|
|
<td
|
|
className="admin-mono"
|
|
style={{ textAlign: "right", whiteSpace: "nowrap" }}
|
|
>
|
|
{formatCurrency(item.unit_price, order.currency)}
|
|
</td>
|
|
<td style={{ textAlign: "center" }}>
|
|
{Number(item.is_included_in_total) ? "Ano" : "Ne"}
|
|
</td>
|
|
<td
|
|
className="admin-mono"
|
|
style={{
|
|
textAlign: "right",
|
|
fontWeight: 600,
|
|
whiteSpace: "nowrap",
|
|
}}
|
|
>
|
|
{formatCurrency(lineTotal, order.currency)}
|
|
</td>
|
|
</tr>
|
|
);
|
|
})}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
) : (
|
|
<p style={{ color: "var(--text-tertiary)" }}>Žádné položky.</p>
|
|
)}
|
|
|
|
{/* Totals */}
|
|
<div className="admin-totals-summary">
|
|
<div className="admin-totals-row">
|
|
<span>Mezisoučet:</span>
|
|
<span>{formatCurrency(totals.subtotal, order.currency)}</span>
|
|
</div>
|
|
{Number(order.apply_vat) > 0 && (
|
|
<div className="admin-totals-row">
|
|
<span>DPH ({order.vat_rate}%):</span>
|
|
<span>{formatCurrency(totals.vatAmount, order.currency)}</span>
|
|
</div>
|
|
)}
|
|
<div className="admin-totals-row admin-totals-total">
|
|
<span>Celkem k úhradě:</span>
|
|
<span>{formatCurrency(totals.total, order.currency)}</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</motion.div>
|
|
|
|
{/* Sections (read-only) */}
|
|
{order.sections?.length > 0 && (
|
|
<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">
|
|
<h3 className="admin-card-title">Rozsah projektu</h3>
|
|
{order.scope_title && (
|
|
<div style={{ fontWeight: 500, marginBottom: "0.5rem" }}>
|
|
{order.scope_title}
|
|
</div>
|
|
)}
|
|
{order.scope_description && (
|
|
<div
|
|
style={{ color: "var(--text-secondary)", marginBottom: "1rem" }}
|
|
>
|
|
{order.scope_description}
|
|
</div>
|
|
)}
|
|
<div className="admin-scope-list">
|
|
{order.sections.map((section, index) => (
|
|
<div
|
|
key={section.id || index}
|
|
className="admin-scope-section"
|
|
style={{ cursor: "default" }}
|
|
>
|
|
<div className="admin-scope-section-header">
|
|
<span className="admin-scope-number">{index + 1}.</span>
|
|
<span className="admin-scope-title">
|
|
{(order.language === "CZ"
|
|
? section.title_cz || section.title
|
|
: section.title || section.title_cz) ||
|
|
`Sekce ${index + 1}`}
|
|
</span>
|
|
</div>
|
|
{section.content && (
|
|
<div
|
|
className="admin-scope-content admin-rich-text-view"
|
|
style={{ padding: "1rem" }}
|
|
dangerouslySetInnerHTML={{
|
|
__html: DOMPurify.sanitize(section.content),
|
|
}}
|
|
/>
|
|
)}
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</motion.div>
|
|
)}
|
|
|
|
{/* Notes (editable) */}
|
|
<motion.div
|
|
className="admin-card"
|
|
initial={{ opacity: 0, y: 12 }}
|
|
animate={{ opacity: 1, y: 0 }}
|
|
transition={{ duration: 0.25, delay: 0.2 }}
|
|
>
|
|
<div className="admin-card-body">
|
|
<h3 className="admin-card-title">Poznámky</h3>
|
|
<FormField label="Poznámky">
|
|
<textarea
|
|
value={notes}
|
|
onChange={(e) => setNotes(e.target.value)}
|
|
className="admin-form-input"
|
|
rows={4}
|
|
placeholder="Interní poznámky k objednávce..."
|
|
disabled={!hasPermission("orders.edit")}
|
|
/>
|
|
</FormField>
|
|
{hasPermission("orders.edit") && (
|
|
<div className="mt-2">
|
|
<button
|
|
onClick={handleSaveNotes}
|
|
className="admin-btn admin-btn-secondary admin-btn-sm"
|
|
disabled={saving}
|
|
>
|
|
{saving ? "Ukládání..." : "Uložit poznámky"}
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</motion.div>
|
|
|
|
{/* Status change confirmation */}
|
|
<ConfirmModal
|
|
isOpen={statusConfirm.show}
|
|
onClose={() => setStatusConfirm({ show: false, status: null })}
|
|
onConfirm={handleStatusChange}
|
|
title="Změnit stav objednávky"
|
|
message={`Opravdu chcete změnit stav objednávky "${order.order_number}" na "${STATUS_LABELS[statusConfirm.status || ""]}"?${statusConfirm.status === "dokoncena" ? " Projekt bude automaticky dokončen." : ""}`}
|
|
confirmText={
|
|
TRANSITION_LABELS[statusConfirm.status || ""] || "Potvrdit"
|
|
}
|
|
cancelText="Zrušit"
|
|
type="default"
|
|
/>
|
|
|
|
{/* Delete confirmation */}
|
|
<ConfirmModal
|
|
isOpen={deleteConfirm}
|
|
onClose={() => {
|
|
setDeleteConfirm(false);
|
|
setDeleteFiles(false);
|
|
}}
|
|
onConfirm={handleDelete}
|
|
title="Smazat objednávku"
|
|
message={
|
|
<>
|
|
Opravdu chcete smazat objednávku "{order.order_number}"?
|
|
Bude smazán i přidružený projekt. Tato akce je nevratná.
|
|
{order.project?.has_nas_folder && (
|
|
<label
|
|
className="admin-form-checkbox"
|
|
style={{ marginTop: "1rem", display: "flex" }}
|
|
>
|
|
<input
|
|
type="checkbox"
|
|
checked={deleteFiles}
|
|
onChange={(e) => setDeleteFiles(e.target.checked)}
|
|
/>
|
|
<span>Smazat i soubory projektu na disku</span>
|
|
</label>
|
|
)}
|
|
</>
|
|
}
|
|
confirmText="Smazat"
|
|
cancelText="Zrušit"
|
|
type="danger"
|
|
loading={deleting}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|