- Auth: TOTP replay protection with counter tracking, constant-time backup code comparison, atomic lockout increment, per-token logout - Invoices/PDFs: net-based VAT calculation, dangerous URL scheme stripping in cleanQuillHtml, orders-pdf error handling - Orders: reject item changes on status transition, cascading delete cleanup, take:1 with orderBy - Projects: atomic rename collision handling, MIME/extension validation, empty customer name rejection - Attendance: Czech public holiday awareness in frontend fund calculation, leave_hours 0 handling, invalid date NaN guard, bounded per-month queries in workfund - Users/Admin: profile audit logging + password validation, session revocation guard, session ID validation, dashboard DB aggregation, soft-deleted record protection in scope templates - Frontend: FormField label linkage, Pagination ARIA, error handling in OrderConfirmationModal, 401 propagation, GPS emoji hidden from screen readers, table sort state fix, geolocation race/abort cleanup, Leaflet popup DOM safety, Vehicles toggleActive minimal body, CompanySettings ref mutation fix, OfferDetail unlock abort, AttendanceBalances combined fetches - Utils: env validation, Puppeteer concurrency mutex, invoice alert cron cleanup on shutdown, body limit alignment, TOTP error logging, trustProxy from env, symlink rejection, rate cache Map usage Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
397 lines
14 KiB
TypeScript
397 lines
14 KiB
TypeScript
import { useState, useCallback } from "react";
|
|
import { motion, AnimatePresence } from "framer-motion";
|
|
import { useAlert } from "../context/AlertContext";
|
|
|
|
interface ConfirmationItem {
|
|
description: string;
|
|
quantity: number;
|
|
unit: string;
|
|
unit_price: number;
|
|
is_included_in_total: boolean;
|
|
vat_rate: number;
|
|
}
|
|
|
|
interface OrderConfirmationModalProps {
|
|
isOpen: boolean;
|
|
onClose: () => void;
|
|
onGenerate: (
|
|
lang: string,
|
|
applyVat: boolean,
|
|
items?: ConfirmationItem[],
|
|
) => Promise<void>;
|
|
initialItems: ConfirmationItem[];
|
|
orderNumber: string;
|
|
defaultVatRate: number;
|
|
applyVat: boolean;
|
|
}
|
|
|
|
export default function OrderConfirmationModal({
|
|
isOpen,
|
|
onClose,
|
|
onGenerate,
|
|
initialItems,
|
|
orderNumber,
|
|
defaultVatRate,
|
|
applyVat,
|
|
}: OrderConfirmationModalProps) {
|
|
const alert = useAlert();
|
|
const [step, setStep] = useState<"choose" | "edit">("choose");
|
|
const [lang, setLang] = useState<string>("cs");
|
|
const [applyVatState, setApplyVatState] = useState(applyVat);
|
|
const [items, setItems] = useState<ConfirmationItem[]>(initialItems);
|
|
const [loading, setLoading] = useState(false);
|
|
|
|
const handleUseExisting = async () => {
|
|
setLoading(true);
|
|
try {
|
|
await onGenerate(lang, applyVatState, undefined);
|
|
} catch (err) {
|
|
console.error("Chyba při generování potvrzení:", err);
|
|
alert.error("Nepodařilo se vygenerovat potvrzení");
|
|
} finally {
|
|
setLoading(false);
|
|
setStep("choose");
|
|
onClose();
|
|
}
|
|
};
|
|
|
|
const handleEditGenerate = async () => {
|
|
setLoading(true);
|
|
try {
|
|
await onGenerate(lang, applyVatState, items);
|
|
} catch (err) {
|
|
console.error("Chyba při generování potvrzení:", err);
|
|
alert.error("Nepodařilo se vygenerovat potvrzení");
|
|
} finally {
|
|
setLoading(false);
|
|
setStep("choose");
|
|
onClose();
|
|
}
|
|
};
|
|
|
|
const updateItem = useCallback(
|
|
(
|
|
index: number,
|
|
field: keyof ConfirmationItem,
|
|
value: string | number | boolean,
|
|
) => {
|
|
setItems((prev) => {
|
|
const next = [...prev];
|
|
next[index] = { ...next[index], [field]: value };
|
|
return next;
|
|
});
|
|
},
|
|
[],
|
|
);
|
|
|
|
const removeItem = useCallback((index: number) => {
|
|
setItems((prev) => prev.filter((_, i) => i !== index));
|
|
}, []);
|
|
|
|
const addItem = useCallback(() => {
|
|
setItems((prev) => [
|
|
...prev,
|
|
{
|
|
description: "",
|
|
quantity: 1,
|
|
unit: "ks",
|
|
unit_price: 0,
|
|
is_included_in_total: true,
|
|
vat_rate: defaultVatRate,
|
|
},
|
|
]);
|
|
}, [defaultVatRate]);
|
|
|
|
return (
|
|
<AnimatePresence>
|
|
{isOpen && (
|
|
<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={onClose} />
|
|
<motion.div
|
|
className={
|
|
step === "edit" ? "admin-modal admin-modal-lg" : "admin-modal"
|
|
}
|
|
role="dialog"
|
|
aria-modal="true"
|
|
aria-labelledby="order-confirmation-modal-title"
|
|
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
|
|
id="order-confirmation-modal-title"
|
|
className="admin-modal-title"
|
|
>
|
|
Potvrzení objednávky {orderNumber}
|
|
</h2>
|
|
</div>
|
|
|
|
<div className="admin-modal-body">
|
|
{step === "choose" ? (
|
|
<div className="admin-form">
|
|
<div className="admin-form-group">
|
|
<label className="admin-form-label">Jazyk dokumentu</label>
|
|
<div className="flex-row gap-2">
|
|
<button
|
|
type="button"
|
|
onClick={() => setLang("cs")}
|
|
className={
|
|
lang === "cs"
|
|
? "admin-btn admin-btn-primary admin-btn-sm"
|
|
: "admin-btn admin-btn-secondary admin-btn-sm"
|
|
}
|
|
>
|
|
Čeština
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => setLang("en")}
|
|
className={
|
|
lang === "en"
|
|
? "admin-btn admin-btn-primary admin-btn-sm"
|
|
: "admin-btn admin-btn-secondary admin-btn-sm"
|
|
}
|
|
>
|
|
English
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="admin-form-group">
|
|
<label className="admin-form-label">DPH</label>
|
|
<div className="flex-row gap-2">
|
|
<button
|
|
type="button"
|
|
onClick={() => setApplyVatState(true)}
|
|
className={
|
|
applyVatState
|
|
? "admin-btn admin-btn-primary admin-btn-sm"
|
|
: "admin-btn admin-btn-secondary admin-btn-sm"
|
|
}
|
|
>
|
|
S DPH
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => setApplyVatState(false)}
|
|
className={
|
|
!applyVatState
|
|
? "admin-btn admin-btn-primary admin-btn-sm"
|
|
: "admin-btn admin-btn-secondary admin-btn-sm"
|
|
}
|
|
>
|
|
Bez DPH
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="admin-form-group">
|
|
<label className="admin-form-label">Obsah potvrzení</label>
|
|
<p
|
|
className="text-secondary"
|
|
style={{ marginBottom: "0.75rem" }}
|
|
>
|
|
Jak chcete připravit potvrzení objednávky?
|
|
</p>
|
|
<button
|
|
onClick={handleUseExisting}
|
|
disabled={loading}
|
|
className="admin-btn admin-btn-primary w-full"
|
|
style={{ marginBottom: "0.5rem" }}
|
|
>
|
|
{loading ? (
|
|
<>
|
|
<div className="admin-spinner admin-spinner-sm" />
|
|
Generuji...
|
|
</>
|
|
) : (
|
|
"Použít položky z objednávky"
|
|
)}
|
|
</button>
|
|
<button
|
|
onClick={() => {
|
|
setItems(initialItems.length > 0 ? initialItems : []);
|
|
setStep("edit");
|
|
}}
|
|
disabled={loading}
|
|
className="admin-btn admin-btn-secondary w-full"
|
|
>
|
|
Upravit položky
|
|
</button>
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<div className="admin-form">
|
|
<div className="admin-table-responsive">
|
|
<table className="admin-table">
|
|
<thead>
|
|
<tr>
|
|
<th>Popis</th>
|
|
<th>Mn.</th>
|
|
<th>Jedn.</th>
|
|
<th>Cena</th>
|
|
<th>%DPH</th>
|
|
<th style={{ width: "40px" }} />
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{items.map((item, i) => (
|
|
<tr key={i}>
|
|
<td>
|
|
<input
|
|
type="text"
|
|
value={item.description}
|
|
onChange={(e) =>
|
|
updateItem(i, "description", e.target.value)
|
|
}
|
|
className="admin-form-input"
|
|
style={{ minWidth: "200px" }}
|
|
/>
|
|
</td>
|
|
<td>
|
|
<input
|
|
type="number"
|
|
value={item.quantity}
|
|
onChange={(e) =>
|
|
updateItem(
|
|
i,
|
|
"quantity",
|
|
Number(e.target.value) || 0,
|
|
)
|
|
}
|
|
className="admin-form-input"
|
|
style={{ width: "80px" }}
|
|
step="0.001"
|
|
/>
|
|
</td>
|
|
<td>
|
|
<input
|
|
type="text"
|
|
value={item.unit}
|
|
onChange={(e) =>
|
|
updateItem(i, "unit", e.target.value)
|
|
}
|
|
className="admin-form-input"
|
|
style={{ width: "60px" }}
|
|
/>
|
|
</td>
|
|
<td>
|
|
<input
|
|
type="number"
|
|
value={item.unit_price}
|
|
onChange={(e) =>
|
|
updateItem(
|
|
i,
|
|
"unit_price",
|
|
Number(e.target.value) || 0,
|
|
)
|
|
}
|
|
className="admin-form-input"
|
|
style={{ width: "100px" }}
|
|
step="0.01"
|
|
/>
|
|
</td>
|
|
<td>
|
|
<input
|
|
type="number"
|
|
value={item.vat_rate}
|
|
onChange={(e) =>
|
|
updateItem(
|
|
i,
|
|
"vat_rate",
|
|
Number(e.target.value) || 0,
|
|
)
|
|
}
|
|
className="admin-form-input"
|
|
style={{ width: "70px" }}
|
|
step="1"
|
|
/>
|
|
</td>
|
|
<td>
|
|
<button
|
|
onClick={() => removeItem(i)}
|
|
className="admin-btn-icon danger"
|
|
title="Odstranit"
|
|
>
|
|
<svg
|
|
width="16"
|
|
height="16"
|
|
viewBox="0 0 24 24"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
strokeWidth="2"
|
|
>
|
|
<polyline points="3 6 5 6 21 6" />
|
|
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
|
|
</svg>
|
|
</button>
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
<button
|
|
onClick={addItem}
|
|
className="admin-btn admin-btn-secondary admin-btn-sm"
|
|
>
|
|
+ Přidat položku
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<div className="admin-modal-footer">
|
|
{step === "edit" && (
|
|
<>
|
|
<button
|
|
type="button"
|
|
onClick={() => setStep("choose")}
|
|
className="admin-btn admin-btn-secondary"
|
|
disabled={loading}
|
|
>
|
|
Zpět
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={handleEditGenerate}
|
|
className="admin-btn admin-btn-primary"
|
|
disabled={loading || items.length === 0}
|
|
>
|
|
{loading ? (
|
|
<>
|
|
<div className="admin-spinner admin-spinner-sm" />
|
|
Generuji...
|
|
</>
|
|
) : (
|
|
"Vygenerovat PDF"
|
|
)}
|
|
</button>
|
|
</>
|
|
)}
|
|
{step === "choose" && (
|
|
<button
|
|
type="button"
|
|
onClick={onClose}
|
|
className="admin-btn admin-btn-secondary"
|
|
disabled={loading}
|
|
>
|
|
Zrušit
|
|
</button>
|
|
)}
|
|
</div>
|
|
</motion.div>
|
|
</motion.div>
|
|
)}
|
|
</AnimatePresence>
|
|
);
|
|
}
|