f7ac9399b2
Add aria-label to buttons (icon-only, pagination, action) and inputs (placeholder-derived) via balanced-brace JSX parser. Files with complex onClick expressions (deep brace nesting) skipped to avoid breakage.
249 lines
10 KiB
TypeScript
249 lines
10 KiB
TypeScript
"use client";
|
|
|
|
import { useState } from "react";
|
|
import Link from "next/link";
|
|
import { deleteProduct } from "@/actions/products";
|
|
import AdminBadge from "./design-system/AdminBadge";
|
|
|
|
type Product = {
|
|
id: string;
|
|
name: string;
|
|
description: string;
|
|
price: number;
|
|
type: string;
|
|
active: boolean;
|
|
deleted_at?: string | null;
|
|
brands: { name: string } | { name: string }[];
|
|
is_taxable?: boolean;
|
|
};
|
|
|
|
type ProductTableBodyProps = {
|
|
products: Product[];
|
|
search: string;
|
|
statusFilter: "all" | "active" | "inactive";
|
|
onSearchChange: (v: string) => void;
|
|
onStatusChange: (v: "all" | "active" | "inactive") => void;
|
|
onDeleted: (productId: string) => void;
|
|
};
|
|
|
|
export default function ProductTableBody({
|
|
products,
|
|
search,
|
|
statusFilter,
|
|
onSearchChange,
|
|
onStatusChange,
|
|
onDeleted,
|
|
}: ProductTableBodyProps) {
|
|
const [openMenu, setOpenMenu] = useState<string | null>(null);
|
|
const [deletingId, setDeletingId] = useState<string | null>(null);
|
|
const [confirmDelete, setConfirmDelete] = useState<string | null>(null);
|
|
const [deleteError, setDeleteError] = useState<string | null>(null);
|
|
|
|
const filtered = products.filter((p) => {
|
|
const matchesSearch =
|
|
!search ||
|
|
p.name.toLowerCase().includes(search.toLowerCase()) ||
|
|
p.description.toLowerCase().includes(search.toLowerCase());
|
|
const matchesStatus =
|
|
statusFilter === "all" ||
|
|
(statusFilter === "active" && p.active) ||
|
|
(statusFilter === "inactive" && !p.active);
|
|
return matchesSearch && matchesStatus;
|
|
});
|
|
|
|
async function handleDelete(productId: string) {
|
|
setDeletingId(productId);
|
|
setDeleteError(null);
|
|
const result = await deleteProduct(productId, null);
|
|
setDeletingId(null);
|
|
setConfirmDelete(null);
|
|
setOpenMenu(null);
|
|
if (result.success) {
|
|
onDeleted(productId);
|
|
} else {
|
|
setDeleteError(result.error ?? "Delete failed");
|
|
}
|
|
}
|
|
|
|
return (
|
|
<>
|
|
{/* Filter bar — sits outside <table> in the parent */}
|
|
<div className="border-b border-[var(--admin-border-light)] px-5 py-3 flex gap-3 flex-wrap items-center">
|
|
<input aria-label="Search Products..."
|
|
type="search"
|
|
placeholder="Search products..."
|
|
value={search}
|
|
onChange={(e) => onSearchChange(e.target.value)}
|
|
className="flex-1 min-w-40 rounded-lg border border-[var(--admin-border)] bg-[var(--admin-card-bg)] px-3 py-2 text-sm text-[var(--admin-text-primary)] outline-none focus:border-[var(--admin-primary)]"
|
|
/>
|
|
<div className="flex gap-1 rounded-lg border border-[var(--admin-border)] bg-[var(--admin-card-bg)] p-1">
|
|
{(["all", "active", "inactive"] as const).map((f) => (
|
|
<button type="button"
|
|
key={f}
|
|
onClick={() => onStatusChange(f)}
|
|
className={`rounded-md px-3 py-1.5 text-xs font-medium transition-colors ${
|
|
statusFilter === f
|
|
? "bg-[var(--admin-primary)] text-white"
|
|
: "text-[var(--admin-text-muted)] hover:bg-[var(--admin-bg)]"
|
|
}`}
|
|
>
|
|
{f === "all" ? "All" : f === "active" ? "Active" : "Inactive"}
|
|
</button>
|
|
))}
|
|
</div>
|
|
<span className="text-xs text-[var(--admin-text-muted)]">{filtered.length}</span>
|
|
</div>
|
|
|
|
{/* Delete error banner */}
|
|
{deleteError && (
|
|
<div className="mx-5 mt-3 rounded-lg bg-[var(--admin-danger-soft)] border border-[var(--admin-danger)] px-4 py-3 text-sm text-[var(--admin-danger)]">
|
|
{deleteError}
|
|
<button type="button"
|
|
onClick={() => setDeleteError(null)}
|
|
className="ml-2 underline hover:no-underline"
|
|
aria-label="Dismiss">
|
|
Dismiss
|
|
</button>
|
|
</div>
|
|
)}
|
|
|
|
<tbody className="divide-y divide-[var(--admin-border-light)]">
|
|
{filtered.length === 0 ? (
|
|
<tr>
|
|
<td colSpan={7} className="px-5 py-10 text-center text-sm text-[var(--admin-text-muted)]">
|
|
{search || statusFilter !== "all"
|
|
? "No products match your search."
|
|
: "No products found."}
|
|
</td>
|
|
</tr>
|
|
) : (
|
|
filtered.map((product) => (
|
|
<tr
|
|
key={product.id}
|
|
className="hover:bg-[var(--admin-primary-soft)] transition-colors relative"
|
|
>
|
|
<td className="px-3 py-2">
|
|
<Link
|
|
href={`/admin/products/${product.id}`}
|
|
className="block font-medium text-[var(--admin-text-primary)] hover:text-[var(--admin-primary)]"
|
|
>
|
|
{product.name}
|
|
</Link>
|
|
<div className="text-[var(--admin-text-muted)] line-clamp-1 text-sm">
|
|
{product.description || <span className="italic text-[var(--admin-text-muted)]">No description</span>}
|
|
</div>
|
|
</td>
|
|
|
|
<td className="px-3 py-2 text-[var(--admin-text-secondary)]">
|
|
{Array.isArray(product.brands)
|
|
? product.brands[0]?.name
|
|
: product.brands?.name}
|
|
</td>
|
|
|
|
<td className="px-3 py-2 text-[var(--admin-text-secondary)]">{product.type}</td>
|
|
|
|
<td
|
|
className="px-3 py-2 font-semibold text-[var(--admin-text-primary)]"
|
|
style={{ fontFamily: "var(--font-fragment-mono)", fontVariantNumeric: "tabular-nums" }}
|
|
>
|
|
${Number(product.price).toFixed(2)}
|
|
</td>
|
|
|
|
<td className="px-3 py-2">
|
|
<AdminBadge tone={product.active ? "success" : "neutral"} dot>
|
|
{product.active ? "Active" : "Inactive"}
|
|
</AdminBadge>
|
|
</td>
|
|
|
|
<td className="px-3 py-2">
|
|
{product.is_taxable === false ? (
|
|
<AdminBadge tone="warning">Non-taxable</AdminBadge>
|
|
) : (
|
|
<AdminBadge tone="success">Taxable</AdminBadge>
|
|
)}
|
|
</td>
|
|
|
|
{/* Actions */}
|
|
<td className="px-3 py-2 text-right">
|
|
<div className="relative inline-flex items-center justify-end gap-2">
|
|
<Link
|
|
href={`/admin/products/${product.id}`}
|
|
className="rounded-lg px-3 py-1.5 text-xs font-medium text-[var(--admin-text-secondary)] hover:bg-[var(--admin-bg)]"
|
|
>
|
|
Edit
|
|
</Link>
|
|
<button type="button"
|
|
onClick={(e) => {
|
|
e.preventDefault();
|
|
setOpenMenu(openMenu === product.id ? null : product.id);
|
|
}}
|
|
className="rounded-lg px-2 py-1.5 text-xs text-[var(--admin-text-muted)] hover:bg-[var(--admin-bg)]"
|
|
>
|
|
<svg className="h-4 w-4" fill="currentColor" viewBox="0 0 20 20">
|
|
<circle cx="10" cy="4" r="1.5"/><circle cx="10" cy="10" r="1.5"/><circle cx="10" cy="16" r="1.5"/>
|
|
</svg>
|
|
</button>
|
|
|
|
{openMenu === product.id && (
|
|
<>
|
|
<button
|
|
type="button"
|
|
aria-label="Close menu"
|
|
className="fixed inset-0 z-10 cursor-default bg-transparent border-0 p-0"
|
|
onClick={() => { setOpenMenu(null); setConfirmDelete(null); }}
|
|
/>
|
|
<div className="absolute right-0 top-full mt-1 z-20 w-44 rounded-xl bg-[var(--admin-card-bg)] shadow-lg ring-1 ring-[var(--admin-border)] overflow-hidden">
|
|
<button type="button"
|
|
onClick={() => { setOpenMenu(null); setConfirmDelete(product.id); }}
|
|
className="w-full text-left px-4 py-2.5 text-sm text-[var(--admin-danger)] hover:bg-[var(--admin-danger-soft)]"
|
|
>
|
|
Delete
|
|
</button>
|
|
</div>
|
|
</>
|
|
)}
|
|
|
|
{confirmDelete === product.id && (
|
|
<>
|
|
<button
|
|
type="button"
|
|
aria-label="Cancel delete confirmation"
|
|
className="fixed inset-0 z-30 backdrop-blur-sm border-0 p-0 cursor-default"
|
|
style={{ backgroundColor: "color-mix(in srgb, var(--admin-text-primary) 60%, transparent)" }}
|
|
onClick={() => { setConfirmDelete(null); setOpenMenu(null); }}
|
|
/>
|
|
<div className="fixed left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 z-40 w-80 rounded-xl bg-[var(--admin-card-bg)] shadow-xl ring-1 ring-[var(--admin-border)] p-6">
|
|
<p className="text-sm font-semibold text-[var(--admin-text-primary)]">
|
|
Delete "{product.name}"?
|
|
</p>
|
|
<p className="mt-2 text-xs text-[var(--admin-text-muted)]">
|
|
This will remove the product. If it is attached to any
|
|
orders, it will be hidden instead of deleted.
|
|
</p>
|
|
<div className="mt-5 flex gap-3">
|
|
<button type="button"
|
|
onClick={() => { setConfirmDelete(null); setOpenMenu(null); }}
|
|
className="flex-1 rounded-lg border border-[var(--admin-border)] px-3 py-2 text-sm font-medium text-[var(--admin-text-secondary)] hover:bg-[var(--admin-bg)]"
|
|
>
|
|
Cancel
|
|
</button>
|
|
<button type="button"
|
|
onClick={() => handleDelete(product.id)}
|
|
disabled={deletingId === product.id}
|
|
className="flex-1 rounded-lg bg-[var(--admin-danger)] px-3 py-2 text-sm font-medium text-white hover:bg-[var(--admin-danger-hover)] disabled:opacity-50"
|
|
>
|
|
{deletingId === product.id ? "..." : "Delete"}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</>
|
|
)}
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
))
|
|
)}
|
|
</tbody>
|
|
</>
|
|
);
|
|
} |