467f7e63fd
- Add PageHeader + Operations eyebrow on list page - Detail page: PageHeader with order-id/date eyebrow, customer title, total/status subtitle, AdminBadge tone for pickup + payment processor - AdminOrdersPanel: KPIStat for stat cards, EmptyState with Create your first order CTA, AdminBadge tone for status pills, lucide-react icons, all hardcoded Tailwind colors replaced with var(--admin-*) tokens - OrderTableBody: AdminBadge tone for status + pickup pills, all hardcoded colors replaced with tokens - OrderEditForm: ha-field-label + ha-field-input / ha-field-textarea classes for form fields, ha-segment control for status buttons, semantic pickup toggle with primary/warning tokens, all hardcoded colors replaced Behavior preserved end-to-end. Type-check clean.
510 lines
16 KiB
TypeScript
510 lines
16 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;
|
|
};
|
|
|
|
function formatCurrency(amount: number) {
|
|
return new Intl.NumberFormat("en-US", { style: "currency", currency: "USD" }).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);
|
|
|
|
const [customer_name, setCustomer_name] = useState(order.customer_name);
|
|
const [customer_email, setCustomer_email] = useState(order.customer_email ?? "");
|
|
const [customer_phone, setCustomer_phone] = useState(order.customer_phone ?? "");
|
|
const [discount_amount, setDiscount_amount] = useState(order.discount_amount ?? 0);
|
|
const [discount_reason, setDiscount_reason] = useState(order.discount_reason ?? "");
|
|
const [internal_notes, setInternal_notes] = useState(order.internal_notes ?? "");
|
|
const [status, setStatus] = useState(order.status);
|
|
const [pickup_complete, setPickup_complete] = useState(order.pickup_complete);
|
|
|
|
const [items, setItems] = useState<EditableItem[]>(
|
|
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 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 {
|
|
for (const item of toRemove) {
|
|
const result = await deleteOrderItem(item.id);
|
|
if (!result.success) {
|
|
showError("Failed to remove item", result.error ?? "Please try again");
|
|
setSaving(false);
|
|
return;
|
|
}
|
|
}
|
|
|
|
for (const item of toSave) {
|
|
const orig = order.order_items.find((o) => o.id === item.id);
|
|
if (!orig) continue;
|
|
if (
|
|
Number(orig.quantity) !== item.quantity ||
|
|
Number(orig.price) !== item.price
|
|
) {
|
|
const result = await updateOrderItem(item.id, {
|
|
quantity: item.quantity,
|
|
price: Number(item.price),
|
|
});
|
|
if (!result.success) {
|
|
showError("Failed to update item", result.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
|
|
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 className="ha-field-label mb-1">Qty</label>
|
|
<input
|
|
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
|
|
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
|
|
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
|
|
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 className="ha-field-label mb-1.5">Discount Reason</label>
|
|
<input
|
|
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
|
|
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>
|
|
);
|
|
}
|