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,463 @@
"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>
);
}