Files
Order-Loop/components/ItemTable.tsx
2026-06-04 01:05:21 +03:00

694 lines
22 KiB
TypeScript

"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.myNetPrice.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);
const totalQty = gi.reduce((s, i) => s + i.quantity, 0);
const totalNet = gi.reduce((s, i) => s + i.myNetPrice, 0);
const totalFinal = gi.reduce((s, i) => s + i.finalPrice, 0);
return (
<div className="mb-4">
<div
className={`flex flex-wrap items-center gap-x-3 gap-y-1 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">
{totalQty} unit{totalQty !== 1 ? "s" : ""} ({gi.length} line{gi.length !== 1 ? "s" : ""})
</span>
<span className="text-xs font-mono text-muted">
Net: <span className="font-medium text-fg">${totalNet.toFixed(2)}</span>
</span>
<span className="text-xs font-mono text-muted">
Final: <span className="font-bold text-fg">${totalFinal.toFixed(2)}</span>
</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 && (() => {
const uQty = ungrouped.reduce((s, i) => s + i.quantity, 0);
const uNet = ungrouped.reduce((s, i) => s + i.netPrice, 0);
const uFinal = ungrouped.reduce((s, i) => s + i.finalPrice, 0);
return (
<div className="mb-4">
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 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">
{uQty} unit{uQty !== 1 ? "s" : ""} ({ungrouped.length} line{ungrouped.length !== 1 ? "s" : ""})
</span>
<span className="text-xs font-mono text-muted">
Net: <span className="font-medium text-fg">${uNet.toFixed(2)}</span>
</span>
<span className="text-xs font-mono text-muted">
Final: <span className="font-bold text-fg">${uFinal.toFixed(2)}</span>
</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>
);
}