feat(orders-ui): add manual order create modal (header only, optional project)

Mirrors the Projects.tsx pattern: useQuery-driven customers + next-number
fetches (enabled only when modal is open), OrderCreatePayload type normalises
customer_id "" → null so the server's CreateOrderSchema never receives 0.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
BOHA
2026-06-06 11:17:53 +02:00
parent 5140d23d73
commit 30dd5a3f3b

View File

@@ -1,16 +1,23 @@
import { useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { useAlert } from "../context/AlertContext";
import { useAuth } from "../context/AuthContext";
import { Link } from "react-router-dom";
import Forbidden from "../components/Forbidden";
import { motion } from "framer-motion";
import ConfirmModal from "../components/ConfirmModal";
import FormModal from "../components/FormModal";
import FormField from "../components/FormField";
import { formatCurrency, formatDate, czechPlural } from "../utils/formatters";
import SortIcon from "../components/SortIcon";
import useTableSort from "../hooks/useTableSort";
import { usePaginatedQuery } from "../hooks/usePaginatedQuery";
import { orderListOptions } from "../lib/queries/orders";
import {
orderListOptions,
orderNextNumberOptions,
} from "../lib/queries/orders";
import { offerCustomersOptions } from "../lib/queries/offers";
import { useApiMutation } from "../lib/queries/mutations";
import Pagination from "../components/Pagination";
@@ -71,6 +78,77 @@ export default function Orders() {
},
});
const [showCreate, setShowCreate] = useState(false);
const [createForm, setCreateForm] = useState({
customer_id: "",
customer_order_number: "",
currency: "CZK",
vat_rate: "21",
apply_vat: true,
scope_title: "",
scope_description: "",
notes: "",
create_project: true,
});
const [creating, setCreating] = useState(false);
const customersQuery = useQuery({
...offerCustomersOptions(),
enabled: showCreate,
});
const nextNumberQuery = useQuery({
...orderNextNumberOptions(),
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 = () => {
setCreateForm({
customer_id: "",
customer_order_number: "",
currency: "CZK",
vat_rate: "21",
apply_vat: true,
scope_title: "",
scope_description: "",
notes: "",
create_project: true,
});
setShowCreate(true);
};
const handleCreate = async () => {
setCreating(true);
try {
await createMutation.mutateAsync({
...createForm,
customer_id: createForm.customer_id
? Number(createForm.customer_id)
: null,
});
} catch (e) {
alert.error(e instanceof Error ? e.message : "Chyba připojení");
} finally {
setCreating(false);
}
};
const {
items: orders,
pagination,
@@ -120,6 +198,27 @@ export default function Orders() {
)}
</p>
</div>
{hasPermission("orders.create") && (
<div className="admin-page-actions">
<button
onClick={openCreate}
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>
Vytvořit objednávku
</button>
</div>
)}
</motion.div>
<motion.div
@@ -377,6 +476,149 @@ export default function Orders() {
type="danger"
loading={deleteMutation.isPending}
/>
<FormModal
isOpen={showCreate}
onClose={() => setShowCreate(false)}
onSubmit={handleCreate}
title="Vytvořit objednávku bez nabídky"
submitLabel="Vytvořit"
loading={creating}
>
<div className="admin-form">
<div className="admin-form-row">
<FormField label="Zákazník">
<select
value={createForm.customer_id}
onChange={(e) =>
setCreateForm({ ...createForm, customer_id: e.target.value })
}
className="admin-form-input"
>
<option value=""> bez zákazníka </option>
{(customersQuery.data ?? []).map((c) => (
<option key={c.id} value={c.id}>
{c.name}
</option>
))}
</select>
</FormField>
<FormField label="Číslo objednávky zákazníka">
<input
type="text"
value={createForm.customer_order_number}
onChange={(e) =>
setCreateForm({
...createForm,
customer_order_number: e.target.value,
})
}
className="admin-form-input"
/>
</FormField>
</div>
<div className="admin-form-row">
<FormField label="Měna">
<select
value={createForm.currency}
onChange={(e) =>
setCreateForm({ ...createForm, currency: e.target.value })
}
className="admin-form-input"
>
<option value="CZK">CZK</option>
<option value="EUR">EUR</option>
<option value="USD">USD</option>
<option value="GBP">GBP</option>
</select>
</FormField>
<FormField label="Sazba DPH (%)">
<input
type="number"
inputMode="numeric"
value={createForm.vat_rate}
onChange={(e) =>
setCreateForm({ ...createForm, vat_rate: e.target.value })
}
className="admin-form-input"
min="0"
/>
</FormField>
</div>
<FormField label="Předmět (scope)">
<input
type="text"
value={createForm.scope_title}
onChange={(e) =>
setCreateForm({ ...createForm, scope_title: e.target.value })
}
className="admin-form-input"
/>
</FormField>
<FormField label="Popis">
<textarea
value={createForm.scope_description}
onChange={(e) =>
setCreateForm({
...createForm,
scope_description: e.target.value,
})
}
className="admin-form-input"
rows={3}
/>
</FormField>
<FormField label="Poznámka">
<textarea
value={createForm.notes}
onChange={(e) =>
setCreateForm({ ...createForm, notes: e.target.value })
}
className="admin-form-input"
rows={2}
/>
</FormField>
<label className="admin-form-checkbox">
<input
type="checkbox"
checked={createForm.apply_vat}
onChange={(e) =>
setCreateForm({ ...createForm, apply_vat: e.target.checked })
}
/>
<span>Účtovat DPH</span>
</label>
<label className="admin-form-checkbox">
<input
type="checkbox"
checked={createForm.create_project}
onChange={(e) =>
setCreateForm({
...createForm,
create_project: e.target.checked,
})
}
/>
<span>Vytvořit propojený projekt</span>
</label>
<p className="admin-form-hint">
Číslo objednávky:{" "}
<strong>
{nextNumberQuery.data?.number ??
nextNumberQuery.data?.next_number ??
"…"}
</strong>{" "}
(přiděleno automaticky)
</p>
</div>
</FormModal>
</div>
);
}