464 lines
19 KiB
TypeScript
464 lines
19 KiB
TypeScript
"use client";
|
||
import { useEffect, useMemo, useState } from "react";
|
||
import { useRouter } from "next/navigation";
|
||
import {
|
||
QrCode, CheckCircle2, XCircle, TrendingUp, Calendar, Search,
|
||
Eye, Download, RefreshCw, MoreVertical, Archive, Power, Trash2, Plus,
|
||
} from "lucide-react";
|
||
import { api, apiBase } from "@/lib/api";
|
||
import QrDetailDrawer from "@/components/QrDetailDrawer";
|
||
|
||
|
||
|
||
export default function QRCenter() {
|
||
const router = useRouter();
|
||
const [rows, setRows] = useState<any[]>([]);
|
||
const [summary, setSummary] = useState<any>(null);
|
||
const [brands, setBrands] = useState<any[]>([]);
|
||
const [products, setProducts] = useState<any[]>([]);
|
||
const [filters, setFilters] = useState({ search: "", product_id: "", brand_id: "", status_filter: "", qr_type: "" ,start_date: "",
|
||
end_date: ""});
|
||
const [selected, setSelected] = useState<Set<string>>(new Set());
|
||
const [openId, setOpenId] = useState<string | null>(null);
|
||
const [menuFor, setMenuFor] = useState<string | null>(null);
|
||
const [page, setPage] = useState(1);
|
||
const pageSize = 25;
|
||
const [showProductPicker, setShowProductPicker] = useState(false);
|
||
const [productSearch, setProductSearch] = useState("");
|
||
const [brandFilter, setBrandFilter] = useState("");
|
||
const filteredProducts = products.filter((p) => {
|
||
const matchSearch =
|
||
p.name.toLowerCase().includes(productSearch.toLowerCase()) ||
|
||
(p.sku ?? "").toLowerCase().includes(productSearch.toLowerCase());
|
||
|
||
const matchBrand =
|
||
!brandFilter || p.brand_id === brandFilter;
|
||
|
||
return matchSearch && matchBrand;
|
||
});
|
||
function load() {
|
||
const qs = new URLSearchParams(Object.entries(filters).filter(([, v]) => v) as any).toString();
|
||
api(`/qr${qs ? "?" + qs : ""}`).then(setRows).catch(() => setRows([]));
|
||
api("/qr/summary").then(setSummary).catch(() => {});
|
||
}
|
||
useEffect(() => { load(); }, [filters]);
|
||
useEffect(() => {
|
||
api("/brands").then(setBrands).catch(() => {});
|
||
api("/products").then((d) => setProducts(d.items ?? [])).catch(() => {});
|
||
}, []);
|
||
|
||
function handleExport() {
|
||
const exportRows =
|
||
selected.size > 0
|
||
? rows.filter((r) => selected.has(r.id))
|
||
: rows;
|
||
|
||
const headers = [
|
||
"QR Code",
|
||
"Product",
|
||
"SKU",
|
||
"Brand",
|
||
"Batch Number",
|
||
"Serial",
|
||
"QR Type",
|
||
"Status",
|
||
"Scan Count",
|
||
"Last Scan",
|
||
"Last Scan Location",
|
||
"Created By",
|
||
"Created Date",
|
||
"Verification URL",
|
||
"QR Image",
|
||
];
|
||
|
||
const csv = [
|
||
headers.join(","),
|
||
|
||
...exportRows.map((r) =>
|
||
[
|
||
`"${r.code ?? ""}"`,
|
||
`"${r.product_name ?? ""}"`,
|
||
`"${r.sku ?? ""}"`,
|
||
`"${r.brand_name ?? ""}"`,
|
||
`"${r.batch_number ?? ""}"`,
|
||
`"${r.serial ?? ""}"`,
|
||
`"${r.qr_type ?? ""}"`,
|
||
`"${r.status ?? ""}"`,
|
||
`"${r.scan_count ?? 0}"`,
|
||
`"${r.last_scan_at ? new Date(r.last_scan_at).toLocaleString("en-IN") : ""}"`,
|
||
`"${r.last_scan_location ?? ""}"`,
|
||
`"${r.created_by ?? ""}"`,
|
||
`"${r.created_at ? new Date(r.created_at).toLocaleString("en-IN") : ""}"`,
|
||
`"${r.url ?? ""}"`,
|
||
`"${r.image_url ? apiBase + r.image_url : apiBase + `/qr/${r.id}/download?format=png`}"`,
|
||
].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 =
|
||
selected.size > 0
|
||
? `selected_qr_${selected.size}.csv`
|
||
: `qr_report_${new Date().toISOString().slice(0, 10)}.csv`;
|
||
|
||
document.body.appendChild(a);
|
||
a.click();
|
||
document.body.removeChild(a);
|
||
|
||
URL.revokeObjectURL(url);
|
||
}
|
||
const paged = useMemo(() => rows.slice((page - 1) * pageSize, page * pageSize), [rows, page]);
|
||
const totalPages = Math.max(1, Math.ceil(rows.length / pageSize));
|
||
|
||
function toggle(id: string) {
|
||
setSelected((s) => { const n = new Set(s); n.has(id) ? n.delete(id) : n.add(id); return n; });
|
||
}
|
||
function toggleAll() {
|
||
setSelected((s) => s.size === paged.length ? new Set() : new Set(paged.map((r) => r.id)));
|
||
}
|
||
async function bulk(action: string) {
|
||
if (selected.size === 0) return;
|
||
if (action === "delete" && !confirm(`Delete ${selected.size} QR code(s)?`)) return;
|
||
await api("/qr/bulk", { method: "POST", body: JSON.stringify({ ids: [...selected], action }) });
|
||
setSelected(new Set()); load();
|
||
}
|
||
const set = (k: string, v: string) => { setPage(1); setFilters((f) => ({ ...f, [k]: v })); };
|
||
|
||
return (
|
||
<div className="space-y-5" onClick={() => setMenuFor(null)}>
|
||
<div className="flex items-center justify-between">
|
||
<h2 className="text-xl font-bold">QR Management Center</h2>
|
||
<button onClick={() => setShowProductPicker(true)}
|
||
className="flex items-center gap-2 bg-brand-600 text-white px-4 py-2 rounded-lg text-sm font-semibold">
|
||
<Plus className="h-4 w-4" /> Generate New QR
|
||
</button>
|
||
</div>
|
||
|
||
{/* KPIs */}
|
||
<div className="grid grid-cols-2 lg:grid-cols-5 gap-4">
|
||
<Kpi label="Total QR Codes" value={summary?.total ?? 0} sub="All time" icon={<QrCode className="h-5 w-5" />} tint="bg-blue-50 text-blue-600" />
|
||
<Kpi label="Active QR Codes" value={summary?.active ?? 0} sub={`${summary?.active_pct ?? 0}% of total`} subColor="text-emerald-600" icon={<CheckCircle2 className="h-5 w-5" />} tint="bg-emerald-50 text-emerald-600" />
|
||
<Kpi label="Inactive QR Codes" value={summary?.inactive ?? 0} sub={`${summary?.inactive_pct ?? 0}% of total`} subColor="text-rose-600" icon={<XCircle className="h-5 w-5" />} tint="bg-rose-50 text-rose-500" />
|
||
<Kpi label="Total Scans" value={(summary?.total_scans ?? 0).toLocaleString()} sub="All time" icon={<TrendingUp className="h-5 w-5" />} tint="bg-violet-50 text-violet-600" />
|
||
<Kpi label="Today's Scans" value={summary?.today_scans ?? 0} sub="↑ vs yesterday" subColor="text-emerald-600" icon={<Calendar className="h-5 w-5" />} tint="bg-cyan-50 text-cyan-600" />
|
||
</div>
|
||
|
||
{/* Filters */}
|
||
<div className="card p-4 flex flex-wrap items-end gap-3">
|
||
<div className="relative">
|
||
<Search className="absolute left-3 top-2.5 h-4 w-4 text-slate-400" />
|
||
<input value={filters.search} onChange={(e) => set("search", e.target.value)} placeholder="Search QR…"
|
||
className="pl-9 pr-3 py-2 w-44 rounded-lg border border-slate-200 text-sm" />
|
||
</div>
|
||
<Filter label="Product" value={filters.product_id} onChange={(v) => set("product_id", v)} opts={products.map((p) => ({ value: p.id, label: p.name }))} all="All Products" />
|
||
<Filter label="Brand" value={filters.brand_id} onChange={(v) => set("brand_id", v)} opts={brands.map((b) => ({ value: b.id, label: b.name }))} all="All Brands" />
|
||
<Filter label="Status" value={filters.status_filter} onChange={(v) => set("status_filter", v)} opts={[{ value: "active", label: "Active" }, { value: "inactive", label: "Inactive" }, { value: "disabled", label: "Disabled" }]} all="All Status" />
|
||
<Filter label="QR Type" value={filters.qr_type} onChange={(v) => set("qr_type", v)} opts={[{ value: "batch", label: "Batch" }, { value: "per_pack", label: "Per-Pack" }]} all="All Types" />
|
||
<div className="block">
|
||
<span className="text-xs text-slate-500">Created On</span>
|
||
|
||
<div className="mt-1 flex items-center gap-2">
|
||
<input
|
||
type="date"
|
||
value={filters.start_date}
|
||
onChange={(e) => set("start_date", e.target.value)}
|
||
className="px-3 py-2 rounded-lg border border-slate-200 text-sm"
|
||
/>
|
||
|
||
<span className="text-slate-400">to</span>
|
||
|
||
<input
|
||
type="date"
|
||
value={filters.end_date}
|
||
onChange={(e) => set("end_date", e.target.value)}
|
||
className="px-3 py-2 rounded-lg border border-slate-200 text-sm"
|
||
/>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Bulk actions bar */}
|
||
<div className="flex items-center justify-between text-sm">
|
||
<div className="flex items-center gap-2">
|
||
|
||
<span className="text-slate-500">{selected.size} selected</span>
|
||
{[["disable", Power, "Disable"], ["archive", Archive, "Archive"], ["delete", Trash2, "Delete"]].map(([a, Icon, label]: any) => (
|
||
<button key={a} onClick={() => bulk(a)} disabled={selected.size === 0}
|
||
className={`flex items-center gap-1 px-3 py-1.5 rounded-lg border text-xs font-medium ${selected.size === 0 ? "border-slate-100 text-slate-300" : a === "delete" ? "border-rose-200 text-rose-600" : "border-slate-200 text-slate-600"}`}>
|
||
<Icon className="h-3.5 w-3.5" /> {label}
|
||
</button>
|
||
))}
|
||
<button
|
||
onClick={handleExport}
|
||
className="flex items-center gap-2 rounded-lg border border-slate-200 px-4 py-2 text-sm hover:bg-slate-50"
|
||
>
|
||
<Download className="h-4 w-4" />
|
||
Export
|
||
</button>
|
||
</div>
|
||
<span className="text-slate-500">Total: {rows.length}</span>
|
||
</div>
|
||
|
||
<div>
|
||
{/* Table */}
|
||
<div className="card overflow-x-auto">
|
||
<table className="w-full text-sm">
|
||
<thead className="bg-slate-50 text-slate-500 text-left">
|
||
<tr>
|
||
<th className="px-3 py-3"><input type="checkbox" checked={selected.size > 0 && selected.size === paged.length} onChange={toggleAll} /></th>
|
||
<th className="px-3 py-3 font-medium">QR</th>
|
||
<th className="px-3 py-3 font-medium">Product / SKU</th>
|
||
<th className="px-3 py-3 font-medium">Batch</th>
|
||
<th className="px-3 py-3 font-medium">Type</th>
|
||
<th className="px-3 py-3 font-medium">Status</th>
|
||
<th className="px-3 py-3 font-medium">Scans</th>
|
||
<th className="px-3 py-3 font-medium">Last Scan</th>
|
||
<th className="px-3 py-3 font-medium">Created</th>
|
||
<th className="px-3 py-3 font-medium">Actions</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody className="divide-y divide-slate-100">
|
||
{paged.map((q) => (
|
||
<tr key={q.id} className={`hover:bg-slate-50 ${openId === q.id ? "bg-brand-50/40" : ""}`}>
|
||
<td className="px-3 py-3"><input type="checkbox" checked={selected.has(q.id)} onChange={() => toggle(q.id)} /></td>
|
||
<td className="px-3 py-3">
|
||
{q.image_url
|
||
? <img src={`${apiBase}${q.image_url}`} alt="" className="h-11 w-11 rounded object-contain border border-slate-100" />
|
||
: <img src={`${apiBase}/qr/${q.id}/download?format=png`} alt="" className="h-11 w-11 rounded object-contain border border-slate-100" />}
|
||
</td>
|
||
<td className="px-3 py-3">
|
||
<div className="font-medium text-slate-800">{q.product_name ?? "—"}</div>
|
||
<div className="text-xs text-slate-400">{q.sku ?? ""}</div>
|
||
</td>
|
||
<td className="px-3 py-3 text-slate-500">{q.batch_number ?? "—"}</td>
|
||
<td className="px-3 py-3"><span className="text-xs font-semibold bg-blue-50 text-blue-700 px-2 py-0.5 rounded-full capitalize">{q.qr_type === "per_pack" ? "Per-Pack" : "Batch"}</span></td>
|
||
<td className="px-3 py-3">
|
||
<span className="flex items-center gap-1.5 text-sm capitalize">
|
||
<span className={`h-2 w-2 rounded-full ${q.status === "active" ? "bg-emerald-500" : "bg-rose-500"}`} />
|
||
{q.status}
|
||
</span>
|
||
</td>
|
||
<td className="px-3 py-3 font-medium">{q.scan_count.toLocaleString()}</td>
|
||
<td className="px-3 py-3 text-xs text-slate-500">
|
||
{q.last_scan_at ? (
|
||
<>
|
||
<div>
|
||
{new Date(q.last_scan_at).toLocaleDateString("en-IN")}
|
||
</div>
|
||
|
||
<div className="text-slate-400">
|
||
{new Date(q.last_scan_at).toLocaleTimeString("en-IN", {
|
||
hour: "2-digit",
|
||
minute: "2-digit",
|
||
second: "2-digit",
|
||
})}
|
||
</div>
|
||
|
||
<div className="text-brand-600">
|
||
{q.last_scan_location ?? ""}
|
||
</div>
|
||
</>
|
||
) : (
|
||
"—"
|
||
)}
|
||
</td>
|
||
<td className="px-3 py-3 text-xs text-slate-500">
|
||
<div>
|
||
{new Date(q.created_at).toLocaleDateString("en-IN")}
|
||
</div>
|
||
|
||
<div className="text-slate-400">
|
||
{new Date(q.created_at).toLocaleTimeString("en-IN", {
|
||
hour: "2-digit",
|
||
minute: "2-digit",
|
||
second: "2-digit",
|
||
})}
|
||
</div>
|
||
</td>
|
||
<td className="px-3 py-3 relative">
|
||
<div className="flex gap-1">
|
||
<IconBtn title="View" onClick={() => setOpenId(q.id)}><Eye className="h-4 w-4" /></IconBtn>
|
||
<button onClick={(e) => { e.stopPropagation(); setMenuFor(menuFor === q.id ? null : q.id); }}
|
||
className="h-8 w-8 grid place-items-center rounded-lg border border-slate-200 text-slate-500 hover:bg-slate-50">
|
||
<MoreVertical className="h-4 w-4" />
|
||
</button>
|
||
</div>
|
||
{menuFor === q.id && (
|
||
<div onClick={(e) => e.stopPropagation()}
|
||
className="absolute right-3 top-12 z-20 w-44 bg-white rounded-xl border border-slate-200 shadow-lg py-1 text-left">
|
||
<a href={`${apiBase}/qr/${q.id}/download?format=png`} className="flex items-center gap-2 px-3 py-2 text-sm text-slate-700 hover:bg-slate-50"><Download className="h-4 w-4" /> Download PNG</a>
|
||
<a href={`${apiBase}/qr/${q.id}/download?format=svg`} className="flex items-center gap-2 px-3 py-2 text-sm text-slate-700 hover:bg-slate-50"><Download className="h-4 w-4" /> Download SVG</a>
|
||
<a href={`${apiBase}/qr/${q.id}/download?format=pdf`} className="flex items-center gap-2 px-3 py-2 text-sm text-slate-700 hover:bg-slate-50"><Download className="h-4 w-4" /> Download PDF</a>
|
||
<div className="border-t border-slate-100 my-1" />
|
||
<button onClick={async () => { await api(`/qr/${q.id}/regenerate`, { method: "POST" }); setMenuFor(null); load(); }} className="w-full flex items-center gap-2 px-3 py-2 text-sm text-slate-700 hover:bg-slate-50"><RefreshCw className="h-4 w-4" /> Regenerate</button>
|
||
<button onClick={async () => { await api(`/qr/${q.id}/disable`, { method: "PUT" }); setMenuFor(null); load(); }} className="w-full flex items-center gap-2 px-3 py-2 text-sm text-rose-600 hover:bg-slate-50"><Power className="h-4 w-4" /> Disable</button>
|
||
</div>
|
||
)}
|
||
</td>
|
||
</tr>
|
||
))}
|
||
{paged.length === 0 && <tr><td colSpan={10} className="px-4 py-12 text-center text-slate-400">No QR codes match.</td></tr>}
|
||
</tbody>
|
||
</table>
|
||
|
||
{/* Pagination */}
|
||
<div className="flex items-center justify-between px-4 py-3 text-sm text-slate-500">
|
||
<span>Showing {(page - 1) * pageSize + 1} to {Math.min(page * pageSize, rows.length)} of {rows.length}</span>
|
||
<div className="flex gap-1">
|
||
<button disabled={page === 1} onClick={() => setPage(page - 1)} className="px-3 py-1 rounded border border-slate-200 disabled:opacity-40">‹</button>
|
||
{Array.from({ length: totalPages }).slice(0, 5).map((_, i) => (
|
||
<button key={i} onClick={() => setPage(i + 1)} className={`px-3 py-1 rounded ${page === i + 1 ? "bg-brand-600 text-white" : "border border-slate-200"}`}>{i + 1}</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>
|
||
</div>
|
||
|
||
</div>
|
||
{showProductPicker && (
|
||
<div className="fixed inset-0 z-50 bg-black/40 flex items-center justify-center">
|
||
<div className="bg-white rounded-2xl shadow-xl w-full max-w-lg">
|
||
|
||
<div className="p-5 border-b">
|
||
<h2 className="text-lg font-bold">
|
||
Generate QR
|
||
</h2>
|
||
<p className="text-sm text-slate-500">
|
||
Select a product.
|
||
</p>
|
||
</div>
|
||
<div className="p-4 border-b flex gap-3">
|
||
<input
|
||
value={productSearch}
|
||
onChange={(e) => setProductSearch(e.target.value)}
|
||
placeholder="Search product..."
|
||
className="flex-1 rounded-lg border px-3 py-2"
|
||
/>
|
||
|
||
<select
|
||
value={brandFilter}
|
||
onChange={(e) => setBrandFilter(e.target.value)}
|
||
className="rounded-lg border px-3 py-2"
|
||
>
|
||
<option value="">All Brands</option>
|
||
|
||
{brands.map((b) => (
|
||
<option key={b.id} value={b.id}>
|
||
{b.name}
|
||
</option>
|
||
))}
|
||
</select>
|
||
</div>
|
||
<div className="max-h-96 overflow-y-auto">
|
||
|
||
{filteredProducts.map((p) => (
|
||
|
||
<button
|
||
key={p.id}
|
||
onClick={() => {
|
||
setShowProductPicker(false);
|
||
router.push(`/dashboard/products/${p.id}/batch`);
|
||
}}
|
||
className="w-full flex items-center justify-between p-4 hover:bg-slate-50 border-b"
|
||
>
|
||
<div className="flex items-center gap-4">
|
||
|
||
<img
|
||
src={p.image_url}
|
||
className="h-14 w-14 rounded-lg border object-cover"
|
||
alt=""
|
||
/>
|
||
|
||
<div className="text-left">
|
||
|
||
<div className="font-semibold text-slate-800">
|
||
{p.name}
|
||
</div>
|
||
|
||
<div className="text-sm text-slate-500 mt-1">
|
||
SKU: <span className="font-medium">{p.sku || "—"}</span>
|
||
|
||
<span className="mx-2">•</span>
|
||
|
||
Brand:
|
||
<span className="font-medium ml-1">
|
||
{p.brand_name || "—"}
|
||
</span>
|
||
</div>
|
||
|
||
</div>
|
||
|
||
</div>
|
||
|
||
<QrCode className="h-5 w-5 text-slate-400" />
|
||
</button>
|
||
|
||
))}
|
||
|
||
</div>
|
||
|
||
<div className="p-4 border-t flex justify-end">
|
||
<button
|
||
onClick={() => setShowProductPicker(false)}
|
||
className="px-4 py-2 rounded-lg border"
|
||
>
|
||
Cancel
|
||
</button>
|
||
</div>
|
||
|
||
</div>
|
||
</div>
|
||
)}
|
||
{/* QR Details modal */}
|
||
{openId && (
|
||
<QrDetailDrawer qrId={openId} onClose={() => setOpenId(null)} onChanged={load} />
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function Kpi({ label, value, sub, subColor = "text-slate-400", icon, tint }: any) {
|
||
return (
|
||
<div className="card p-5">
|
||
<div className="flex items-start gap-4">
|
||
<div className={`h-12 w-12 rounded-xl grid place-items-center shrink-0 ${tint}`}>{icon}</div>
|
||
<div className="min-w-0">
|
||
<div className="text-sm text-slate-500">{label}</div>
|
||
<div className="text-2xl font-extrabold text-slate-900 mt-0.5">{value}</div>
|
||
<div className={`text-xs font-medium mt-1 ${subColor}`}>{sub}</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
function Filter({ label, value, onChange, opts, all }: {
|
||
label: string; value: string; onChange: (v: string) => void;
|
||
opts: { value: string; label: string }[]; all: string;
|
||
}) {
|
||
return (
|
||
<label className="block">
|
||
<span className="text-xs text-slate-500">{label}</span>
|
||
<select value={value} onChange={(e) => onChange(e.target.value)}
|
||
className="mt-1 block px-3 py-2 rounded-lg border border-slate-200 text-sm min-w-[130px]">
|
||
<option value="">{all}</option>
|
||
{opts.map((o: any) => <option key={o.value} value={o.value}>{o.label}</option>)}
|
||
</select>
|
||
</label>
|
||
);
|
||
}
|
||
function IconBtn({ children, onClick, title }: any) {
|
||
return (
|
||
<button title={title} onClick={onClick} className="h-8 w-8 grid place-items-center rounded-lg border border-slate-200 text-slate-500 hover:bg-slate-50">
|
||
{children}
|
||
</button>
|
||
);
|
||
}
|