Files
app/src/admin/pages/Offers.tsx
BOHA 5233db2002 v1.8.0: warehouse module (16 commits), docházka mzda model, leave_type=holiday removal, api.ts race fix
Highlights:
- Warehouse module: receipts, issues, reservations, inventory, reports, dashboard,
  master data (categories, suppliers, locations), FIFO service, integration tests
- Docházka: mzda PDF counting model (Odpracováno / Vč. svátků / Přesčas / Svátek /
  So/Ne / Noc) with Czech weekday names and decimal hours
- AttendanceAdmin/AttendanceHistory KPI cards unified to mzda formula with
  fund bar colored by delta, badges for Práce/Dov/Nem/Sv/Nep
- Remove leave_type=holiday entirely (auto-computed from Czech public holidays)
- Allow multiple work shifts per day (overlap detection only)
- Pre-flight refresh in api.ts eliminates spurious 401s on fresh page loads
- Prisma: company_settings gets 6 nullable columns for warehouse numbering
  (PRI/VYD/INV prefixes, default patterns); migration seeds defaults
2026-06-03 23:13:10 +02:00

1043 lines
41 KiB
TypeScript

import { useState, useEffect, useRef } from "react";
import { useAlert } from "../context/AlertContext";
import { useAuth } from "../context/AuthContext";
import { Link, useNavigate } from "react-router-dom";
import { motion, AnimatePresence } from "framer-motion";
import ConfirmModal from "../components/ConfirmModal";
import FormModal from "../components/FormModal";
import Forbidden from "../components/Forbidden";
import apiFetch from "../utils/api";
import { formatCurrency, formatDate, czechPlural } from "../utils/formatters";
import SortIcon from "../components/SortIcon";
import useTableSort from "../hooks/useTableSort";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { usePaginatedQuery } from "../hooks/usePaginatedQuery";
import { offerListOptions, offerCustomersOptions } from "../lib/queries/offers";
import Pagination from "../components/Pagination";
import FormField from "../components/FormField";
import { useApiMutation } from "../lib/queries/mutations";
const API_BASE = "/api/admin";
const DRAFT_KEY = "boha_offer_draft";
const STATUS_FILTERS = [
{ value: "", label: "Vše" },
{ value: "active", label: "Aktivní" },
{ value: "ordered", label: "Objednaná" },
{ value: "invalidated", label: "Zneplatněná" },
];
interface Quotation {
id: number;
quotation_number: string;
project_code: string;
customer_name: string;
created_at: string;
valid_until: string;
currency: string;
total: number;
status: string;
order_id?: number;
order_status?: string;
}
interface Draft {
form: {
project_code: string;
customer_name: string;
created_at: string;
valid_until: string;
currency: string;
};
items: unknown[];
savedAt?: string;
}
export default function Offers() {
const alert = useAlert();
const { hasPermission } = useAuth();
const navigate = useNavigate();
const { sort, order, handleSort, activeSort } =
useTableSort("quotation_number");
const [search, setSearch] = useState("");
const [page, setPage] = useState(1);
const [statusFilter, setStatusFilter] = useState("");
const [customerFilter, setCustomerFilter] = useState<number | "">("");
const { data: customers } = useQuery(offerCustomersOptions());
const [deleteConfirm, setDeleteConfirm] = useState<{
show: boolean;
quotation: Quotation | null;
}>({ show: false, quotation: null });
const [deleting, setDeleting] = useState(false);
const [invalidateConfirm, setInvalidateConfirm] = useState<{
show: boolean;
quotation: Quotation | null;
}>({ show: false, quotation: null });
const [invalidating, setInvalidating] = useState(false);
const blobUrlRef = useRef<string | null>(null);
useEffect(() => {
return () => {
if (blobUrlRef.current) {
URL.revokeObjectURL(blobUrlRef.current);
blobUrlRef.current = null;
}
};
}, []);
const [duplicating, setDuplicating] = useState<number | null>(null);
const [pdfLoading, setPdfLoading] = useState<number | null>(null);
const [creatingOrder, setCreatingOrder] = useState<number | null>(null);
const [orderModal, setOrderModal] = useState<{
show: boolean;
quotation: Quotation | null;
}>({ show: false, quotation: null });
const [customerOrderNumber, setCustomerOrderNumber] = useState("");
const [orderAttachment, setOrderAttachment] = useState<File | null>(null);
const [draft, setDraft] = useState<Draft | null>(() => {
try {
const raw = localStorage.getItem(DRAFT_KEY);
if (!raw) return null;
const parsed = JSON.parse(raw);
if (parsed && parsed.form && Array.isArray(parsed.items)) return parsed;
} catch {
/* ignore corrupt data */
}
return null;
});
const queryClient = useQueryClient();
const {
items: quotations,
pagination,
isPending,
isFetching,
} = usePaginatedQuery<Quotation>(
offerListOptions({
search,
sort,
order,
page,
status: statusFilter || undefined,
customer_id: customerFilter || undefined,
}),
);
const duplicateMutation = useApiMutation<
number,
{ message?: string; error?: string }
>({
url: (id) => `${API_BASE}/offers/${id}/duplicate`,
method: () => "POST",
invalidate: ["offers", "orders", "projects", "invoices"],
onSuccess: (data) => {
alert.success(data?.message || "Nabídka byla duplikována");
},
});
const deleteOfferMutation = useApiMutation<
number,
{ message?: string; error?: string }
>({
url: (id) => `${API_BASE}/offers/${id}`,
method: () => "DELETE",
invalidate: ["offers", "orders", "projects", "invoices"],
onSuccess: (data) => {
setDeleteConfirm({ show: false, quotation: null });
alert.success(data?.message || "Nabídka byla smazána");
},
});
const invalidateMutation = useApiMutation<
number,
{ message?: string; error?: string }
>({
url: (id) => `${API_BASE}/offers/${id}/invalidate`,
method: () => "POST",
invalidate: ["offers", "orders", "projects", "invoices"],
onSuccess: (data) => {
setInvalidateConfirm({ show: false, quotation: null });
alert.success(data?.message || "Nabídka byla zneplatněna");
},
});
const discardDraft = () => {
try {
localStorage.removeItem(DRAFT_KEY);
} catch {
/* ignore */
}
setDraft(null);
};
const getRowClass = (
invalidated: boolean,
expired: boolean,
completed: boolean,
) => {
if (invalidated) return "offers-invalidated-row";
if (completed) return "offers-completed-row";
if (expired) return "offers-expired-row";
return "";
};
if (!hasPermission("offers.view")) return <Forbidden />;
const handleDuplicate = async (quotation: Quotation) => {
setDuplicating(quotation.id);
try {
await duplicateMutation.mutateAsync(quotation.id);
} catch (e) {
alert.error(e instanceof Error ? e.message : "Chyba připojení");
} finally {
setDuplicating(null);
}
};
const handleCreateOrder = async () => {
if (!customerOrderNumber.trim() || !orderModal.quotation) return;
setCreatingOrder(orderModal.quotation.id);
try {
let fetchOptions: RequestInit;
if (orderAttachment) {
// With attachment: send as multipart/form-data
const formData = new FormData();
formData.append("quotationId", String(orderModal.quotation.id));
formData.append("customerOrderNumber", customerOrderNumber.trim());
formData.append("attachment", orderAttachment);
fetchOptions = { method: "POST", body: formData };
} else {
fetchOptions = {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
quotationId: orderModal.quotation.id,
customerOrderNumber: customerOrderNumber.trim(),
}),
};
}
const response = await apiFetch(`${API_BASE}/orders`, fetchOptions);
const result = await response.json();
if (result.success) {
setOrderModal({ show: false, quotation: null });
alert.success(result.message || "Objednávka byla vytvořena");
queryClient.invalidateQueries({ queryKey: ["offers"] });
queryClient.invalidateQueries({ queryKey: ["orders"] });
queryClient.invalidateQueries({ queryKey: ["projects"] });
queryClient.invalidateQueries({ queryKey: ["invoices"] });
navigate(`/orders/${result.data.order_id}`);
} else {
alert.error(result.error || "Nepodařilo se vytvořit objednávku");
}
} catch {
alert.error("Chyba připojení");
} finally {
setCreatingOrder(null);
}
};
const handleDelete = async () => {
if (!deleteConfirm.quotation) return;
setDeleting(true);
try {
await deleteOfferMutation.mutateAsync(deleteConfirm.quotation.id);
} catch (e) {
alert.error(e instanceof Error ? e.message : "Chyba připojení");
} finally {
setDeleting(false);
}
};
const handleInvalidate = async () => {
if (!invalidateConfirm.quotation) return;
setInvalidating(true);
try {
await invalidateMutation.mutateAsync(invalidateConfirm.quotation.id);
} catch (e) {
alert.error(e instanceof Error ? e.message : "Chyba připojení");
} finally {
setInvalidating(false);
}
};
const handlePdf = async (quotation: Quotation) => {
if (pdfLoading) return;
const newWindow = window.open("", "_blank");
setPdfLoading(quotation.id);
try {
const response = await apiFetch(
`${API_BASE}/offers/${quotation.id}/file`,
);
if (response.status === 401) {
newWindow?.close();
return;
}
if (!response.ok) {
newWindow?.close();
alert.error("PDF soubor nenalezen — otevřete nabídku a uložte ji");
return;
}
const blob = await response.blob();
if (blobUrlRef.current) {
URL.revokeObjectURL(blobUrlRef.current);
}
blobUrlRef.current = URL.createObjectURL(blob);
if (newWindow) newWindow.location.href = blobUrlRef.current;
} catch {
newWindow?.close();
alert.error("Chyba připojení");
} finally {
setPdfLoading(null);
}
};
if (isPending) {
return (
<div className="admin-loading">
<div className="admin-spinner" />
</div>
);
}
return (
<div>
<motion.div
className="admin-page-header"
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25 }}
>
<div>
<h1 className="admin-page-title">Nabídky</h1>
<p className="admin-page-subtitle">
{pagination?.total ?? quotations.length}{" "}
{czechPlural(
pagination?.total ?? quotations.length,
"nabídka",
"nabídky",
"nabídek",
)}
</p>
</div>
<div className="admin-page-actions">
{hasPermission("settings.templates") && (
<Link
to="/offers/templates"
className="admin-btn admin-btn-secondary"
>
<svg
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<rect x="3" y="3" width="18" height="18" rx="2" />
<path d="M3 9h18M9 21V9" />
</svg>
Šablony
</Link>
)}
{hasPermission("offers.create") && (
<Link to="/offers/new" className="admin-btn admin-btn-primary">
<svg
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<line x1="12" y1="5" x2="12" y2="19" />
<line x1="5" y1="12" x2="19" y2="12" />
</svg>
Nová nabídka
</Link>
)}
</div>
</motion.div>
<motion.div
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, delay: 0.1 }}
>
<div className="admin-tabs mb-6">
{STATUS_FILTERS.map((f) => (
<button
key={f.value}
className={`admin-tab ${statusFilter === f.value ? "active" : ""}`}
onClick={() => {
setStatusFilter(f.value);
setPage(1);
}}
>
{f.label}
</button>
))}
</div>
</motion.div>
<motion.div
className="admin-card"
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, delay: 0.15 }}
style={{ opacity: isFetching ? 0.6 : 1, transition: "opacity 0.2s" }}
>
<div className="admin-card-body">
<div className="admin-search-bar mb-4">
<input
type="text"
value={search}
onChange={(e) => {
setSearch(e.target.value);
setPage(1);
}}
className="admin-form-input"
placeholder="Hledat podle čísla, projektu nebo zákazníka..."
/>
</div>
<div className="admin-filters mb-4">
<div className="admin-filter-group">
<label className="admin-filter-label">Zákazník</label>
<select
value={customerFilter}
onChange={(e) => {
setCustomerFilter(
e.target.value ? Number(e.target.value) : "",
);
setPage(1);
}}
className="admin-form-select"
>
<option value="">Všichni</option>
{customers?.map((c) => (
<option key={c.id} value={c.id}>
{c.name}
</option>
))}
</select>
</div>
</div>
<AnimatePresence mode="wait">
<motion.div
key={`${statusFilter}-${customerFilter}-${search}-${page}-${quotations.length}`}
initial={{ opacity: 0, y: 6 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.15 }}
>
{quotations.length === 0 && !draft ? (
<div className="admin-empty-state">
<div className="admin-empty-icon">
<svg
width="28"
height="28"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
>
<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" />
<line x1="12" y1="18" x2="12" y2="12" />
<line x1="9" y1="15" x2="15" y2="15" />
</svg>
</div>
<p>Zatím nejsou žádné nabídky.</p>
{hasPermission("offers.create") && (
<Link
to="/offers/new"
className="admin-btn admin-btn-primary"
>
Vytvořit první nabídku
</Link>
)}
</div>
) : (
<div className="admin-table-responsive">
<table className="admin-table">
<thead>
<tr>
<th
style={{ cursor: "pointer" }}
onClick={() => handleSort("quotation_number")}
>
Číslo{" "}
<SortIcon
column="quotation_number"
sort={activeSort}
order={order}
/>
</th>
<th
style={{ cursor: "pointer" }}
onClick={() => handleSort("project_code")}
>
Projekt{" "}
<SortIcon
column="project_code"
sort={activeSort}
order={order}
/>
</th>
<th>Zákazník</th>
<th
style={{ cursor: "pointer" }}
onClick={() => handleSort("created_at")}
>
Datum{" "}
<SortIcon
column="created_at"
sort={activeSort}
order={order}
/>
</th>
<th
style={{ cursor: "pointer" }}
onClick={() => handleSort("valid_until")}
>
Platnost{" "}
<SortIcon
column="valid_until"
sort={activeSort}
order={order}
/>
</th>
<th
style={{ cursor: "pointer" }}
onClick={() => handleSort("currency")}
>
Měna{" "}
<SortIcon
column="currency"
sort={activeSort}
order={order}
/>
</th>
<th className="text-right">Celkem</th>
<th>Akce</th>
</tr>
</thead>
<tbody>
{draft && !search && (
<tr className="offers-draft-row">
<td>
<span className="offers-draft-row-label">
Koncept
{draft.savedAt && (
<span style={{ fontWeight: 400, opacity: 0.8 }}>
{" · "}
{new Date(draft.savedAt).toLocaleTimeString(
"cs-CZ",
{ hour: "2-digit", minute: "2-digit" },
)}
</span>
)}
</span>
</td>
<td>{draft.form.project_code || "—"}</td>
<td>{draft.form.customer_name || "—"}</td>
<td className="admin-mono">
{draft.form.created_at
? formatDate(draft.form.created_at)
: "—"}
</td>
<td className="admin-mono">
{draft.form.valid_until
? formatDate(draft.form.valid_until)
: "—"}
</td>
<td>
<span className="admin-badge admin-badge-secondary">
{draft.form.currency || "—"}
</span>
</td>
<td />
<td>
<div className="admin-table-actions">
<Link
to="/offers/new"
className="admin-btn-icon"
title="Pokračovat v konceptu"
aria-label="Pokračovat v konceptu"
>
<svg
width="18"
height="18"
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>
</Link>
<button
onClick={discardDraft}
className="admin-btn-icon danger"
title="Zahodit koncept"
>
<svg
width="18"
height="18"
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>
</div>
</td>
</tr>
)}
{(quotations as Quotation[]).map((q) => {
const isInvalidated = q.status === "invalidated";
const isCompleted =
!isInvalidated && q.order_status === "dokoncena";
const isExpired =
!isInvalidated &&
!isCompleted &&
!q.order_id &&
q.valid_until &&
new Date(q.valid_until) <
new Date(new Date().toDateString());
const readOnly = isInvalidated || isCompleted;
return (
<tr
key={q.id}
className={getRowClass(
isInvalidated,
!!isExpired,
isCompleted,
)}
>
<td>
<Link
to={`/offers/${q.id}`}
className="link-accent"
>
{q.quotation_number}
</Link>
</td>
<td>{q.project_code || "—"}</td>
<td>{q.customer_name || "—"}</td>
<td className="admin-mono">
{formatDate(q.created_at)}
</td>
<td className="admin-mono">
{formatDate(q.valid_until)}
</td>
<td>
<span className="admin-badge admin-badge-secondary">
{q.currency}
</span>
</td>
<td className="admin-mono text-right fw-500">
{formatCurrency(q.total, q.currency)}
</td>
<td>
<div className="admin-table-actions">
<Link
to={`/offers/${q.id}`}
className="admin-btn-icon"
title={readOnly ? "Zobrazit" : "Upravit"}
aria-label={readOnly ? "Zobrazit" : "Upravit"}
>
{readOnly ? (
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z" />
<circle cx="12" cy="12" r="3" />
</svg>
) : (
<svg
width="18"
height="18"
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>
)}
</Link>
{!readOnly &&
hasPermission("offers.create") && (
<button
onClick={() => handleDuplicate(q)}
className="admin-btn-icon"
title="Duplikovat"
disabled={duplicating === q.id}
>
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<rect
x="9"
y="9"
width="13"
height="13"
rx="2"
ry="2"
/>
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" />
</svg>
</button>
)}
{!readOnly && q.order_id ? (
<Link
to={`/orders/${q.order_id}`}
className="admin-btn-icon accent"
title="Zobrazit objednávku"
aria-label="Zobrazit objednávku"
>
<svg
width="18"
height="18"
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" />
<text
x="12"
y="16.5"
textAnchor="middle"
fill="currentColor"
stroke="none"
fontSize="9"
fontWeight="700"
>
O
</text>
</svg>
</Link>
) : (
!readOnly &&
hasPermission("orders.create") && (
<button
onClick={() => {
setCustomerOrderNumber("");
setOrderAttachment(null);
setOrderModal({
show: true,
quotation: q,
});
}}
className="admin-btn-icon"
title="Vytvořit objednávku"
disabled={creatingOrder === q.id}
>
{creatingOrder === q.id ? (
<div
className="admin-spinner"
style={{
width: 18,
height: 18,
borderWidth: 2,
}}
/>
) : (
<svg
width="18"
height="18"
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" />
<line
x1="12"
y1="11"
x2="12"
y2="17"
/>
<line
x1="9"
y1="14"
x2="15"
y2="14"
/>
</svg>
)}
</button>
)
)}
{!isInvalidated &&
!isCompleted &&
!q.order_id &&
hasPermission("offers.edit") && (
<button
onClick={() =>
setInvalidateConfirm({
show: true,
quotation: q,
})
}
className="admin-btn-icon"
title="Zneplatnit"
>
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<circle cx="12" cy="12" r="10" />
<line
x1="4.93"
y1="4.93"
x2="19.07"
y2="19.07"
/>
</svg>
</button>
)}
{hasPermission("offers.export") && (
<button
onClick={() => handlePdf(q)}
className="admin-btn-icon"
title="Zobrazit nabídku"
disabled={pdfLoading === q.id}
>
{pdfLoading === q.id ? (
<div
className="admin-spinner"
style={{
width: 18,
height: 18,
borderWidth: 2,
}}
/>
) : (
<svg
width="18"
height="18"
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" />
<line x1="16" y1="13" x2="8" y2="13" />
<line x1="16" y1="17" x2="8" y2="17" />
</svg>
)}
</button>
)}
{hasPermission("offers.delete") && (
<button
onClick={() =>
setDeleteConfirm({
show: true,
quotation: q,
})
}
className="admin-btn-icon danger"
title="Smazat"
>
<svg
width="18"
height="18"
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>
)}
</div>
</td>
</tr>
);
})}
{quotations.length === 0 && draft && search && (
<tr>
<td
colSpan={8}
className="text-muted text-center"
style={{ padding: "1.5rem" }}
>
Žádné nabídky odpovídající hledání.
</td>
</tr>
)}
</tbody>
</table>
</div>
)}
</motion.div>
</AnimatePresence>
<Pagination pagination={pagination} onPageChange={setPage} />
</div>
</motion.div>
<ConfirmModal
isOpen={deleteConfirm.show}
onClose={() => setDeleteConfirm({ show: false, quotation: null })}
onConfirm={handleDelete}
title="Smazat nabídku"
message={`Opravdu chcete smazat nabídku "${deleteConfirm.quotation?.quotation_number}"? Budou smazány i všechny položky a sekce. Tato akce je nevratná.`}
confirmText="Smazat"
cancelText="Zrušit"
type="danger"
loading={deleting}
/>
<ConfirmModal
isOpen={invalidateConfirm.show}
onClose={() => setInvalidateConfirm({ show: false, quotation: null })}
onConfirm={handleInvalidate}
title="Zneplatnit nabídku"
message={`Opravdu chcete zneplatnit nabídku "${invalidateConfirm.quotation?.quotation_number}"? Nabídka bude pouze pro čtení a nepůjde upravovat.`}
confirmText="Zneplatnit"
cancelText="Zrušit"
type="danger"
loading={invalidating}
/>
<FormModal
isOpen={orderModal.show}
onClose={() => setOrderModal({ show: false, quotation: null })}
onSubmit={handleCreateOrder}
title="Vytvořit objednávku"
submitLabel={creatingOrder ? "Vytváření..." : "Vytvořit"}
loading={!!creatingOrder}
>
<p
className="text-secondary text-md"
style={{ marginTop: "-0.5rem", marginBottom: "0.5rem" }}
>
Nabídka: <strong>{orderModal.quotation?.quotation_number}</strong>
</p>
<div className="admin-form">
<FormField label="Číslo objednávky zákazníka" required>
<input
type="text"
value={customerOrderNumber}
onChange={(e) => setCustomerOrderNumber(e.target.value)}
onKeyDown={(e) =>
e.key === "Enter" && !creatingOrder && handleCreateOrder()
}
className="admin-form-input"
placeholder="Např. PO-2026-001"
autoFocus
/>
</FormField>
<FormField label="Příloha (PDF)">
{orderAttachment ? (
<div className="flex-row gap-2">
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="var(--accent-color)"
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>
<span className="text-md">
{orderAttachment.name}{" "}
<span className="text-tertiary">
({(orderAttachment.size / 1024).toFixed(0)} KB)
</span>
</span>
<button
type="button"
onClick={() => setOrderAttachment(null)}
className="admin-btn-icon"
title="Odebrat"
style={{ marginLeft: "auto" }}
>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<path d="M18 6L6 18M6 6l12 12" />
</svg>
</button>
</div>
) : (
<label
className="admin-btn admin-btn-secondary admin-btn-sm inline-flex"
style={{
cursor: "pointer",
gap: "0.4rem",
}}
>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
<polyline points="17 8 12 3 7 8" />
<line x1="12" y1="3" x2="12" y2="15" />
</svg>
Vybrat soubor
<input
type="file"
accept="application/pdf"
onChange={(e) =>
setOrderAttachment(e.target.files?.[0] || null)
}
style={{ display: "none" }}
/>
</label>
)}
<small className="admin-form-hint" style={{ marginTop: "0.25rem" }}>
Max 10 MB
</small>
</FormField>
</div>
</FormModal>
</div>
);
}