258 lines
8.1 KiB
TypeScript
258 lines
8.1 KiB
TypeScript
"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>
|
|
);
|
|
}
|