feat(orders-ui): manual order price line item + PO upload (hybrid submit)

Adds price/quantity fields (→ one line item) and a PDF attachment picker
to the manual-order create modal. Submit now uses apiFetch directly:
FormData (multipart) when a file is attached, JSON otherwise. Navigates
to the new order on success. Removes the old useApiMutation-based
createMutation; deleteMutation is untouched.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
BOHA
2026-06-06 13:58:21 +02:00
parent bc62d32efc
commit 337feb8b3a

View File

@@ -1,8 +1,9 @@
import { useState } from "react"; import { useState } from "react";
import { useQuery } from "@tanstack/react-query"; import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useAlert } from "../context/AlertContext"; import { useAlert } from "../context/AlertContext";
import { useAuth } from "../context/AuthContext"; import { useAuth } from "../context/AuthContext";
import { Link } from "react-router-dom"; import { Link, useNavigate } from "react-router-dom";
import apiFetch from "../utils/api";
import Forbidden from "../components/Forbidden"; import Forbidden from "../components/Forbidden";
import { motion } from "framer-motion"; import { motion } from "framer-motion";
import ConfirmModal from "../components/ConfirmModal"; import ConfirmModal from "../components/ConfirmModal";
@@ -89,8 +90,13 @@ export default function Orders() {
scope_description: "", scope_description: "",
notes: "", notes: "",
create_project: true, create_project: true,
price: "",
quantity: "1",
}); });
const [creating, setCreating] = useState(false); const [creating, setCreating] = useState(false);
const queryClient = useQueryClient();
const navigate = useNavigate();
const [orderAttachment, setOrderAttachment] = useState<File | null>(null);
const customersQuery = useQuery({ const customersQuery = useQuery({
...offerCustomersOptions(), ...offerCustomersOptions(),
@@ -101,23 +107,6 @@ export default function Orders() {
enabled: showCreate, enabled: showCreate,
}); });
// The payload normalises customer_id: "" → null. CreateOrderSchema's
// customer_id coercion does NOT special-case "", so a raw "" would become
// Number("") = 0 and hit a foreign-key violation.
type OrderCreatePayload = Omit<typeof createForm, "customer_id"> & {
customer_id: number | null;
};
const createMutation = useApiMutation<OrderCreatePayload, { id: number }>({
url: () => `${API_BASE}/orders`,
method: () => "POST",
invalidate: ["orders", "projects", "offers", "invoices"],
onSuccess: () => {
setShowCreate(false);
alert.success("Objednávka byla vytvořena");
},
});
const openCreate = () => { const openCreate = () => {
setCreateForm({ setCreateForm({
customer_id: "", customer_id: "",
@@ -129,21 +118,82 @@ export default function Orders() {
scope_description: "", scope_description: "",
notes: "", notes: "",
create_project: true, create_project: true,
price: "",
quantity: "1",
}); });
setOrderAttachment(null);
setShowCreate(true); setShowCreate(true);
}; };
const handleCreate = async () => { const handleCreate = async () => {
setCreating(true); setCreating(true);
try { try {
await createMutation.mutateAsync({ const priceNum = createForm.price ? Number(createForm.price) : 0;
...createForm, const qtyNum = createForm.quantity ? Number(createForm.quantity) : 1;
customer_id: createForm.customer_id const items =
? Number(createForm.customer_id) priceNum > 0
: null, ? [
}); {
} catch (e) { description: createForm.scope_title || "Položka",
alert.error(e instanceof Error ? e.message : "Chyba připojení"); quantity: qtyNum,
unit_price: priceNum,
is_included_in_total: true,
},
]
: undefined;
let fetchOptions: RequestInit;
if (orderAttachment) {
const fd = new FormData();
if (createForm.customer_id)
fd.append("customer_id", createForm.customer_id);
fd.append("customer_order_number", createForm.customer_order_number);
fd.append("currency", createForm.currency);
fd.append("vat_rate", createForm.vat_rate);
fd.append("apply_vat", createForm.apply_vat ? "1" : "0");
fd.append("scope_title", createForm.scope_title);
fd.append("scope_description", createForm.scope_description);
fd.append("notes", createForm.notes);
fd.append("create_project", createForm.create_project ? "1" : "0");
if (items) fd.append("items", JSON.stringify(items));
fd.append("attachment", orderAttachment);
fetchOptions = { method: "POST", body: fd };
} else {
fetchOptions = {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
customer_id: createForm.customer_id
? Number(createForm.customer_id)
: null,
customer_order_number: createForm.customer_order_number,
currency: createForm.currency,
vat_rate: createForm.vat_rate,
apply_vat: createForm.apply_vat,
scope_title: createForm.scope_title,
scope_description: createForm.scope_description,
notes: createForm.notes,
create_project: createForm.create_project,
...(items ? { items } : {}),
}),
};
}
const response = await apiFetch(`${API_BASE}/orders`, fetchOptions);
const result = await response.json();
if (result.success) {
setShowCreate(false);
alert.success(result.message || "Objednávka byla vytvořena");
await queryClient.invalidateQueries({ queryKey: ["orders"] });
await queryClient.invalidateQueries({ queryKey: ["projects"] });
await queryClient.invalidateQueries({ queryKey: ["offers"] });
await queryClient.invalidateQueries({ queryKey: ["invoices"] });
if (result.data?.id) navigate(`/orders/${result.data.id}`);
} else {
alert.error(result.error || "Nepodařilo se vytvořit objednávku");
}
} catch {
alert.error("Chyba připojení");
} finally { } finally {
setCreating(false); setCreating(false);
} }
@@ -547,6 +597,36 @@ export default function Orders() {
</FormField> </FormField>
</div> </div>
<div className="admin-form-row">
<FormField label="Cena za jednotku (bez DPH)">
<input
type="number"
inputMode="decimal"
value={createForm.price}
onChange={(e) =>
setCreateForm({ ...createForm, price: e.target.value })
}
className="admin-form-input"
min="0"
step="0.01"
placeholder="0"
/>
</FormField>
<FormField label="Množství">
<input
type="number"
inputMode="decimal"
value={createForm.quantity}
onChange={(e) =>
setCreateForm({ ...createForm, quantity: e.target.value })
}
className="admin-form-input"
min="0"
step="1"
/>
</FormField>
</div>
<FormField label="Předmět (scope)"> <FormField label="Předmět (scope)">
<input <input
type="text" type="text"
@@ -583,6 +663,57 @@ export default function Orders() {
/> />
</FormField> </FormField>
<FormField label="Příloha (PO zákazníka, PDF)">
{orderAttachment ? (
<div className="flex-row gap-2">
<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"
style={{
cursor: "pointer",
display: "inline-flex",
alignItems: "center",
gap: "0.4rem",
}}
>
Vybrat soubor
<input
type="file"
accept="application/pdf"
onChange={(e) =>
setOrderAttachment(e.target.files?.[0] || null)
}
style={{ display: "none" }}
/>
</label>
)}
</FormField>
<label className="admin-form-checkbox"> <label className="admin-form-checkbox">
<input <input
type="checkbox" type="checkbox"