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:
@@ -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"
|
||||||
|
|||||||
Reference in New Issue
Block a user