- feat: order confirmation PDF generation with VAT support
- feat: order confirmation modal with custom item editing
- fix: attendance negative duration clamping and switchProject timing
- fix: Quill editor locked to Tahoma 14px, PDF heading sizes
- fix: invoice/offer PDF font consistency (Tahoma enforcement)
- fix: invoice alert cron improvements
- fix: NAS financials manager edge cases
- refactor: numbering service with unique sequence constraints

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
BOHA
2026-04-23 17:23:10 +02:00
parent b197017644
commit 07cb428287
36 changed files with 2233 additions and 480 deletions

View File

@@ -70,8 +70,11 @@ function renderProjectCell(record: AttendanceRecord): React.ReactNode {
} else {
isActive = !log.ended_at;
const end = log.ended_at ? new Date(log.ended_at) : new Date();
const mins = Math.floor(
(end.getTime() - new Date(log.started_at!).getTime()) / 60000,
const mins = Math.max(
0,
Math.floor(
(end.getTime() - new Date(log.started_at!).getTime()) / 60000,
),
);
h = Math.floor(mins / 60);
m = mins % 60;

View File

@@ -0,0 +1,347 @@
import { useState, useCallback } from "react";
import { motion, AnimatePresence } from "framer-motion";
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, items?: ConfirmationItem[]) => Promise<void>;
initialItems: ConfirmationItem[];
orderNumber: string;
defaultVatRate: number;
}
export default function OrderConfirmationModal({
isOpen,
onClose,
onGenerate,
initialItems,
orderNumber,
defaultVatRate,
}: OrderConfirmationModalProps) {
const [step, setStep] = useState<"choose" | "edit">("choose");
const [lang, setLang] = useState<string>("cs");
const [items, setItems] = useState<ConfirmationItem[]>(initialItems);
const [loading, setLoading] = useState(false);
const handleUseExisting = async () => {
setLoading(true);
try {
await onGenerate(lang, undefined);
} finally {
setLoading(false);
setStep("choose");
onClose();
}
};
const handleEditGenerate = async () => {
setLoading(true);
try {
await onGenerate(lang, items);
} 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"
}
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 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">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>
);
}

View File

