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,202 @@
"use client";
import { useRef, useState } from "react";
import { useParams } from "next/navigation";
import { Boxes, Layers, Check, Download } from "lucide-react";
import { api, apiBase } from "@/lib/api";
import QrDesigner, { defaultStyle, type QrStyle, type QrDesignerHandle } from "@/components/QrDesigner";
const VERIFY_BASE =
(process.env.NEXT_PUBLIC_APP_URL || "http://localhost:3000") + "/v/";
export default function BatchQR() {
const { id } = useParams<{ id: string }>();
const [step, setStep] = useState<"batch" | "customize" | "done">("batch");
const [batch, setBatch] = useState<any>({
batch_number: "", mfg_date: "", expiry_date: "", quantity: 100, qr_type: "batch", notes: "",
});
const [batchId, setBatchId] = useState("");
const [style, setStyle] = useState<QrStyle>(defaultStyle);
const [result, setResult] = useState<any>(null);
const [busy, setBusy] = useState(false);
const [err, setErr] = useState("");
const designerRef = useRef<QrDesignerHandle>(null);
// preview encodes a representative URL; real codes are assigned on generate
const previewData = result?.codes?.[0]?.url ?? VERIFY_BASE + "preview";
async function createBatch(e: React.FormEvent) {
e.preventDefault();
setErr("");
try {
const b = await api("/batches", {
method: "POST",
body: JSON.stringify({ ...batch, product_id: id, quantity: Number(batch.quantity) }),
});
setBatchId(b.id);
setStep("customize");
} catch (e: any) { setErr(e.detail || "Failed"); }
}
// map the rich client style onto the backend QRStyle shape
function styleForApi(): any {
return {
dot_shape: style.dot_shape,
corner_square_shape: style.corner_square_shape,
corner_dot_shape: style.corner_dot_shape,
fg_color: style.fg_color,
bg_color: style.bg_color,
transparent_bg: style.transparent_bg,
gradient: style.gradient,
gradient_color: style.gradient_color,
logo_size: style.logo_size,
error_correction: style.error_correction,
quiet_zone: style.quiet_zone,
};
}
async function generate() {
setBusy(true);
setErr("");
try {
const res = await api("/qr/generate", {
method: "POST",
body: JSON.stringify({ batch_id: batchId, style: styleForApi() }),
});
setResult(res);
setStep("done");
// Render the WYSIWYG image for the first code and upload it.
// (For per-pack batches we upload the representative design; each code
// still resolves to its own verify URL.)
const dataUrl = await designerRef.current?.getDataUrl("png");
if (dataUrl && res.codes?.[0]) {
await api(`/qr/${res.codes[0].id}/image`, {
method: "POST",
body: JSON.stringify({ data_url: dataUrl, format: "png" }),
}).catch(() => {});
}
} catch (e: any) {
setErr(typeof e.detail === "string" ? e.detail : "Generation failed");
} finally { setBusy(false); }
}
async function downloadClient(fmt: "png" | "svg") {
const blob = await designerRef.current?.getRawData(fmt);
if (!blob) return;
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `${result?.codes?.[0]?.code ?? "qr"}.${fmt}`;
a.click();
URL.revokeObjectURL(url);
}
return (
<div className="max-w-5xl space-y-5">
<h2 className="text-xl font-bold">Create Batch & QR</h2>
{err && <div className="card p-3 bg-rose-50 text-rose-700 text-sm">{err}</div>}
{step === "batch" && (
<form onSubmit={createBatch} className="card p-5 space-y-4 max-w-2xl">
<div className="grid grid-cols-2 gap-3">
<Input label="Batch number (blank = auto)" value={batch.batch_number}
onChange={(e: any) => setBatch({ ...batch, batch_number: e.target.value })} />
<Input label="Quantity" type="number" value={batch.quantity}
onChange={(e: any) => setBatch({ ...batch, quantity: e.target.value })} />
<Input label="Mfg date" type="date" value={batch.mfg_date}
onChange={(e: any) => setBatch({ ...batch, mfg_date: e.target.value })} />
<Input label="Expiry date" type="date" value={batch.expiry_date}
onChange={(e: any) => setBatch({ ...batch, expiry_date: e.target.value })} />
</div>
<div>
<span className="text-sm font-medium">QR Type</span>
<div className="grid grid-cols-2 gap-3 mt-2">
<TypeCard active={batch.qr_type === "batch"} onClick={() => setBatch({ ...batch, qr_type: "batch" })}
icon={<Boxes className="h-5 w-5" />} title="Batch QR"
desc="One QR for the entire batch. All packs share the same code." note="1 QR generated" />
<TypeCard active={batch.qr_type === "per_pack"} onClick={() => setBatch({ ...batch, qr_type: "per_pack" })}
icon={<Layers className="h-5 w-5" />} title="Per-Pack QR"
desc="Unique QR per pack. Maximum anti-counterfeit protection." note={`${batch.quantity} QRs → ZIP`} />
</div>
</div>
<button className="bg-brand-600 text-white px-6 py-2.5 rounded-lg font-semibold">Continue to designer</button>
</form>
)}
{step === "customize" && (
<>
<h3 className="font-bold text-slate-700">Design the QR</h3>
<QrDesigner ref={designerRef} value={style} onChange={setStyle} data={previewData} />
<div className="flex justify-end">
<button onClick={generate} disabled={busy}
className="bg-brand-600 text-white px-8 py-2.5 rounded-lg font-semibold disabled:opacity-60">
{busy ? "Generating…" : "Generate QR"}
</button>
</div>
</>
)}
{step === "done" && result && (
<div className="grid md:grid-cols-2 gap-5 items-start">
<div className="card p-6 text-center space-y-4">
<div className="h-14 w-14 mx-auto rounded-full bg-emerald-100 text-emerald-600 grid place-items-center">
<Check className="h-7 w-7" />
</div>
<h3 className="font-bold text-lg">
{result.generated} QR code{result.generated > 1 ? "s" : ""} generated
</h3>
<div className="flex gap-3 justify-center flex-wrap">
<button onClick={() => downloadClient("png")}
className="flex items-center gap-2 border border-slate-200 px-4 py-2 rounded-lg text-sm font-medium">
<Download className="h-4 w-4" /> PNG
</button>
<button onClick={() => downloadClient("svg")}
className="flex items-center gap-2 border border-slate-200 px-4 py-2 rounded-lg text-sm font-medium">
<Download className="h-4 w-4" /> SVG
</button>
{result.download_zip && (
<a href={`${apiBase}/qr/batch/${batchId}/download-zip?format=png`}
className="flex items-center gap-2 bg-brand-600 text-white px-4 py-2 rounded-lg text-sm font-semibold">
<Download className="h-4 w-4" /> Download all (ZIP)
</a>
)}
</div>
{result.codes?.[0] && (
<p className="text-sm text-slate-500">
Verify URL:{" "}
<a className="text-brand-600" href={result.codes[0].url} target="_blank" rel="noreferrer">
{result.codes[0].url}
</a>
</p>
)}
</div>
{/* keep the ref'd designer mounted so client PNG/SVG downloads work */}
<div className="card p-6 grid place-items-center">
<span className="text-sm text-slate-500 mb-3">Your QR</span>
<QrDesigner ref={designerRef} value={style} onChange={setStyle}
data={result.codes?.[0]?.url ?? previewData} />
</div>
</div>
)}
</div>
);
}
function Input({ label, ...props }: any) {
return (
<label className="block">
<span className="text-sm font-medium">{label}</span>
<input {...props} className="mt-1 w-full px-3 py-2 rounded-lg border border-slate-200 text-sm" />
</label>
);
}
function TypeCard({ active, onClick, icon, title, desc, note }: any) {
return (
<button type="button" onClick={onClick}
className={`text-left rounded-xl border p-4 ${active ? "border-brand-500 ring-2 ring-brand-500/20 bg-brand-50/40" : "border-slate-200"}`}>
<div className="flex items-center gap-2 font-semibold text-slate-800">{icon}{title}</div>
<p className="text-xs text-slate-500 mt-1">{desc}</p>
<p className="text-xs font-semibold text-brand-600 mt-2">{note}</p>
</button>
);
}

