This commit is contained in:
2026-06-02 10:23:09 +03:00
parent e08d555b23
commit 0c87eaef46
136 changed files with 16069 additions and 94 deletions

View File

@@ -0,0 +1,36 @@
'use client';
export function CustomerChips({
customers,
onRemove,
disabled = false,
}: {
customers: { id: string; name: string }[];
onRemove: (id: string) => void;
disabled?: boolean;
}) {
if (customers.length === 0) return null;
return (
<div className="flex flex-wrap gap-2">
{customers.map((c) => (
<span
key={c.id}
className="inline-flex items-center gap-1.5 px-3 py-1 rounded-full border-2 border-border bg-surface text-sm font-medium"
>
{c.name}
{!disabled && (
<button
type="button"
onClick={() => onRemove(c.id)}
className="text-muted hover:text-danger ml-0.5 cursor-pointer"
aria-label={`Remove ${c.name}`}
>
&times;
</button>
)}
</span>
))}
</div>
);
}

428
components/ItemTable.tsx Normal file
View File

@@ -0,0 +1,428 @@
"use client";
import { useState } from "react";
import type { OrderItemData } from "@/lib/types";
// 6 color presets for customer grouping
const CUSTOMER_COLORS = [
{ border: "border-l-blue-500", bg: "bg-blue-50", text: "text-blue-800" },
{ border: "border-l-emerald-500", bg: "bg-emerald-50", text: "text-emerald-800" },
{ border: "border-l-violet-500", bg: "bg-violet-50", text: "text-violet-800" },
{ border: "border-l-orange-500", bg: "bg-orange-50", text: "text-orange-800" },
{ border: "border-l-teal-500", bg: "bg-teal-50", text: "text-teal-800" },
{ border: "border-l-pink-500", bg: "bg-pink-50", text: "text-pink-800" },
];
function getColor(index: number) {
return CUSTOMER_COLORS[index % CUSTOMER_COLORS.length];
}
// Build a color map from customer list
function buildColorMap(customers: { id: string; name: string }[]) {
const map: Record<string, number> = {};
customers.forEach((c, i) => {
map[c.id] = i;
});
return map;
}
// Group items by customerId
function groupItems(items: OrderItemData[]) {
const groups: Record<string, OrderItemData[]> = {};
const ungrouped: OrderItemData[] = [];
for (const item of items) {
if (item.customerId) {
if (!groups[item.customerId]) groups[item.customerId] = [];
groups[item.customerId].push(item);
} else {
ungrouped.push(item);
}
}
return { groups, ungrouped };
}
// Inline edit form for an item
function ItemEditForm({
item,
customers,
onSave,
onCancel,
}: {
item: OrderItemData;
customers: { id: string; name: string }[];
onSave: (item: OrderItemData) => void;
onCancel: () => void;
}) {
const [form, setForm] = useState({ ...item });
function update(field: string, value: string | number) {
const next = { ...form, [field]: value };
// Recompute prices
next.netPrice = (next.initPrice + next.initPrice * next.taxRatio) * next.quantity;
next.myNetPrice = (next.myPrice + next.myPrice * next.taxRatio) * next.quantity;
next.finalPrice = next.netPrice;
setForm(next);
}
const inputClass = "border-2 border-border rounded-lg px-2 py-1.5 text-sm bg-bg text-fg focus:outline-none focus:border-accent w-full";
const labelClass = "text-xs text-muted font-medium mb-0.5 block";
return (
<div className="bg-surface border-2 border-border rounded-xl p-4 mt-2">
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
<div>
<label className={labelClass}>Customer</label>
<select
value={form.customerId ?? ""}
onChange={(e) => update("customerId", e.target.value)}
className={inputClass}
>
<option value="">None</option>
{customers.map((c) => (
<option key={c.id} value={c.id}>{c.name}</option>
))}
</select>
</div>
<div>
<label className={labelClass}>Item Name</label>
<input type="text" value={form.itemName} onChange={(e) => update("itemName", e.target.value)} className={inputClass} />
</div>
<div>
<label className={labelClass}>Init Price</label>
<input type="number" step="0.01" min="0" value={form.initPrice} onChange={(e) => update("initPrice", parseFloat(e.target.value) || 0)} className={inputClass} />
</div>
<div>
<label className={labelClass}>My Price</label>
<input type="number" step="0.01" min="0" value={form.myPrice} onChange={(e) => update("myPrice", parseFloat(e.target.value) || 0)} className={inputClass} />
</div>
<div>
<label className={labelClass}>Tax Ratio</label>
<input type="number" step="0.01" min="0" value={form.taxRatio} onChange={(e) => update("taxRatio", parseFloat(e.target.value) || 0)} className={inputClass} />
</div>
<div>
<label className={labelClass}>Quantity</label>
<input type="number" min="1" value={form.quantity} onChange={(e) => update("quantity", parseInt(e.target.value) || 1)} className={inputClass} />
</div>
</div>
<div className="flex gap-2 mt-3">
<button type="button" onClick={() => onSave(form)} className="bg-accent text-accent-on px-4 py-1.5 rounded-lg text-sm font-bold hover:bg-accent-hover cursor-pointer">
Save
</button>
<button type="button" onClick={onCancel} className="bg-surface border-2 border-border px-4 py-1.5 rounded-lg text-sm font-medium text-muted hover:text-fg cursor-pointer">
Cancel
</button>
</div>
</div>
);
}
// Add item form
function AddItemForm({
customers,
onAdd,
disabled,
}: {
customers: { id: string; name: string }[];
onAdd: (item: OrderItemData) => void;
disabled: boolean;
}) {
const [form, setForm] = useState({
customerId: customers[0]?.id ?? "",
itemName: "",
initPrice: 0,
myPrice: 0,
taxRatio: 0.16,
quantity: 1,
});
function update(field: string, value: string | number) {
setForm((prev) => ({ ...prev, [field]: value }));
}
function handleAdd() {
if (!form.itemName.trim()) return;
const netPrice = (form.initPrice + form.initPrice * form.taxRatio) * form.quantity;
const myNetPrice = (form.myPrice + form.myPrice * form.taxRatio) * form.quantity;
onAdd({
id: crypto.randomUUID(),
orderId: "",
customerId: form.customerId || null,
customerName: customers.find((c) => c.id === form.customerId)?.name ?? null,
itemName: form.itemName,
initPrice: form.initPrice,
myPrice: form.myPrice,
taxRatio: form.taxRatio,
quantity: form.quantity,
netPrice,
myNetPrice,
finalPrice: netPrice,
});
setForm({ customerId: customers[0]?.id ?? "", itemName: "", initPrice: 0, myPrice: 0, taxRatio: 0.16, quantity: 1 });
}
const inputClass = "border-2 border-border rounded-lg px-2 py-1.5 text-sm bg-bg text-fg focus:outline-none focus:border-accent w-full";
const labelClass = "text-xs text-muted font-medium mb-0.5 block";
return (
<div className="bg-surface border-2 border-border rounded-xl p-4">
<h4 className="text-sm font-bold text-fg mb-3">Add New Item</h4>
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-6 gap-3">
<div>
<label className={labelClass}>Customer</label>
<select value={form.customerId} onChange={(e) => update("customerId", e.target.value)} className={inputClass} disabled={disabled}>
<option value="">None</option>
{customers.map((c) => (
<option key={c.id} value={c.id}>{c.name}</option>
))}
</select>
</div>
<div>
<label className={labelClass}>Item Name</label>
<input type="text" value={form.itemName} onChange={(e) => update("itemName", e.target.value)} placeholder="Product name" className={inputClass} disabled={disabled} />
</div>
<div>
<label className={labelClass}>Init Price</label>
<input type="number" step="0.01" min="0" value={form.initPrice} onChange={(e) => update("initPrice", parseFloat(e.target.value) || 0)} className={inputClass} disabled={disabled} />
</div>
<div>
<label className={labelClass}>My Price</label>
<input type="number" step="0.01" min="0" value={form.myPrice} onChange={(e) => update("myPrice", parseFloat(e.target.value) || 0)} className={inputClass} disabled={disabled} />
</div>
<div>
<label className={labelClass}>Tax / Qty</label>
<div className="flex gap-1">
<input type="number" step="0.01" min="0" value={form.taxRatio} onChange={(e) => update("taxRatio", parseFloat(e.target.value) || 0)} className={inputClass} disabled={disabled} />
<input type="number" min="1" value={form.quantity} onChange={(e) => update("quantity", parseInt(e.target.value) || 1)} className={inputClass} disabled={disabled} />
</div>
</div>
<div className="flex items-end">
<button type="button" onClick={handleAdd} disabled={disabled || !form.itemName.trim()} className="bg-accent text-accent-on px-4 py-1.5 rounded-lg text-sm font-bold hover:bg-accent-hover disabled:opacity-50 cursor-pointer w-full">
+ Add
</button>
</div>
</div>
</div>
);
}
// Main ItemTable component
export function ItemTable({
items,
customers,
onItemsChange,
disabled = false,
}: {
items: OrderItemData[];
customers: { id: string; name: string }[];
onItemsChange: (items: OrderItemData[]) => void;
disabled?: boolean;
}) {
const [editingId, setEditingId] = useState<string | null>(null);
const colorMap = buildColorMap(customers);
const { groups, ungrouped } = groupItems(items);
function handleAdd(newItem: OrderItemData) {
onItemsChange([...items, newItem]);
}
function handleRemove(id: string) {
onItemsChange(items.filter((i) => i.id !== id));
if (editingId === id) setEditingId(null);
}
function handleEditSave(updated: OrderItemData) {
onItemsChange(items.map((i) => (i.id === updated.id ? updated : i)));
setEditingId(null);
}
// Get customer name by id
function getCustomerName(id: string) {
return customers.find((c) => c.id === id)?.name ?? "Unknown";
}
// Render a single item row (desktop table row)
function TableRow({ item, colorIndex }: { item: OrderItemData; colorIndex: number | null }) {
const color = colorIndex !== null ? getColor(colorIndex) : null;
const isEditing = editingId === item.id;
return (
<>
<tr className={`border-b border-border/50 hover:bg-surface/30 transition-colors ${color ? `border-l-4 ${color.border} ${color.bg}` : ""}`}>
<td className="px-3 py-2 text-sm font-medium text-fg">{item.itemName || "—"}</td>
<td className="px-3 py-2 text-sm font-mono text-right">${item.initPrice.toFixed(2)}</td>
<td className="px-3 py-2 text-sm font-mono text-right">${item.myPrice.toFixed(2)}</td>
<td className="px-3 py-2 text-sm font-mono text-center">{item.quantity}</td>
<td className="px-3 py-2 text-sm font-mono text-right font-medium">${item.netPrice.toFixed(2)}</td>
<td className="px-3 py-2 text-sm font-mono text-right font-bold">${item.finalPrice.toFixed(2)}</td>
<td className="px-3 py-2 text-right">
{!disabled && (
<div className="flex gap-1 justify-end">
<button type="button" onClick={() => setEditingId(isEditing ? null : item.id)} className="text-xs text-accent hover:text-accent-hover font-medium px-2 py-1 rounded cursor-pointer">
{isEditing ? "Cancel" : "Edit"}
</button>
<button type="button" onClick={() => handleRemove(item.id)} className="text-xs text-danger hover:text-red-700 font-medium px-2 py-1 rounded cursor-pointer">
Remove
</button>
</div>
)}
</td>
</tr>
{isEditing && (
<tr>
<td colSpan={7} className="px-3 py-0">
<ItemEditForm item={item} customers={customers} onSave={handleEditSave} onCancel={() => setEditingId(null)} />
</td>
</tr>
)}
</>
);
}
// Render a single item card (mobile)
function MobileCard({ item, colorIndex }: { item: OrderItemData; colorIndex: number | null }) {
const color = colorIndex !== null ? getColor(colorIndex) : null;
const isEditing = editingId === item.id;
return (
<div className={`border-2 border-border rounded-xl overflow-hidden ${color ? `border-l-4 ${color.border}` : ""}`}>
<div className={`p-3 ${color?.bg ?? "bg-white"}`}>
<div className="flex items-start justify-between gap-2">
<div className="flex-1 min-w-0">
<p className="text-sm font-bold text-fg truncate">{item.itemName || "—"}</p>
{colorIndex !== null && (
<p className={`text-xs font-medium ${color?.text ?? "text-muted"}`}>
{getCustomerName(item.customerId!)}
</p>
)}
</div>
<p className="text-sm font-bold font-mono text-fg whitespace-nowrap">${item.finalPrice.toFixed(2)}</p>
</div>
<div className="grid grid-cols-3 gap-2 mt-2 text-xs">
<div>
<span className="text-muted">Init: </span>
<span className="font-mono">${item.initPrice.toFixed(2)}</span>
</div>
<div>
<span className="text-muted">My: </span>
<span className="font-mono">${item.myPrice.toFixed(2)}</span>
</div>
<div>
<span className="text-muted">Qty: </span>
<span className="font-mono">{item.quantity}</span>
</div>
</div>
{!disabled && (
<div className="flex gap-2 mt-2">
<button type="button" onClick={() => setEditingId(isEditing ? null : item.id)} className="text-xs text-accent hover:text-accent-hover font-medium cursor-pointer">
{isEditing ? "Cancel" : "Edit"}
</button>
<button type="button" onClick={() => handleRemove(item.id)} className="text-xs text-danger hover:text-red-700 font-medium cursor-pointer">
Remove
</button>
</div>
)}
</div>
{isEditing && (
<div className="p-3 border-t border-border">
<ItemEditForm item={item} customers={customers} onSave={handleEditSave} onCancel={() => setEditingId(null)} />
</div>
)}
</div>
);
}
// Render items for a customer group
function CustomerGroup({ customerId, groupItems: gi }: { customerId: string; groupItems: OrderItemData[] }) {
const colorIndex = colorMap[customerId] ?? 0;
const color = getColor(colorIndex);
const name = getCustomerName(customerId);
return (
<div className="mb-4">
<div className={`flex items-center gap-2 px-3 py-1.5 rounded-t-lg ${color.bg} border-l-4 ${color.border}`}>
<span className={`text-sm font-bold ${color.text}`}>{name}</span>
<span className="text-xs text-muted">({gi.length} item{gi.length !== 1 ? "s" : ""})</span>
</div>
{/* Desktop table */}
<div className="hidden sm:block border-x border-b border-border rounded-b-lg overflow-hidden">
<table className="w-full">
<thead>
<tr className="bg-surface/50">
<th className="text-left px-3 py-1.5 text-xs font-medium text-muted">Item</th>
<th className="text-right px-3 py-1.5 text-xs font-medium text-muted">Init Price</th>
<th className="text-right px-3 py-1.5 text-xs font-medium text-muted">My Price</th>
<th className="text-center px-3 py-1.5 text-xs font-medium text-muted">Qty</th>
<th className="text-right px-3 py-1.5 text-xs font-medium text-muted">Net</th>
<th className="text-right px-3 py-1.5 text-xs font-medium text-muted">Final</th>
<th className="text-right px-3 py-1.5 text-xs font-medium text-muted">Actions</th>
</tr>
</thead>
<tbody>
{gi.map((item) => <TableRow key={item.id} item={item} colorIndex={colorIndex} />)}
</tbody>
</table>
</div>
{/* Mobile cards */}
<div className="sm:hidden flex flex-col gap-2 mt-1">
{gi.map((item) => <MobileCard key={item.id} item={item} colorIndex={colorIndex} />)}
</div>
</div>
);
}
return (
<div className="flex flex-col gap-4">
{/* Add item form */}
{!disabled && (
<AddItemForm customers={customers} onAdd={handleAdd} disabled={disabled} />
)}
{/* Items grouped by customer */}
{items.length === 0 ? (
<div className="text-center py-8 text-muted text-sm border-2 border-dashed border-border rounded-xl">
No items yet. Add one above.
</div>
) : (
<>
{/* Customer groups */}
{Object.entries(groups).map(([customerId, gi]) => (
<CustomerGroup key={customerId} customerId={customerId} groupItems={gi} />
))}
{/* Ungrouped items */}
{ungrouped.length > 0 && (
<div className="mb-4">
<div className="flex items-center gap-2 px-3 py-1.5 rounded-t-lg bg-gray-50 border-l-4 border-l-gray-400">
<span className="text-sm font-bold text-gray-700">Unassigned</span>
<span className="text-xs text-muted">({ungrouped.length} item{ungrouped.length !== 1 ? "s" : ""})</span>
</div>
<div className="hidden sm:block border-x border-b border-border rounded-b-lg overflow-hidden">
<table className="w-full">
<thead>
<tr className="bg-surface/50">
<th className="text-left px-3 py-1.5 text-xs font-medium text-muted">Item</th>
<th className="text-right px-3 py-1.5 text-xs font-medium text-muted">Init Price</th>
<th className="text-right px-3 py-1.5 text-xs font-medium text-muted">My Price</th>
<th className="text-center px-3 py-1.5 text-xs font-medium text-muted">Qty</th>
<th className="text-right px-3 py-1.5 text-xs font-medium text-muted">Net</th>
<th className="text-right px-3 py-1.5 text-xs font-medium text-muted">Final</th>
<th className="text-right px-3 py-1.5 text-xs font-medium text-muted">Actions</th>
</tr>
</thead>
<tbody>
{ungrouped.map((item) => <TableRow key={item.id} item={item} colorIndex={null} />)}
</tbody>
</table>
</div>
<div className="sm:hidden flex flex-col gap-2 mt-1">
{ungrouped.map((item) => <MobileCard key={item.id} item={item} colorIndex={null} />)}
</div>
</div>
)}
</>
)}
</div>
);
}

