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