Initial project upload

This commit is contained in:
Mohamed Mathar Irfan
2026-07-28 17:57:02 +05:30
commit ed6610d5d8
23919 changed files with 3003316 additions and 0 deletions

View File

@@ -0,0 +1,544 @@
"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>
);
}