11
components/LockBanner.tsx Normal file
View File

@@ -0,0 +1,11 @@
export function LockBanner() {
return (
<div className="w-full bg-gray-100 border-2 border-border rounded-xl p-4 flex items-center gap-3">
<span className="text-xl">&#x1f512;</span>
<div>
<p className="font-medium text-fg">This order is closed and cannot be edited.</p>
<p className="text-sm text-muted">All fields are read-only. Change the status to reopen editing.</p>
</div>
</div>
);
}

22
components/Navbar.tsx Normal file
View File

@@ -0,0 +1,22 @@
import Link from "next/link";
import { getCurrentUser } from "@/lib/auth";
import { NavbarMenu } from "./NavbarMenu";
export async function Navbar() {
const user = await getCurrentUser();
return (
<nav className="sticky top-0 z-50 backdrop-blur-md bg-white/80 border-b-2 border-border">
<div className="max-w-[1080px] mx-auto px-4 sm:px-6 h-14 sm:h-16 flex items-center justify-between">
<Link href="/" className="flex items-center gap-2 sm:gap-3">
<div className="w-8 h-8 sm:w-9 sm:h-9 rounded-xl bg-accent flex items-center justify-center shadow-card">
<span className="text-accent-on font-bold text-xs sm:text-sm">OL</span>
</div>
<span className="font-display text-base sm:text-lg font-bold text-fg">Order Loop</span>
</Link>
{user && <NavbarMenu email={user.email} />}
</div>
</nav>
);
}

