Files
verify/frontend/app/dashboard/products/page.tsx
Mohamed Mathar Irfan ed6610d5d8 Initial project upload
2026-07-28 17:57:02 +05:30

545 lines
17 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"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&apos;ve reached your plan&apos;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>
);
}