@@ -1,66 +1,7 @@
import { useMemo, useRef, useCallback } from "react";
import { useMemo, useRef, useCallback, useEffect } from "react";
import ReactQuill from "react-quill-new";
import "react-quill-new/dist/quill.snow.css";
const Quill = ReactQuill.Quill;
if (!(Quill as any).__bohaRegistered) {
const Font = Quill.import("attributors/class/font") as any;
Font.whitelist = [
"arial",
"tahoma",
"verdana",
"georgia",
"times-new-roman",
"courier-new",
"trebuchet-ms",
"impact",
"comic-sans-ms",
"lucida-console",
"palatino-linotype",
"garamond",
];
Quill.register(Font, true);
const SizeStyle = Quill.import("attributors/style/size") as any;
SizeStyle.whitelist = [
"8px",
"9px",
"10px",
"11px",
"12px",
"14px",
"16px",
"18px",
"20px",
"24px",
"28px",
"32px",
"36px",
"48px",
];
Quill.register(SizeStyle, true);
(Quill as any).__bohaRegistered = true;
}
const Font = Quill.import("attributors/class/font") as any;
const SIZE_WHITELIST = [
"8px",
"9px",
"10px",
"11px",
"12px",
"14px",
"16px",
"18px",
"20px",
"24px",
"28px",
"32px",
"36px",
"48px",
];
const COLORS = [
"#000000",
"#1a1a1a",
@@ -95,8 +36,6 @@ const COLORS = [
];
const TOOLBAR = [
[{ font: Font.whitelist }],
[{ size: SIZE_WHITELIST }],
["bold", "italic", "underline", "strike"],
[{ color: COLORS }, { background: COLORS }],
[{ list: "ordered" }, { list: "bullet" }],
@@ -107,8 +46,6 @@ const TOOLBAR = [
];
const FORMATS = [
"font",
"size",
"bold",
"italic",
"underline",
@@ -159,6 +96,13 @@ export default function RichEditor({
[onChange],
);
useEffect(() => {
if (!quillRef.current) return;
const editor = quillRef.current.getEditor();
editor.format("font", "tahoma");
editor.format("size", "14px");
}, []);
return (
<div
className="admin-rich-editor"

View File

@@ -381,7 +381,8 @@
.admin-rich-editor .ql-container.ql-snow {
border: none;
border-radius: 0 0 0.5rem 0.5rem;
font-size: 0.875rem;
font-family: Tahoma, sans-serif;
font-size: 14px;
}
.admin-rich-editor .ql-editor {
@@ -389,7 +390,8 @@
padding: 0.75rem;
color: var(--text-primary);
line-height: 1.6;
font-size: 0.875rem;
font-family: Tahoma, sans-serif;
font-size: 14px;
background: var(--input-bg);
}

View File

@@ -609,8 +609,11 @@ export default function Attendance() {
const end = log.ended_at
? new Date(log.ended_at)
: new Date();
const mins = Math.floor(
(end.getTime() - start.getTime()) / 60000,
const mins = Math.max(
0,
Math.floor(
(end.getTime() - start.getTime()) / 60000,
),
);
const h = Math.floor(mins / 60);
const mm = mins % 60;

View File

@@ -85,8 +85,11 @@ const renderProjectCell = (record: AttendanceRecord) => {
} else {
isActive = !log.ended_at;
const end = log.ended_at ? new Date(log.ended_at) : new Date();
const mins = Math.floor(
(end.getTime() - new Date(log.started_at!).getTime()) / 60000,
const mins = Math.max(
0,
Math.floor(
(end.getTime() - new Date(log.started_at!).getTime()) / 60000,
),
);
h = Math.floor(mins / 60);
m = mins % 60;

View File

@@ -785,9 +785,8 @@ export default function InvoiceDetail() {
setSaving(true);
try {
const payload = {
const payload: any = {
...form,
invoice_number: invoiceNumber,
items: items
.filter((i) => i.description.trim())
.map((item, i) => ({
@@ -795,6 +794,7 @@ export default function InvoiceDetail() {
position: i,
})),
};
if (isEdit) payload.invoice_number = invoiceNumber;
const url = isEdit
? `${API_BASE}/invoices/${id}`
@@ -1416,19 +1416,12 @@ export default function InvoiceDetail() {
<input
type="text"
value={invoiceNumber}
onChange={(e) => {
if (!isEdit) setInvoiceNumber(e.target.value);
}}
readOnly
className="admin-form-input"
readOnly={isEdit}
style={
isEdit
? {
backgroundColor: "var(--bg-secondary)",
cursor: "default",
}
: undefined
}
style={{
backgroundColor: "var(--bg-secondary)",
cursor: "default",
}}
/>
</FormField>
<FormField label="Odběratel" error={errors.customer_id} required>

View File

@@ -635,14 +635,17 @@ export default function OfferDetail() {
setSaving(true);
try {
const url = isEdit ? `${API_BASE}/offers/${id}` : `${API_BASE}/offers`;
const payload: any = {
...form,
items: items.map((item, i) => ({ ...item, position: i })),
sections: sections.map((s, i) => ({ ...s, position: i })),
};
if (!isEdit) delete payload.quotation_number;
const response = await apiFetch(url, {
method: isEdit ? "PUT" : "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
...form,
items: items.map((item, i) => ({ ...item, position: i })),
sections: sections.map((s, i) => ({ ...s, position: i })),
}),
body: JSON.stringify(payload),
});
const result = await response.json();
if (result.success) {
@@ -1016,13 +1019,12 @@ export default function OfferDetail() {
<input
type="text"
value={form.quotation_number}
onChange={(e) =>
setForm((prev) => ({
...prev,
quotation_number: e.target.value,
}))
}
readOnly
className="admin-form-input"
style={{
backgroundColor: "var(--bg-secondary)",
cursor: "default",
}}
/>
</FormField>
<FormField label="Kód projektu">

View File

@@ -11,6 +11,7 @@ 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";
@@ -112,13 +113,12 @@ export default function OrderDetail() {
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 [showConfirmationModal, setShowConfirmationModal] = useState(false);
const [confirmationLoading, setConfirmationLoading] = useState(false);
const fetchDetail = useCallback(async () => {
try {
@@ -186,42 +186,6 @@ export default function OrderDetail() {
}
};
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 {
@@ -265,6 +229,48 @@ export default function OrderDetail() {
}
};
const handleGenerateConfirmation = async (
lang: string,
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, 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);
setTimeout(() => URL.revokeObjectURL(url), 60000);
} catch {
alert.error("Chyba připojení");
} finally {
setConfirmationLoading(false);
}
};
const handleDelete = async () => {
setDeleting(true);
try {
@@ -361,102 +367,7 @@ export default function OrderDetail() {
</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>Objednávka {order.order_number}</span>
<span
className={`admin-badge ${STATUS_CLASSES[order.status] || ""}`}
>
@@ -506,6 +417,24 @@ export default function OrderDetail() {
</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 &&
@@ -900,6 +829,25 @@ export default function OrderDetail() {
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}
/>
)}
</div>
);
}

View File

@@ -161,7 +161,6 @@ export default function ProjectCreate() {
name: form.name.trim(),
customer_id: form.customer_id,
start_date: form.start_date,
project_number: form.project_number.trim(),
responsible_user_id: form.responsible_user_id || null,
};
@@ -172,7 +171,7 @@ export default function ProjectCreate() {
});
const data = await res.json();
if (data.success) {
navigate(`/projects/${data.data.project_id}`, {
navigate(`/projects/${data.data.id}`, {
state: { created: true },
});
} else {
@@ -265,9 +264,12 @@ export default function ProjectCreate() {
<input
type="text"
value={form.project_number}
onChange={(e) => updateForm("project_number", e.target.value)}
readOnly
className="admin-form-input"
placeholder="Ponechte prázdné pro automatické"
style={{
backgroundColor: "var(--bg-secondary)",
cursor: "default",
}}
/>
</FormField>
<FormField label="Název" error={errors.name} required>