View File

@@ -0,0 +1,110 @@
"use client";
import { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import { api } from "@/lib/api";
export default function NewProduct() {
const r = useRouter();
const [brands, setBrands] = useState<any[]>([]);
const [form, setForm] = useState<any>({
brand_id: "", name: "", sku: "", category: "", description: "",
manufacturer: { company: "", country: "India", plant: "" },
details: { net_weight: "", shelf_life: "", storage_condition: "" },
status: "active",
});
const [err, setErr] = useState("");
const [newBrand, setNewBrand] = useState("");
useEffect(() => { api("/brands").then(setBrands).catch(() => {}); }, []);
async function createBrand() {
if (!newBrand) return;
const b = await api("/brands", { method: "POST", body: JSON.stringify({ name: newBrand }) });
setBrands([...brands, b]);
setForm({ ...form, brand_id: b.id });
setNewBrand("");
}
async function submit(e: React.FormEvent) {
e.preventDefault();
setErr("");
try {
const p = await api("/products", { method: "POST", body: JSON.stringify(form) });
r.push(`/dashboard/products/${p.id}/batch`);
} catch (e: any) {
const d = e.detail;
setErr(typeof d === "object" ? d.message : d || "Failed to create product");
}
}
const set = (path: string) => (e: any) => {
const v = e.target.value;
if (path.includes(".")) {
const [g, k] = path.split(".");
setForm({ ...form, [g]: { ...form[g], [k]: v } });
} else setForm({ ...form, [path]: v });
};
return (
<div className="max-w-2xl space-y-5">
<h2 className="text-xl font-bold">Add Product</h2>
{err && <div className="card p-3 bg-rose-50 border-rose-200 text-rose-700 text-sm">{err}</div>}
<form onSubmit={submit} className="space-y-5">
<div className="card p-5 space-y-4">
<h3 className="font-semibold text-slate-800">Basic Info</h3>
<div className="flex gap-2 items-end">
<label className="flex-1">
<span className="text-sm font-medium">Brand</span>
<select value={form.brand_id} onChange={set("brand_id")} required
className="mt-1 w-full px-3 py-2 rounded-lg border border-slate-200 text-sm">
<option value="">Select brand</option>
{brands.map((b) => <option key={b.id} value={b.id}>{b.name}</option>)}
</select>
</label>
<input placeholder="+ New brand" value={newBrand} onChange={(e) => setNewBrand(e.target.value)}
className="px-3 py-2 rounded-lg border border-slate-200 text-sm w-32" />
<button type="button" onClick={createBrand}
className="px-3 py-2 rounded-lg bg-slate-100 text-sm font-medium">Add</button>
</div>
<Input label="Product name" value={form.name} onChange={set("name")} required />
<div className="grid grid-cols-2 gap-3">
<Input label="SKU" value={form.sku} onChange={set("sku")} />
<Input label="Category" value={form.category} onChange={set("category")} />
</div>
</div>
<div className="card p-5 space-y-4">
<h3 className="font-semibold text-slate-800">Manufacturing Info</h3>
<Input label="Manufacturer" value={form.manufacturer.company} onChange={set("manufacturer.company")} />
<div className="grid grid-cols-2 gap-3">
<Input label="Country" value={form.manufacturer.country} onChange={set("manufacturer.country")} />
<Input label="Plant" value={form.manufacturer.plant} onChange={set("manufacturer.plant")} />
</div>
</div>
<div className="card p-5 space-y-4">
<h3 className="font-semibold text-slate-800">Product Details</h3>
<div className="grid grid-cols-2 gap-3">
<Input label="Net weight" value={form.details.net_weight} onChange={set("details.net_weight")} />
<Input label="Shelf life" value={form.details.shelf_life} onChange={set("details.shelf_life")} />
</div>
<Input label="Storage condition" value={form.details.storage_condition} onChange={set("details.storage_condition")} />
</div>
<button className="bg-brand-600 text-white px-6 py-2.5 rounded-lg font-semibold">
Save & create batch
</button>
</form>
</div>
);
}
function Input({ label, ...props }: any) {
return (
<label className="block">
<span className="text-sm font-medium">{label}</span>
<input {...props} className="mt-1 w-full px-3 py-2 rounded-lg border border-slate-200 text-sm" />
</label>
);
}

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>
);
}