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 { useState } from "react";
|
||||||
|
import { useQuery } 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 } from "react-router-dom";
|
||||||
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";
|
||||||
|
import FormModal from "../components/FormModal";
|
||||||
|
import FormField from "../components/FormField";
|
||||||
|
|
||||||
import { formatCurrency, formatDate, czechPlural } from "../utils/formatters";
|
import { formatCurrency, formatDate, czechPlural } from "../utils/formatters";
|
||||||
import SortIcon from "../components/SortIcon";
|
import SortIcon from "../components/SortIcon";
|
||||||
import useTableSort from "../hooks/useTableSort";
|
import useTableSort from "../hooks/useTableSort";
|
||||||
import { usePaginatedQuery } from "../hooks/usePaginatedQuery";
|
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 { useApiMutation } from "../lib/queries/mutations";
|
||||||
import Pagination from "../components/Pagination";
|
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 {
|
const {
|
||||||
items: orders,
|
items: orders,
|
||||||
pagination,
|
pagination,
|
||||||
@@ -120,6 +198,27 @@ export default function Orders() {
|
|||||||
)}
|
)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</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>
|
||||||
|
|
||||||
<motion.div
|
<motion.div
|
||||||
@@ -377,6 +476,149 @@ export default function Orders() {
|
|||||||
type="danger"
|
type="danger"
|
||||||
loading={deleteMutation.isPending}
|
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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user