Files
route-commerce/src/components/admin/OrderEditForm.tsx
T

619 lines
20 KiB
TypeScript

"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { updateOrder, updateOrderItem, deleteOrderItem } from "@/actions/orders/update-order";
import { AdminInput, AdminTextInput, AdminTextarea, useToast, AdminButton } from "./design-system";
type OrderItem = {
id: string;
product_id: string;
quantity: number;
price: number;
products: { name: string } | null;
};
type Order = {
id: string;
customer_name: string;
customer_email: string | null;
customer_phone: string | null;
stop_id: string | null;
status: string;
subtotal: number;
discount_amount: number;
tax_amount: number | null;
tax_rate: number | null;
tax_source?: string | null;
tax_location: string | null;
internal_notes: string | null;
discount_reason: string | null;
pickup_complete: boolean;
pickup_completed_at: string | null;
pickup_completed_by: string | null;
created_at: string;
stops: { city: string; state: string; date: string } | null;
order_items: OrderItem[];
};
type OrderEditFormProps = {
order: Order;
brandId: string | null;
};
type EditableItem = {
id: string;
product_id: string;
quantity: number;
price: number;
productName: string;
removed: boolean;
};
const currencyFormatter = new Intl.NumberFormat("en-US", { style: "currency", currency: "USD" });
function formatCurrency(amount: number) {
return currencyFormatter.format(amount);
}
export default function OrderEditForm({ order, brandId }: OrderEditFormProps) {
const router = useRouter();
const { success: showSuccess, error: showError } = useToast();
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const [saved, setSaved] = useState(false);
// Holds user edits only; missing keys fall back to the current prop so we
// never copy the prop into useState. When the prop changes, the fallback
// value tracks the new prop automatically.
const [draft, setDraft] = useState<{
customer_name?: string;
customer_email?: string;
customer_phone?: string;
discount_amount?: number;
discount_reason?: string;
internal_notes?: string;
status?: string;
pickup_complete?: boolean;
items?: EditableItem[];
}>({});
const customer_name = draft.customer_name ?? order.customer_name;
const customer_email = draft.customer_email ?? order.customer_email ?? "";
const customer_phone = draft.customer_phone ?? order.customer_phone ?? "";
const discount_amount = draft.discount_amount ?? order.discount_amount ?? 0;
const discount_reason = draft.discount_reason ?? order.discount_reason ?? "";
const internal_notes = draft.internal_notes ?? order.internal_notes ?? "";
const status = draft.status ?? order.status;
const pickup_complete = draft.pickup_complete ?? order.pickup_complete;
const setCustomer_name = (v: string | ((prev: string) => string)) =>
setDraft((d) => ({
...d,
customer_name: typeof v === "function" ? v(d.customer_name ?? order.customer_name) : v,
}));
const setCustomer_email = (v: string | ((prev: string) => string)) =>
setDraft((d) => ({
...d,
customer_email:
typeof v === "function" ? v(d.customer_email ?? order.customer_email ?? "") : v,
}));
const setCustomer_phone = (v: string | ((prev: string) => string)) =>
setDraft((d) => ({
...d,
customer_phone:
typeof v === "function" ? v(d.customer_phone ?? order.customer_phone ?? "") : v,
}));
const setDiscount_amount = (v: number | ((prev: number) => number)) =>
setDraft((d) => ({
...d,
discount_amount:
typeof v === "function"
? v(d.discount_amount ?? order.discount_amount ?? 0)
: v,
}));
const setDiscount_reason = (v: string | ((prev: string) => string)) =>
setDraft((d) => ({
...d,
discount_reason:
typeof v === "function" ? v(d.discount_reason ?? order.discount_reason ?? "") : v,
}));
const setInternal_notes = (v: string | ((prev: string) => string)) =>
setDraft((d) => ({
...d,
internal_notes:
typeof v === "function" ? v(d.internal_notes ?? order.internal_notes ?? "") : v,
}));
const setStatus = (v: string | ((prev: string) => string)) =>
setDraft((d) => ({
...d,
status: typeof v === "function" ? v(d.status ?? order.status) : v,
}));
const setPickup_complete = (v: boolean | ((prev: boolean) => boolean)) =>
setDraft((d) => ({
...d,
pickup_complete:
typeof v === "function" ? v(d.pickup_complete ?? order.pickup_complete) : v,
}));
const items =
draft.items ??
order.order_items.map((item) => ({
id: item.id,
product_id: item.product_id,
quantity: item.quantity,
price: Number(item.price),
productName: item.products?.name ?? "Unknown",
removed: false,
}));
const setItems = (updater: React.SetStateAction<EditableItem[]>) =>
setDraft((d) => {
const currentItems =
d.items ??
order.order_items.map((item) => ({
id: item.id,
product_id: item.product_id,
quantity: item.quantity,
price: Number(item.price),
productName: item.products?.name ?? "Unknown",
removed: false,
}));
const next =
typeof updater === "function"
? (updater as (prev: EditableItem[]) => EditableItem[])(currentItems)
: updater;
return { ...d, items: next };
});
const visibleItems = items.filter((i) => !i.removed);
const subtotal = visibleItems.reduce(
(sum, i) => sum + Number(i.price) * i.quantity,
0
);
const total = Math.max(0, subtotal - discount_amount);
// Validation
const [fieldErrors, setFieldErrors] = useState<Record<string, string>>({});
function validateForm(): boolean {
const errors: Record<string, string> = {};
if (!customer_name.trim()) {
errors.customer_name = "Customer name is required";
}
if (discount_amount < 0) {
errors.discount_amount = "Discount cannot be negative";
}
setFieldErrors(errors);
return Object.keys(errors).length === 0;
}
function updateItem(id: string, field: "quantity" | "price", value: number) {
setItems((prev) =>
prev.map((i) => (i.id === id ? { ...i, [field]: value } : i))
);
// Clear error for the field when changed
setFieldErrors((prev) => {
const next = { ...prev };
delete next[field];
return next;
});
}
function removeItem(id: string) {
setItems((prev) =>
prev.map((i) => (i.id === id ? { ...i, removed: true } : i))
);
}
async function handleSave() {
if (!validateForm()) {
showError("Validation failed", "Please fix the errors below");
return;
}
setSaving(true);
setError(null);
setSaved(false);
const toSave = items.filter((i) => !i.removed);
const toRemove = items.filter((i) => i.removed);
try {
const removeResults = await Promise.all(
toRemove.map(async (item) => {
try {
const result = await deleteOrderItem(item.id);
if (result.success) {
return { id: item.id, success: true, error: null as string | null };
}
return { id: item.id, success: false, error: result.error };
} catch {
return { id: item.id, success: false, error: "Network error" };
}
})
);
const failedRemoval = removeResults.find((r) => !r.success);
if (failedRemoval) {
showError("Failed to remove item", failedRemoval.error ?? "Please try again");
setSaving(false);
return;
}
const updateResults = await Promise.all(
toSave.map(async (item) => {
const orig = order.order_items.find((o) => o.id === item.id);
if (!orig) return { id: item.id, success: true, error: null as string | null };
if (
Number(orig.quantity) === item.quantity &&
Number(orig.price) === item.price
) {
return { id: item.id, success: true, error: null as string | null };
}
try {
const result = await updateOrderItem(item.id, {
quantity: item.quantity,
price: Number(item.price),
});
if (result.success) {
return { id: item.id, success: true, error: null as string | null };
}
return { id: item.id, success: false, error: result.error };
} catch {
return { id: item.id, success: false, error: "Network error" };
}
})
);
const failedUpdate = updateResults.find((r) => !r.success);
if (failedUpdate) {
showError("Failed to update item", failedUpdate.error ?? "Please try again");
setSaving(false);
return;
}
const result = await updateOrder(order.id, brandId, {
customer_name,
customer_email: customer_email || null,
customer_phone: customer_phone || null,
discount_amount,
discount_reason: discount_reason || null,
internal_notes: internal_notes || null,
status,
pickup_complete,
pickup_completed_at: pickup_complete ? new Date().toISOString() : null,
subtotal,
});
if (!result.success) {
showError("Failed to save", result.error ?? "Please try again");
setSaving(false);
return;
}
showSuccess("Order updated", "Changes have been saved");
setSaved(true);
setSaving(false);
router.refresh();
} catch (err) {
showError("Network error", "Please check your connection and try again");
setSaving(false);
}
}
return (
<div className="space-y-6">
{error && (
<div
className="rounded-xl border p-4 text-sm flex items-start gap-3"
style={{
borderColor: "var(--admin-danger-soft)",
backgroundColor: "var(--admin-danger-soft)",
color: "var(--admin-danger)",
}}
>
<svg
className="h-5 w-5 shrink-0"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
style={{ color: "var(--admin-danger)" }}
>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
{error}
</div>
)}
{/* Order items */}
<div>
<p className="ha-field-label mb-3">
Order Items ({visibleItems.length})
</p>
{visibleItems.length === 0 ? (
<p
className="rounded-xl border border-dashed p-4 text-center text-sm"
style={{
borderColor: "var(--admin-border-strong)",
color: "var(--admin-text-muted)",
}}
>
No items
</p>
) : (
<div className="space-y-3">
{visibleItems.map((item) => (
<div
key={item.id}
className="rounded-xl border p-4"
style={{
borderColor: "var(--admin-border)",
backgroundColor: "var(--admin-card-bg)",
}}
>
<div className="flex items-start justify-between gap-3">
<p
className="font-medium"
style={{ color: "var(--admin-text-primary)" }}
>
{item.productName}
</p>
<button type="button"
onClick={() => removeItem(item.id)}
className="shrink-0 rounded-lg px-2 py-1 text-xs font-medium"
style={{ color: "var(--admin-danger)" }}
>
Remove
</button>
</div>
<div className="mt-3 grid grid-cols-1 sm:grid-cols-2 gap-3">
<div>
<label htmlFor="fld-1-qty" className="ha-field-label mb-1">Qty</label>
<input id="fld-1-qty" aria-label="Number"
type="number"
min="1"
value={item.quantity}
onChange={(e) =>
updateItem(item.id, "quantity", Number(e.target.value))
}
className="ha-field-input ha-field-input-mono"
/>
</div>
<div>
<label className="ha-field-label mb-1">Price</label>
<div className="relative">
<span
className="absolute left-3 top-1/2 -translate-y-1/2 text-sm"
style={{ color: "var(--admin-text-muted)" }}
>
$
</span>
<input aria-label="Number"
type="number"
step="0.01"
min="0"
value={item.price}
onChange={(e) =>
updateItem(item.id, "price", Number(e.target.value))
}
className="ha-field-input ha-field-input-mono pl-8 pr-3"
/>
</div>
</div>
</div>
<p
className="mt-2 text-right text-sm font-semibold"
style={{ color: "var(--admin-text-primary)" }}
>
{formatCurrency(Number(item.price) * item.quantity)}
</p>
</div>
))}
</div>
)}
</div>
{/* Pricing */}
<div className="space-y-4">
<AdminInput label="Subtotal (auto-calculated)">
<input aria-label="Number"
type="number"
step="0.01"
value={subtotal}
readOnly
className="ha-field-input ha-field-input-mono"
style={{ color: "var(--admin-text-muted)" }}
/>
</AdminInput>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label className="ha-field-label mb-1.5">Discount Amount</label>
<div className="relative">
<span
className="absolute left-3 top-1/2 -translate-y-1/2 text-sm"
style={{ color: "var(--admin-text-muted)" }}
>
$
</span>
<input aria-label="Number"
type="number"
step="0.01"
min="0"
value={discount_amount}
onChange={(e) => setDiscount_amount(Number(e.target.value))}
className="ha-field-input ha-field-input-mono pl-8 pr-3"
style={
fieldErrors.discount_amount
? { borderColor: "var(--admin-danger)", backgroundColor: "var(--admin-danger-soft)" }
: undefined
}
/>
</div>
{fieldErrors.discount_amount && (
<p className="mt-1 text-xs" style={{ color: "var(--admin-danger)" }}>
{fieldErrors.discount_amount}
</p>
)}
</div>
<div>
<label htmlFor="fld-2-discount-reason" className="ha-field-label mb-1.5">Discount Reason</label>
<input id="fld-2-discount-reason" aria-label="Optional"
type="text"
value={discount_reason}
onChange={(e) => setDiscount_reason(e.target.value)}
placeholder="Optional"
className="ha-field-input"
/>
</div>
</div>
<div
className="rounded-xl border p-4"
style={{
borderColor: "var(--admin-border)",
backgroundColor: "var(--admin-bg-subtle)",
}}
>
<div className="flex justify-between">
<span
className="text-sm font-medium"
style={{ color: "var(--admin-text-secondary)" }}
>
Total
</span>
<span
className="text-xl font-bold"
style={{ color: "var(--admin-text-primary)" }}
>
{formatCurrency(subtotal + Number(order.tax_amount ?? 0) - discount_amount)}
</span>
</div>
</div>
</div>
{/* Customer fields */}
<div className="space-y-4">
<div>
<label className="ha-field-label mb-1.5">
<span>Name</span>
<span className="ha-field-label-required">*</span>
</label>
<input aria-label="Text"
type="text"
value={customer_name}
onChange={(e) => {
setCustomer_name(e.target.value);
setFieldErrors((prev) => {
const next = { ...prev };
delete next.customer_name;
return next;
});
}}
className="ha-field-input"
style={
fieldErrors.customer_name
? { borderColor: "var(--admin-danger)", backgroundColor: "var(--admin-danger-soft)" }
: undefined
}
/>
{fieldErrors.customer_name && (
<p className="mt-1 text-xs" style={{ color: "var(--admin-danger)" }}>
{fieldErrors.customer_name}
</p>
)}
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<AdminInput label="Email">
<AdminTextInput
type="email"
value={customer_email}
onChange={(e) => setCustomer_email(e.target.value)}
/>
</AdminInput>
<AdminInput label="Phone">
<AdminTextInput
type="tel"
value={customer_phone}
onChange={(e) => setCustomer_phone(e.target.value)}
/>
</AdminInput>
</div>
</div>
{/* Status & pickup */}
<div className="space-y-4">
<div>
<label className="ha-field-label mb-2">Status</label>
<div className="ha-segment">
{["pending", "confirmed", "cancelled"].map((s) => (
<button
key={s}
type="button"
onClick={() => setStatus(s)}
className={`ha-segment-btn capitalize ${
status === s ? "ha-segment-btn--active" : ""
}`}
>
{s}
</button>
))}
</div>
</div>
<div>
<label className="ha-field-label mb-2">Pickup</label>
<button
type="button"
onClick={() => setPickup_complete((v) => !v)}
className="ha-segment-btn w-full justify-center"
style={
pickup_complete
? {
backgroundColor: "var(--admin-primary-soft)",
color: "var(--admin-primary)",
border: "1px solid var(--admin-primary)",
}
: {
backgroundColor: "var(--admin-warning-soft)",
color: "var(--admin-warning)",
border: "1px solid var(--admin-warning)",
}
}
>
{pickup_complete ? "✓ Picked Up" : "○ Not Picked Up"}
</button>
</div>
</div>
{/* Internal notes */}
<AdminInput label="Internal Notes">
<AdminTextarea
value={internal_notes}
onChange={(e) => setInternal_notes(e.target.value)}
rows={2}
placeholder="Private notes for staff..."
/>
</AdminInput>
<AdminButton
onClick={handleSave}
disabled={saving}
isLoading={saving}
variant="primary"
fullWidth
size="lg"
className="ha-btn-primary"
>
{saving ? "Saving..." : "Save Changes"}
</AdminButton>
</div>
);
}