545 lines
17 KiB
TypeScript
545 lines
17 KiB
TypeScript
"use client";
|
||
import { useEffect, useRef, useState } from "react";
|
||
import { useRouter } from "next/navigation";
|
||
import {
|
||
Plus, Package, AlertTriangle, MoreVertical, Eye, Pencil, Copy, QrCode, Archive, RotateCcw,
|
||
} from "lucide-react";
|
||
import { api, apiBase } from "@/lib/api";
|
||
import ProductDialog from "@/components/ProductDialog";
|
||
import { Download } from "lucide-react";
|
||
|
||
type Mode = "add" | "edit" | "duplicate" | "view";
|
||
|
||
// data-URLs and absolute URLs pass through; relative /uploads paths get the API host
|
||
function imgSrc(url: string) {
|
||
if (!url) return "";
|
||
if (url.startsWith("data:") || url.startsWith("http")) return url;
|
||
return `${apiBase}${url}`;
|
||
}
|
||
|
||
export default function Products() {
|
||
const router = useRouter();
|
||
const [data, setData] = useState<any>(null);
|
||
const [showArchived, setShowArchived] = useState(false);
|
||
const [dialog, setDialog] = useState<{ mode: Mode; id?: string } | null>(null);
|
||
const [menuFor, setMenuFor] = useState<string | null>(null);
|
||
const [search, setSearch] = useState("");
|
||
const [brandId, setBrandId] = useState("");
|
||
const [category, setCategory] = useState("");
|
||
const [status, setStatus] = useState("");
|
||
const [qrStatus, setQrStatus] = useState("");
|
||
const [trustFrom, setTrustFrom] = useState("");
|
||
const [trustTo, setTrustTo] = useState("");
|
||
const [createdFrom, setCreatedFrom] = useState("");
|
||
const [createdTo, setCreatedTo] = useState("");
|
||
const [brands, setBrands] = useState<any[]>([]);
|
||
|
||
|
||
const [page, setPage] = useState(1);
|
||
const pageSize = 20;
|
||
const [selected, setSelected] = useState<string[]>([]);
|
||
const toggleSelectAll = () => {
|
||
if (selected.length === (data?.items?.length ?? 0)) {
|
||
setSelected([]);
|
||
} else {
|
||
setSelected((data?.items ?? []).map((p: any) => p.id));
|
||
}
|
||
};
|
||
const toggleSelect = (id: string) => {
|
||
setSelected((prev) =>
|
||
prev.includes(id)
|
||
? prev.filter((x) => x !== id)
|
||
: [...prev, id]
|
||
);
|
||
};
|
||
|
||
useEffect(() => {
|
||
api("/brands")
|
||
.then((res) => {
|
||
setBrands(res.items ?? res);
|
||
})
|
||
.catch(() => setBrands([]));
|
||
}, []);
|
||
|
||
function load() {
|
||
const params = new URLSearchParams();
|
||
|
||
if (search) params.append("search", search);
|
||
if (brandId) params.append("brand_id", brandId);
|
||
if (category) params.append("category", category);
|
||
|
||
if (showArchived) {
|
||
params.append("status_filter", "archived");
|
||
} else if (status) {
|
||
params.append("status_filter", status);
|
||
}
|
||
|
||
if (qrStatus) params.append("qr_status", qrStatus);
|
||
|
||
if (trustFrom) params.append("trust_from", trustFrom);
|
||
if (trustTo) params.append("trust_to", trustTo);
|
||
|
||
if (createdFrom) params.append("created_from", createdFrom);
|
||
if (createdTo) params.append("created_to", createdTo);
|
||
|
||
params.append("page", page.toString());
|
||
params.append("page_size", pageSize.toString());
|
||
|
||
|
||
api(`/products?${params.toString()}`)
|
||
.then(setData)
|
||
.catch(() => setData({ items: [] }));
|
||
}
|
||
useEffect(() => { load(); }, [showArchived,search,
|
||
brandId,
|
||
category,
|
||
status,
|
||
qrStatus,
|
||
trustFrom,
|
||
trustTo,
|
||
createdFrom,
|
||
createdTo,
|
||
page,]);
|
||
|
||
const limitReached = data?.limit_reached;
|
||
|
||
async function archive(id: string) {
|
||
await api(`/products/${id}/archive`, { method: "PUT" }); setMenuFor(null); load();
|
||
}
|
||
async function restore(id: string) {
|
||
try { await api(`/products/${id}/restore`, { method: "PUT" }); } catch (e: any) { alert(e.detail?.message || "Restore failed"); }
|
||
setMenuFor(null); load();
|
||
}
|
||
async function duplicate(id: string) {
|
||
try { await api(`/products/${id}/duplicate`, { method: "POST" }); load(); }
|
||
catch (e: any) { alert(e.detail?.message || "Duplicate failed"); }
|
||
setMenuFor(null);
|
||
}
|
||
function exportSelected() {
|
||
const rows = (data?.items ?? []).filter((p: any) =>
|
||
selected.includes(p.id)
|
||
);
|
||
|
||
const csv = [
|
||
[
|
||
"Name",
|
||
"SKU",
|
||
"Brand",
|
||
"Category",
|
||
"Trust Score",
|
||
"Status",
|
||
"QR Count",
|
||
"Create At"
|
||
],
|
||
...rows.map((p: any) => [
|
||
p.name,
|
||
p.sku,
|
||
p.brand_name,
|
||
p.category,
|
||
p.trust_score,
|
||
p.status,
|
||
p.qr_count,
|
||
p.created_at,
|
||
]),
|
||
]
|
||
.map((r) => r.join(","))
|
||
.join("\n");
|
||
|
||
const blob = new Blob([csv], {
|
||
type: "text/csv;charset=utf-8;",
|
||
});
|
||
|
||
const url = URL.createObjectURL(blob);
|
||
|
||
const a = document.createElement("a");
|
||
a.href = url;
|
||
a.download = "products.csv";
|
||
a.click();
|
||
|
||
URL.revokeObjectURL(url);
|
||
}
|
||
async function archiveSelected() {
|
||
if (selected.length === 0) return;
|
||
|
||
if (!confirm(`Archive ${selected.length} products?`))
|
||
return;
|
||
|
||
await Promise.all(
|
||
selected.map((id) =>
|
||
api(`/products/${id}/archive`, {
|
||
method: "PUT",
|
||
})
|
||
)
|
||
);
|
||
|
||
setSelected([]);
|
||
load();
|
||
}
|
||
const totalPages = data?.pages ?? 1;
|
||
const totalItems = data?.total ?? 0;
|
||
|
||
const startPage = Math.max(1, page - 2);
|
||
const endPage = Math.min(totalPages, startPage + 4);
|
||
|
||
const pages = [];
|
||
for (let i = startPage; i <= endPage; i++) {
|
||
pages.push(i);
|
||
}
|
||
|
||
return (
|
||
<div className="space-y-5" onClick={() => setMenuFor(null)}>
|
||
<div className="flex items-center justify-between">
|
||
<div>
|
||
<h2 className="text-xl font-bold">Products</h2>
|
||
{data && <p className="text-sm text-slate-500">{data.count} / {data.limit ?? "∞"} products used</p>}
|
||
</div>
|
||
<div className="flex items-center gap-3">
|
||
<label className="flex items-center gap-2 text-sm text-slate-600">
|
||
<input type="checkbox" checked={showArchived} onChange={(e) => setShowArchived(e.target.checked)} /> Archived
|
||
</label>
|
||
<button onClick={() => !limitReached && setDialog({ mode: "add" })}
|
||
className={`flex items-center gap-2 px-4 py-2 rounded-lg font-semibold text-sm text-white ${limitReached ? "bg-slate-300 cursor-not-allowed" : "bg-brand-600"}`}>
|
||
<Plus className="h-4 w-4" /> Add Product
|
||
</button>
|
||
</div>
|
||
|
||
|
||
</div>
|
||
<div className="card p-4 mb-4">
|
||
<div className="grid grid-cols-1 md:grid-cols-3 lg:grid-cols-8 gap-3">
|
||
|
||
{/* Search */}
|
||
<input
|
||
placeholder="Search Product / SKU / Brand / Trust"
|
||
value={search}
|
||
onChange={(e) => setSearch(e.target.value)}
|
||
className="border rounded-lg px-3 py-2"
|
||
/>
|
||
|
||
{/* Brand */}
|
||
<select
|
||
value={brandId}
|
||
onChange={(e) => setBrandId(e.target.value)}
|
||
className="border rounded-lg px-3 py-2"
|
||
>
|
||
<option value="">All Brands</option>
|
||
{brands.map((b: any) => (
|
||
<option key={b.id} value={b.id}>
|
||
{b.name}
|
||
</option>
|
||
))}
|
||
</select>
|
||
|
||
{/* Category */}
|
||
<select
|
||
value={category}
|
||
onChange={(e) => setCategory(e.target.value)}
|
||
className="border rounded-lg px-3 py-2"
|
||
>
|
||
<option value="">All Categories</option>
|
||
|
||
{[...new Set((data?.items ?? []).map((p: any) => p.category).filter(Boolean))]
|
||
.map((c: any) => (
|
||
<option key={c} value={c}>
|
||
{c}
|
||
</option>
|
||
))}
|
||
</select>
|
||
|
||
{/* Status */}
|
||
<select
|
||
value={status}
|
||
onChange={(e) => setStatus(e.target.value)}
|
||
className="border rounded-lg px-3 py-2"
|
||
>
|
||
<option value="">All Status</option>
|
||
<option value="draft">Draft</option>
|
||
<option value="active">Active</option>
|
||
<option value="archived">Archived</option>
|
||
</select>
|
||
|
||
{/* QR Status */}
|
||
<select
|
||
value={qrStatus}
|
||
onChange={(e) => setQrStatus(e.target.value)}
|
||
className="border rounded-lg px-3 py-2"
|
||
>
|
||
<option value="">QR Status</option>
|
||
<option value="generated">Generated</option>
|
||
<option value="not_generated">Not Generated</option>
|
||
</select>
|
||
|
||
{/* Created From */}
|
||
<input
|
||
type="date"
|
||
value={createdFrom}
|
||
onChange={(e) => setCreatedFrom(e.target.value)}
|
||
className="border rounded-lg px-3 py-2"
|
||
/>
|
||
|
||
{/* Created To */}
|
||
<input
|
||
type="date"
|
||
value={createdTo}
|
||
onChange={(e) => setCreatedTo(e.target.value)}
|
||
className="border rounded-lg px-3 py-2"
|
||
/>
|
||
|
||
{/* Search Button */}
|
||
|
||
|
||
{/* Reset */}
|
||
<button
|
||
onClick={() => {
|
||
setSearch("");
|
||
setBrandId("");
|
||
setCategory("");
|
||
setStatus("");
|
||
setQrStatus("");
|
||
setCreatedFrom("");
|
||
setCreatedTo("");
|
||
setPage(1);
|
||
}}
|
||
className="rounded-lg bg-slate-200 px-4 py-2"
|
||
>
|
||
Reset
|
||
</button>
|
||
|
||
</div>
|
||
</div>
|
||
|
||
{limitReached && !showArchived && (
|
||
<div className="card p-4 border-amber-200 bg-amber-50 flex items-center gap-3">
|
||
<AlertTriangle className="h-5 w-5 text-amber-500" />
|
||
<div className="flex-1 text-sm text-amber-800">You've reached your plan's product limit. Upgrade to add more.</div>
|
||
<a href="/dashboard/billing" className="text-sm font-semibold text-amber-700 underline">Upgrade</a>
|
||
</div>
|
||
)}
|
||
|
||
<div className="flex items-center justify-between px-4 py-3 border-b bg-slate-50">
|
||
|
||
{/* Left */}
|
||
<div className="flex items-center gap-3">
|
||
|
||
<span className="text-sm font-semibold">
|
||
Selected:
|
||
<span className="ml-1 text-brand-600">
|
||
{selected.length}
|
||
</span>
|
||
</span>
|
||
|
||
<button
|
||
disabled={selected.length === 0}
|
||
onClick={exportSelected}
|
||
className={`flex items-center gap-2 px-3 py-2 rounded-lg text-sm font-medium
|
||
${
|
||
selected.length === 0
|
||
? "bg-slate-200 text-slate-400 cursor-not-allowed"
|
||
: "bg-blue-600 text-white hover:bg-blue-700"
|
||
}`}
|
||
>
|
||
<Download className="h-4 w-4" />
|
||
Export
|
||
</button>
|
||
|
||
{!showArchived && (
|
||
<button
|
||
disabled={selected.length === 0}
|
||
onClick={archiveSelected}
|
||
className={`flex items-center gap-2 px-3 py-2 rounded-lg text-sm font-medium
|
||
${
|
||
selected.length === 0
|
||
? "bg-slate-200 text-slate-400 cursor-not-allowed"
|
||
: "bg-rose-600 text-white hover:bg-rose-700"
|
||
}`}
|
||
>
|
||
<Archive className="h-4 w-4" />
|
||
Archive
|
||
</button>
|
||
)}
|
||
|
||
</div>
|
||
|
||
{/* Right */}
|
||
<div className="text-sm text-slate-600">
|
||
Total Products
|
||
<span className="ml-2 font-bold">
|
||
{data?.total ?? data?.items?.length ?? 0}
|
||
</span>
|
||
</div>
|
||
|
||
</div>
|
||
<div className="card overflow-visible">
|
||
|
||
<table className="w-full text-sm">
|
||
<thead className="bg-slate-50 text-slate-500 text-left">
|
||
<tr>
|
||
<th className="px-4 py-3 w-10">
|
||
<input
|
||
type="checkbox"
|
||
checked={
|
||
selected.length > 0 &&
|
||
selected.length === (data?.items?.length ?? 0)
|
||
}
|
||
onChange={toggleSelectAll}
|
||
/>
|
||
</th>
|
||
<th className="px-4 py-3 font-medium">Image</th>
|
||
<th className="px-4 py-3 font-medium">Product</th>
|
||
<th className="px-4 py-3 font-medium">SKU</th>
|
||
<th className="px-4 py-3 font-medium">Category</th>
|
||
<th className="px-4 py-3 font-medium">Brand</th>
|
||
<th className="px-4 py-3 font-medium">QR Status</th>
|
||
<th className="px-4 py-3 font-medium">Trust</th>
|
||
<th className="px-4 py-3 font-medium">Status</th>
|
||
<th className="px-4 py-3 font-medium">QRs</th>
|
||
<th className="px-4 py-3 font-medium">Created</th>
|
||
<th className="px-4 py-3">Actions</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody className="divide-y divide-slate-100">
|
||
{(data?.items ?? []).map((p: any) => (
|
||
<tr key={p.id} className="hover:bg-slate-50">
|
||
<td className="px-4 py-3">
|
||
<input
|
||
type="checkbox"
|
||
checked={selected.includes(p.id)}
|
||
onChange={() => toggleSelect(p.id)}
|
||
/>
|
||
</td>
|
||
<td className="px-4 py-3">
|
||
{p.image_url
|
||
? <img src={imgSrc(p.image_url)} alt="" className="h-11 w-11 rounded-lg object-contain border border-slate-100" />
|
||
: <div className="h-11 w-11 rounded-lg bg-slate-100 grid place-items-center"><Package className="h-4 w-4 text-slate-300" /></div>}
|
||
</td>
|
||
<td className="px-4 py-3 font-medium text-slate-800">{p.name}</td>
|
||
<td className="px-4 py-3 text-slate-500">{p.sku ?? "—"}</td>
|
||
<td className="px-4 py-3 text-slate-600">
|
||
{p.category || "—"}
|
||
</td>
|
||
|
||
<td className="px-4 py-3 text-slate-500">{p.brand_name ?? "—"}</td>
|
||
<td className="px-4 py-3">
|
||
<span
|
||
className={`text-xs font-semibold px-2 py-0.5 rounded-full ${
|
||
p.qr_count > 0
|
||
? "bg-blue-50 text-blue-700"
|
||
: "bg-slate-100 text-slate-500"
|
||
}`}
|
||
>
|
||
{p.qr_count > 0 ? "Generated" : "Not Generated"}
|
||
</span>
|
||
</td>
|
||
<td className="px-4 py-3">{p.trust_score}%</td>
|
||
<td className="px-4 py-3">
|
||
<span className={`text-xs font-semibold px-2 py-0.5 rounded-full capitalize ${p.status === "archived" ? "bg-slate-100 text-slate-500" : "bg-emerald-50 text-emerald-700"}`}>{p.status}</span>
|
||
</td>
|
||
<td className="px-4 py-3">{p.qr_count}</td>
|
||
<td className="px-4 py-3 text-slate-500">
|
||
{new Date(p.created_at).toLocaleString()}
|
||
</td>
|
||
|
||
<td className="px-4 py-3 text-right relative">
|
||
<div className="flex items-center justify-end gap-1">
|
||
<button title="View" onClick={() => setDialog({ mode: "view", id: p.id })}
|
||
className="h-8 w-8 grid place-items-center rounded-lg border border-slate-200 text-slate-500 hover:bg-slate-50">
|
||
<Eye className="h-4 w-4" />
|
||
</button>
|
||
<button onClick={(e) => { e.stopPropagation(); setMenuFor(menuFor === p.id ? null : p.id); }}
|
||
className="h-8 w-8 grid place-items-center rounded-lg border border-slate-200 hover:bg-slate-50">
|
||
<MoreVertical className="h-4 w-4 text-slate-500" />
|
||
</button>
|
||
</div>
|
||
{menuFor === p.id && (
|
||
<div onClick={(e) => e.stopPropagation()}
|
||
className="absolute right-4 top-12 z-20 w-44 bg-white rounded-xl border border-slate-200 shadow-lg py-1 text-left">
|
||
{p.status === "archived" ? (
|
||
<MenuItem icon={<RotateCcw className="h-4 w-4" />} label="Restore" onClick={() => restore(p.id)} />
|
||
) : (
|
||
<>
|
||
<MenuItem icon={<Pencil className="h-4 w-4" />} label="Edit" onClick={() => { setDialog({ mode: "edit", id: p.id }); setMenuFor(null); }} />
|
||
<MenuItem icon={<Copy className="h-4 w-4" />} label="Duplicate" onClick={() => duplicate(p.id)} />
|
||
<MenuItem icon={<QrCode className="h-4 w-4" />} label="Generate QR" onClick={() => router.push(`/dashboard/products/${p.id}/batch`)} />
|
||
<div className="border-t border-slate-100 my-1" />
|
||
<MenuItem icon={<Archive className="h-4 w-4" />} label="Archive" danger onClick={() => archive(p.id)} />
|
||
</>
|
||
)}
|
||
</div>
|
||
)}
|
||
</td>
|
||
</tr>
|
||
))}
|
||
{data?.items?.length === 0 && (
|
||
<tr><td colSpan={12} className="px-4 py-12 text-center text-slate-400">
|
||
<Package className="h-8 w-8 mx-auto mb-2 opacity-50" />
|
||
{showArchived ? "No archived products." : "No products yet. Add your first product."}
|
||
</td></tr>
|
||
)}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
<div className="flex items-center justify-between px-4 py-3 border-t text-sm text-slate-500">
|
||
|
||
<span>
|
||
Showing{" "}
|
||
{totalItems === 0
|
||
? 0
|
||
: (page - 1) * pageSize + 1}{" "}
|
||
to{" "}
|
||
{Math.min(page * pageSize, totalItems)}{" "}
|
||
of {totalItems}
|
||
</span>
|
||
|
||
<div className="flex items-center gap-1">
|
||
|
||
<button
|
||
disabled={page === 1}
|
||
onClick={() => setPage(page - 1)}
|
||
className="px-3 py-1 rounded border border-slate-200 disabled:opacity-40"
|
||
>
|
||
‹
|
||
</button>
|
||
|
||
{pages.map((p) => (
|
||
<button
|
||
key={p}
|
||
onClick={() => setPage(p)}
|
||
className={`px-3 py-1 rounded ${
|
||
page === p
|
||
? "bg-brand-600 text-white"
|
||
: "border border-slate-200 hover:bg-slate-50"
|
||
}`}
|
||
>
|
||
{p}
|
||
</button>
|
||
))}
|
||
|
||
<button
|
||
disabled={page === totalPages}
|
||
onClick={() => setPage(page + 1)}
|
||
className="px-3 py-1 rounded border border-slate-200 disabled:opacity-40"
|
||
>
|
||
›
|
||
</button>
|
||
|
||
</div>
|
||
</div>
|
||
|
||
{dialog && (
|
||
<ProductDialog mode={dialog.mode} productId={dialog.id}
|
||
onClose={() => setDialog(null)} onSaved={load} />
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function MenuItem({ icon, label, onClick, danger }: any) {
|
||
return (
|
||
<button onClick={onClick}
|
||
className={`w-full flex items-center gap-2 px-3 py-2 text-sm hover:bg-slate-50 ${danger ? "text-rose-600" : "text-slate-700"}`}>
|
||
{icon} {label}
|
||
</button>
|
||
);
|
||
}
|
||
|