Files
app/src/admin/pages/OrderDetail.tsx
BOHA cd6d3daf43 v1.6.5: fix attendance unique constraint, schema sync, seed script
- Change attendance idx_attendance_user_date from unique to index (allow multiple shifts per day)
- Reset migrations to single baseline init migration
- Add seed script with admin user (admin/admin)
- Update CLAUDE.md with migration workflow documentation
- Various frontend fixes (queries, pages, hooks)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-17 16:21:34 +02:00

822 lines
29 KiB
TypeScript

import { useState, useEffect, useMemo, useRef, type ReactNode } from "react";
import DOMPurify from "dompurify";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import {
orderDetailOptions,
type OrderData,
type OrderItem,
type OrderSection,
type OrderInvoice,
type OrderProject,
} from "../lib/queries/orders";
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 { Skeleton } from "boneyard-js/react";
import OrderDetailFixture from "../fixtures/OrderDetailFixture";
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",
};
export default function OrderDetail() {
const { id } = useParams();
const alert = useAlert();
const { hasPermission } = useAuth();
const navigate = useNavigate();
const queryClient = useQueryClient();
const orderQuery = useQuery(orderDetailOptions(id));
const order = orderQuery.data;
const loading = orderQuery.isPending;
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 formInitializedRef = useRef(false);
const blobTimeoutsRef = useRef<ReturnType<typeof setTimeout>[]>([]);
// Reset form sync when navigating to a different order
useEffect(() => {
formInitializedRef.current = false;
}, [id]);
// Sync order data to local form state on first load
useEffect(() => {
if (orderQuery.data && !formInitializedRef.current) {
const orderData = orderQuery.data!;
setNotes(orderData.notes || "");
initialNotesRef.current = orderData.notes || "";
formInitializedRef.current = true;
}
}, [orderQuery.data]);
// Navigate away on fetch error
useEffect(() => {
if (orderQuery.error) {
alert.error("Nepodařilo se načíst objednávku");
navigate("/orders");
}
}, [orderQuery.error]); // eslint-disable-line react-hooks/exhaustive-deps
useEffect(() => {
return () => {
blobTimeoutsRef.current.forEach(clearTimeout);
};
}, []);
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");
queryClient.invalidateQueries({ queryKey: ["orders"] });
queryClient.invalidateQueries({ queryKey: ["offers"] });
queryClient.invalidateQueries({ queryKey: ["projects"] });
queryClient.invalidateQueries({ queryKey: ["invoices"] });
} 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;
queryClient.invalidateQueries({ queryKey: ["orders"] });
queryClient.invalidateQueries({ queryKey: ["offers"] });
queryClient.invalidateQueries({ queryKey: ["projects"] });
queryClient.invalidateQueries({ queryKey: ["invoices"] });
} 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");
await queryClient.invalidateQueries({ queryKey: ["orders"] });
await queryClient.invalidateQueries({ queryKey: ["offers"] });
await queryClient.invalidateQueries({ queryKey: ["projects"] });
await queryClient.invalidateQueries({ queryKey: ["invoices"] });
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 (!order) return null;
return (
<Skeleton
name="order-detail"
loading={loading}
fixture={<OrderDetailFixture />}
>
<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>
)
)}
{hasPermission("orders.export") && (
<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>
</Skeleton>
);
}