66
components/NavbarMenu.tsx Normal file
View File

@@ -0,0 +1,66 @@
"use client";
import { useState } from "react";
import Link from "next/link";
import { logout } from "@/app/actions/auth";
export function NavbarMenu({ email }: { email: string }) {
const [open, setOpen] = useState(false);
return (
<>
{/* Desktop menu */}
<div className="hidden sm:flex items-center gap-4">
<span className="text-sm text-muted font-mono">{email}</span>
<Link href="/change-password" className="text-sm text-accent hover:text-accent-hover font-medium">
Change Password
</Link>
<form action={logout}>
<button type="submit" className="text-sm text-danger hover:text-red-700 font-medium cursor-pointer">
Logout
</button>
</form>
</div>
{/* Mobile hamburger */}
<div className="sm:hidden">
<button
type="button"
onClick={() => setOpen(!open)}
className="p-2 rounded-lg hover:bg-surface transition-colors cursor-pointer"
aria-label="Menu"
>
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
{open ? (
<>
<line x1="4" y1="4" x2="16" y2="16" />
<line x1="16" y1="4" x2="4" y2="16" />
</>
) : (
<>
<line x1="3" y1="5" x2="17" y2="5" />
<line x1="3" y1="10" x2="17" y2="10" />
<line x1="3" y1="15" x2="17" y2="15" />
</>
)}
</svg>
</button>
{/* Mobile dropdown */}
{open && (
<div className="absolute right-4 top-14 bg-white border-2 border-border rounded-xl shadow-lg p-4 flex flex-col gap-3 min-w-[180px]">
<span className="text-sm text-muted font-mono truncate">{email}</span>
<Link href="/change-password" onClick={() => setOpen(false)} className="text-sm text-accent hover:text-accent-hover font-medium">
Change Password
</Link>
<form action={logout}>
<button type="submit" className="text-sm text-danger hover:text-red-700 font-medium cursor-pointer">
Logout
</button>
</form>
</div>
)}
</div>
</>
);
}

