- Auth: HS256 algorithm restriction on JWT verify, timing-safe bcrypt
for inactive/locked users, locked_until check in loadAuthData, TOTP
fixes (async bcrypt, BigInt conversion, future-code counter fix)
- Validation: Zod enums for leave_type/status, numeric transforms on
foreign keys, VAT 0% coercion fix (Number(v)||21 → v!=null checks)
- Permissions: requirePermission on attendance PUT, attendance_users
and project_logs access checks, trips users filtered by trips.record
- Prisma queries: fixed roles.is:{OR} pattern (doesn't work on to-one
relations), attendance_users now filters by attendance.record only
- Transactions: wrapped deleteOrder, createOrder, updateUser, deleteUser,
duplicateOffer, bulkCreateAttendance, createLeave, scope-templates,
leave-requests, company-settings, profile updates
- Frontend: mountedRef reset in useListData, blob URL cleanup on unmount,
null checks on date fields, AdminDatePicker min/max for HH:mm
- Security headers: COOP, CORP, CSP frame-ancestors/form-action/base-uri
- Other: exchange-rate cache TTL, invoice-alert midnight comparison fix,
numbering.service releaseSequence no-op, nas-offers filename sanitize,
Content-Disposition header injection fix, mojibake Czech strings
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
895 lines
29 KiB
TypeScript
895 lines
29 KiB
TypeScript
import {
|
|
useState,
|
|
useEffect,
|
|
useCallback,
|
|
useMemo,
|
|
useRef,
|
|
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 OrderConfirmationModal from "../components/OrderConfirmationModal";
|
|
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 [attachmentLoading, setAttachmentLoading] = useState(false);
|
|
const [deleteConfirm, setDeleteConfirm] = useState(false);
|
|
const [deleting, setDeleting] = useState(false);
|
|
const [deleteFiles, setDeleteFiles] = useState(false);
|
|
const [showConfirmationModal, setShowConfirmationModal] = useState(false);
|
|
const [confirmationLoading, setConfirmationLoading] = useState(false);
|
|
const initialNotesRef = useRef<string | null>(null);
|
|
const hasSetInitialSnapshot = useRef(false);
|
|
const blobTimeoutsRef = useRef<ReturnType<typeof setTimeout>[]>([]);
|
|
|
|
useEffect(() => {
|
|
return () => {
|
|
blobTimeoutsRef.current.forEach(clearTimeout);
|
|
};
|
|
}, []);
|
|
|
|
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]);
|
|
|
|
useEffect(() => {
|
|
if (loading) {
|
|
hasSetInitialSnapshot.current = false;
|
|
return;
|
|
}
|
|
if (!hasSetInitialSnapshot.current) {
|
|
initialNotesRef.current = notes;
|
|
hasSetInitialSnapshot.current = true;
|
|
}
|
|
}, [loading, notes]);
|
|
|
|
const isDirty = useMemo(() => {
|
|
if (!initialNotesRef.current) return false;
|
|
return notes !== initialNotesRef.current;
|
|
}, [notes]);
|
|
|
|
useEffect(() => {
|
|
if (!isDirty) return;
|
|
const handler = (e: BeforeUnloadEvent) => {
|
|
e.preventDefault();
|
|
e.returnValue = "";
|
|
};
|
|
window.addEventListener("beforeunload", handler);
|
|
return () => window.removeEventListener("beforeunload", handler);
|
|
}, [isDirty]);
|
|
|
|
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 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");
|
|
initialNotesRef.current = notes;
|
|
} 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;
|
|
const timeoutId = setTimeout(() => URL.revokeObjectURL(url), 60000);
|
|
blobTimeoutsRef.current.push(timeoutId);
|
|
} catch {
|
|
newWindow?.close();
|
|
alert.error("Chyba připojení");
|
|
} finally {
|
|
setAttachmentLoading(false);
|
|
}
|
|
};
|
|
|
|
const handleGenerateConfirmation = async (
|
|
lang: string,
|
|
applyVat: boolean,
|
|
customItems?: Array<{
|
|
description: string;
|
|
quantity: number;
|
|
unit: string;
|
|
unit_price: number;
|
|
is_included_in_total: boolean;
|
|
vat_rate: number;
|
|
}>,
|
|
) => {
|
|
setConfirmationLoading(true);
|
|
try {
|
|
const response = await apiFetch(
|
|
`${API_BASE}/orders-pdf/${id}/confirmation`,
|
|
{
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ lang, applyVat, items: customItems }),
|
|
},
|
|
);
|
|
if (!response.ok) {
|
|
const result = await response.json().catch(() => ({}));
|
|
alert.error(result.error || "Nepodařilo se vygenerovat PDF");
|
|
return;
|
|
}
|
|
const blob = await response.blob();
|
|
const url = URL.createObjectURL(blob);
|
|
const a = document.createElement("a");
|
|
a.href = url;
|
|
a.download = `Potvrzeni-${order?.order_number || String(id)}.pdf`;
|
|
document.body.appendChild(a);
|
|
a.click();
|
|
document.body.removeChild(a);
|
|
const timeoutId = setTimeout(() => URL.revokeObjectURL(url), 60000);
|
|
blobTimeoutsRef.current.push(timeoutId);
|
|
} catch {
|
|
alert.error("Chyba připojení");
|
|
} finally {
|
|
setConfirmationLoading(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">
|
|
<span>Objednávka {order.order_number}</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>
|
|
)
|
|
)}
|
|
<button
|
|
onClick={() => setShowConfirmationModal(true)}
|
|
className="admin-btn admin-btn-secondary"
|
|
disabled={confirmationLoading}
|
|
>
|
|
<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>
|
|
Potvrzení objednávky
|
|
</button>
|
|
{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}
|
|
/>
|
|
|
|
{/* Order confirmation PDF modal */}
|
|
{order && (
|
|
<OrderConfirmationModal
|
|
isOpen={showConfirmationModal}
|
|
onClose={() => setShowConfirmationModal(false)}
|
|
onGenerate={handleGenerateConfirmation}
|
|
initialItems={order.items.map((it) => ({
|
|
description: it.description || "",
|
|
quantity: Number(it.quantity) || 0,
|
|
unit: it.unit || "",
|
|
unit_price: Number(it.unit_price) || 0,
|
|
is_included_in_total: Number(it.is_included_in_total) !== 0,
|
|
vat_rate: Number(order.vat_rate) || 21,
|
|
}))}
|
|
orderNumber={order.order_number}
|
|
defaultVatRate={Number(order.vat_rate) || 21}
|
|
applyVat={!!order.apply_vat}
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|