init app
This commit is contained in:
257
components/OrderForm.tsx
Normal file
257
components/OrderForm.tsx
Normal file
@@ -0,0 +1,257 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useTransition, type FormEvent } from "react";
|
||||
import { CustomerChips } from "./CustomerChips";
|
||||
import { ItemTable } from "./ItemTable";
|
||||
import { SummaryPanel } from "./SummaryPanel";
|
||||
import type {
|
||||
OrderItemData,
|
||||
OrderWithDetails,
|
||||
OrderStatus,
|
||||
FormState,
|
||||
} from "@/lib/types";
|
||||
|
||||
interface OrderFormProps {
|
||||
mode: "create" | "edit";
|
||||
initialData?: OrderWithDetails;
|
||||
serverAction: (
|
||||
prevState: FormState | undefined,
|
||||
formData: FormData,
|
||||
) => Promise<FormState>;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export function OrderForm({
|
||||
mode,
|
||||
initialData,
|
||||
serverAction,
|
||||
disabled = false,
|
||||
}: OrderFormProps) {
|
||||
const isClosed = initialData?.status === "closed";
|
||||
const isDisabled = disabled || isClosed;
|
||||
|
||||
const [title, setTitle] = useState(initialData?.title ?? "");
|
||||
const [status, setStatus] = useState<OrderStatus>(
|
||||
initialData?.status ?? "pending",
|
||||
);
|
||||
const [notes, setNotes] = useState(initialData?.notes ?? "");
|
||||
const [customers, setCustomers] = useState<{ id: string; name: string }[]>(
|
||||
initialData?.customers.map((c) => ({
|
||||
id: c.customer.id,
|
||||
name: c.customer.name,
|
||||
})) ?? [],
|
||||
);
|
||||
const [items, setItems] = useState<OrderItemData[]>(
|
||||
initialData?.items ?? [],
|
||||
);
|
||||
const [customerInput, setCustomerInput] = useState("");
|
||||
const [actionMessage, setActionMessage] = useState<string | null>(null);
|
||||
const [actionSuccess, setActionSuccess] = useState(false);
|
||||
const [isPending, startTransition] = useTransition();
|
||||
|
||||
function handleAddCustomer() {
|
||||
const name = customerInput.trim();
|
||||
if (!name) return;
|
||||
if (customers.some((c) => c.name.toLowerCase() === name.toLowerCase()))
|
||||
return;
|
||||
setCustomers((prev) => [...prev, { id: crypto.randomUUID(), name }]);
|
||||
setCustomerInput("");
|
||||
}
|
||||
|
||||
function handleRemoveCustomer(id: string) {
|
||||
setCustomers((prev) => prev.filter((c) => c.id !== id));
|
||||
setItems((prev) =>
|
||||
prev.map((item) =>
|
||||
item.customerId === id
|
||||
? { ...item, customerId: null, customerName: null }
|
||||
: item,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
function handleSubmit(e: FormEvent<HTMLFormElement>) {
|
||||
e.preventDefault();
|
||||
const formData = new FormData(e.currentTarget);
|
||||
formData.set("title", title);
|
||||
formData.set("status", status);
|
||||
formData.set("notes", notes);
|
||||
formData.set(
|
||||
"customers",
|
||||
JSON.stringify(customers.map((c) => ({ id: c.id, name: c.name }))),
|
||||
);
|
||||
formData.set(
|
||||
"items",
|
||||
JSON.stringify(
|
||||
items.map((i) => ({
|
||||
id: i.id,
|
||||
customerId: i.customerId,
|
||||
itemName: i.itemName,
|
||||
initPrice: i.initPrice,
|
||||
myPrice: i.myPrice,
|
||||
taxRatio: i.taxRatio,
|
||||
quantity: i.quantity,
|
||||
})),
|
||||
),
|
||||
);
|
||||
|
||||
startTransition(async () => {
|
||||
const result = await serverAction(undefined, formData);
|
||||
if (result?.message) {
|
||||
setActionMessage(result.message);
|
||||
setActionSuccess(result.success ?? false);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const inputClass =
|
||||
"border-2 border-border rounded-xl px-3 py-2 text-sm bg-bg text-fg focus:outline-none focus:border-accent disabled:opacity-50 disabled:cursor-not-allowed";
|
||||
const labelClass = "text-sm font-medium text-fg mb-1";
|
||||
const submitClass =
|
||||
"bg-accent text-accent-on font-bold px-6 py-3 rounded-xl border-b-4 border-accent-active hover:bg-accent-hover active:translate-y-0.5 active:border-b-0 transition-all disabled:opacity-50 disabled:cursor-not-allowed";
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="w-full max-w-[1080px] mx-auto">
|
||||
{/* Closed order lock banner */}
|
||||
{isClosed && (
|
||||
<div className="mb-6 p-4 rounded-xl border-2 border-border bg-surface text-fg font-medium">
|
||||
This order is closed and read-only. Editing is disabled.
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
{/* Main content — left 2/3 on desktop */}
|
||||
<div className="lg:col-span-2 flex flex-col gap-6">
|
||||
{/* Title + Status row */}
|
||||
<div className="flex flex-col sm:flex-row gap-4">
|
||||
<div className="flex-1 flex flex-col">
|
||||
<label htmlFor="order-title" className={labelClass}>
|
||||
Order Title
|
||||
</label>
|
||||
<input
|
||||
id="order-title"
|
||||
type="text"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
placeholder="Order title"
|
||||
className={inputClass}
|
||||
disabled={isDisabled}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col sm:w-48">
|
||||
<label htmlFor="order-status" className={labelClass}>
|
||||
Status
|
||||
</label>
|
||||
<select
|
||||
id="order-status"
|
||||
value={status}
|
||||
onChange={(e) => setStatus(e.target.value as OrderStatus)}
|
||||
className={inputClass}
|
||||
disabled={isDisabled}
|
||||
>
|
||||
<option value="pending">Pending</option>
|
||||
<option value="purchased">Purchased</option>
|
||||
<option value="delivered">Delivered</option>
|
||||
<option value="closed">Closed</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Customer section */}
|
||||
<div className="flex flex-col gap-3">
|
||||
<label className={labelClass}>Customers</label>
|
||||
<div className="flex flex-col sm:flex-row gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={customerInput}
|
||||
onChange={(e) => setCustomerInput(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
handleAddCustomer();
|
||||
}
|
||||
}}
|
||||
placeholder="Customer name"
|
||||
className={`flex-1 ${inputClass}`}
|
||||
disabled={isDisabled}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleAddCustomer}
|
||||
disabled={isDisabled}
|
||||
className={submitClass}
|
||||
>
|
||||
Add Customer
|
||||
</button>
|
||||
</div>
|
||||
<CustomerChips
|
||||
customers={customers}
|
||||
onRemove={handleRemoveCustomer}
|
||||
disabled={isDisabled}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Items section */}
|
||||
<div className="flex flex-col gap-3">
|
||||
<label className={labelClass}>Items ({items.length})</label>
|
||||
<ItemTable
|
||||
items={items}
|
||||
customers={customers}
|
||||
onItemsChange={setItems}
|
||||
disabled={isDisabled}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Notes */}
|
||||
<div className="flex flex-col">
|
||||
<label htmlFor="order-notes" className={labelClass}>
|
||||
Notes
|
||||
</label>
|
||||
<textarea
|
||||
id="order-notes"
|
||||
value={notes}
|
||||
onChange={(e) => setNotes(e.target.value)}
|
||||
placeholder="Order notes"
|
||||
rows={4}
|
||||
className={`${inputClass} resize-y`}
|
||||
disabled={isDisabled}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Submit button */}
|
||||
<div>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isDisabled || isPending}
|
||||
className={submitClass}
|
||||
>
|
||||
{isPending
|
||||
? "Saving..."
|
||||
: mode === "create"
|
||||
? "Create Order"
|
||||
: "Save Order"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Error/success message */}
|
||||
{actionMessage && (
|
||||
<p
|
||||
aria-live="polite"
|
||||
className={`text-sm font-medium ${actionSuccess ? "text-success" : "text-danger"}`}
|
||||
>
|
||||
{actionMessage}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Sidebar — right 1/3 on desktop, below on mobile */}
|
||||
<div className="lg:col-span-1">
|
||||
<SummaryPanel
|
||||
items={items}
|
||||
status={status}
|
||||
customerCount={customers.length}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user