Files
app/src/admin/pages/OrderDetail.tsx
BOHA d7c7fbad88 fix: security, validation, and data integrity fixes across 53 files
- 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>
2026-04-28 08:40:38 +02:00

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 &quot;{order.order_number}&quot;?
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>
);
}