257
components/OrderForm.tsx Normal file
View 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>
);
}

131
components/OrderTable.tsx Normal file
View File

@@ -0,0 +1,131 @@
'use client';
import { useState } from "react";
import Link from "next/link";
import type { OrderWithDetails, OrderStatus } from "@/lib/types";
import { StatusBadge } from "./StatusBadge";
export function OrderTable({ orders }: { orders: OrderWithDetails[] }) {
const [search, setSearch] = useState("");
const [statusFilter, setStatusFilter] = useState<OrderStatus | "">("");
const filtered = orders.filter((order) => {
const matchesSearch =
!search ||
order.title.toLowerCase().includes(search.toLowerCase()) ||
order.customers.some((oc) =>
oc.customer.name.toLowerCase().includes(search.toLowerCase())
);
const matchesStatus = !statusFilter || order.status === statusFilter;
return matchesSearch && matchesStatus;
});
const getTotal = (order: OrderWithDetails) =>
order.items.reduce((sum, item) => sum + item.finalPrice, 0);
return (
<div>
{/* Filters */}
<div className="flex flex-col sm:flex-row gap-3 mb-4">
<input
type="text"
placeholder="Search orders..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="flex-1 border-2 border-border rounded-xl px-4 py-2.5 text-sm bg-white focus:border-accent focus:ring-2 focus:ring-accent/20 outline-none"
/>
<select
value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value as OrderStatus | "")}
className="border-2 border-border rounded-xl px-4 py-2.5 text-sm bg-white focus:border-accent outline-none"
>
<option value="">All Statuses</option>
<option value="pending">Pending</option>
<option value="purchased">Purchased</option>
<option value="delivered">Delivered</option>
<option value="closed">Closed</option>
</select>
</div>
{/* Mobile cards */}
<div className="sm:hidden flex flex-col gap-3">
{filtered.length === 0 ? (
<div className="px-4 py-8 text-center text-muted border-2 border-border rounded-2xl">
No orders found
</div>
) : (
filtered.map((order) => (
<Link
key={order.id}
href={`/orders/${order.id}`}
className="block border-2 border-border rounded-2xl p-4 hover:bg-surface/50 transition-colors"
>
<div className="flex items-start justify-between gap-2 mb-2">
<h3 className="font-bold text-fg text-sm truncate flex-1">{order.title}</h3>
<StatusBadge status={order.status} />
</div>
<div className="flex items-center justify-between text-xs text-muted">
<span>{order.items.length} item{order.items.length !== 1 ? "s" : ""}</span>
<span className="font-mono font-bold text-fg">${getTotal(order).toFixed(2)}</span>
</div>
{order.customers.length > 0 && (
<p className="text-xs text-muted mt-1 truncate">
{order.customers.map((oc) => oc.customer.name).join(", ")}
</p>
)}
</Link>
))
)}
</div>
{/* Desktop table */}
<div className="hidden sm:block border-2 border-border rounded-2xl overflow-hidden">
<table className="w-full">
<thead>
<tr className="bg-surface border-b-2 border-border">
<th className="text-left px-4 py-3 text-sm font-medium text-muted">Title</th>
<th className="text-left px-4 py-3 text-sm font-medium text-muted">Status</th>
<th className="text-left px-4 py-3 text-sm font-medium text-muted hidden md:table-cell">Customers</th>
<th className="text-right px-4 py-3 text-sm font-medium text-muted">Items</th>
<th className="text-right px-4 py-3 text-sm font-medium text-muted">Total</th>
<th className="text-right px-4 py-3 text-sm font-medium text-muted hidden lg:table-cell">Updated</th>
</tr>
</thead>
<tbody>
{filtered.length === 0 ? (
<tr>
<td colSpan={6} className="px-4 py-8 text-center text-muted">
No orders found
</td>
</tr>
) : (
filtered.map((order) => (
<tr
key={order.id}
className="border-b border-border last:border-b-0 hover:bg-surface/50 transition-colors"
>
<td className="px-4 py-3">
<Link href={`/orders/${order.id}`} className="font-medium text-fg hover:text-accent transition-colors">
{order.title}
</Link>
</td>
<td className="px-4 py-3">
<StatusBadge status={order.status} />
</td>
<td className="px-4 py-3 text-sm text-muted hidden md:table-cell">
{order.customers.map((oc) => oc.customer.name).join(", ") || "\u2014"}
</td>
<td className="px-4 py-3 text-right font-mono text-sm">{order.items.length}</td>
<td className="px-4 py-3 text-right font-mono text-sm font-medium">${getTotal(order).toFixed(2)}</td>
<td className="px-4 py-3 text-right text-sm text-muted hidden lg:table-cell font-mono">
{new Date(order.updatedAt).toLocaleDateString()}
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</div>
);
}

16
components/StatCard.tsx Normal file
View File

@@ -0,0 +1,16 @@
export function StatCard({
label,
value,
color = "text-fg",
}: {
label: string;
value: number | string;
color?: string;
}) {
return (
<div className="bg-surface border-2 border-border rounded-2xl p-5 shadow-card">
<div className={`font-mono text-3xl font-bold ${color}`}>{value}</div>
<div className="text-muted text-sm mt-1">{label}</div>
</div>
);
}

View File

@@ -0,0 +1,18 @@
import type { OrderStatus } from "@/lib/types";
const statusStyles: Record<OrderStatus, string> = {
pending: "bg-yellow-100 text-yellow-800 border-yellow-300",
purchased: "bg-blue-100 text-blue-800 border-blue-300",
delivered: "bg-green-100 text-green-800 border-green-300",
closed: "bg-gray-100 text-gray-600 border-gray-300",
};
export function StatusBadge({ status }: { status: OrderStatus }) {
return (
<span
className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium border capitalize ${statusStyles[status]}`}
>
{status}
</span>
);
}

View File

@@ -0,0 +1,46 @@
'use client';
import type { OrderItemData, OrderStatus } from "@/lib/types";
import { StatusBadge } from "./StatusBadge";
export function SummaryPanel({
items,
status,
customerCount,
}: {
items: OrderItemData[];
status: OrderStatus;
customerCount: number;
}) {
const totalOrderValue = items.reduce((sum, item) => sum + item.finalPrice, 0);
const myNetTotal = items.reduce((sum, item) => sum + item.myNetPrice, 0);
return (
<div className="bg-surface border-2 border-border rounded-2xl p-5 shadow-card space-y-4">
<h3 className="font-display text-lg font-bold text-fg">Order Summary</h3>
<div className="space-y-3">
<div className="flex justify-between items-center">
<span className="text-sm text-muted">Status</span>
<StatusBadge status={status} />
</div>
<div className="flex justify-between items-center">
<span className="text-sm text-muted">Total Order Value</span>
<span className="font-mono font-bold text-lg text-fg">${totalOrderValue.toFixed(2)}</span>
</div>
<div className="flex justify-between items-center">
<span className="text-sm text-muted">My Net Total</span>
<span className="font-mono font-bold text-lg text-accent">${myNetTotal.toFixed(2)}</span>
</div>
<div className="flex justify-between items-center">
<span className="text-sm text-muted">Items</span>
<span className="font-mono font-medium">{items.length}</span>
</div>
<div className="flex justify-between items-center">
<span className="text-sm text-muted">Customers</span>
<span className="font-mono font-medium">{customerCount}</span>
</div>
</div>
</div>
);
}