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

464 lines
19 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, 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>
);
}