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,473 @@
"use client";
import { Fragment, useEffect, useState } from "react";
import {
PieChart, Pie, Cell, ResponsiveContainer, Tooltip,
} from "recharts";
import { Download, ScanLine, Users, Globe, ShieldCheck } from "lucide-react";
import ScanTrendChart from "@/components/ScanTrendChart";
import { api, apiBase } from "@/lib/api";
import GeographicOverview from "@/components/GeographicOverview";
const PRESETS = [
{ label: "7d", days: 7 },
{ label: "30d", days: 30 },
{ label: "90d", days: 90 },
];
const DONUT = ["#2563eb", "#10b981", "#f59e0b", "#94a3b8"];
const DOW = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"];
export default function Analytics() {
const [days, setDays] = useState(30);
const [s, setS] = useState<any>(null);
const [trend, setTrend] = useState<any[]>([]);
const [topP, setTopP] = useState<any[]>([]);
const [cities, setCities] = useState<any[]>([]);
const [devices, setDevices] = useState<any[]>([]);
const [heat, setHeat] = useState<any[]>([]);
const [recent, setRecent] = useState<any[]>([]);
const [geo, setGeo] = useState<any[]>([]);
const totalDevices = devices.reduce((sum, d) => sum + d.scans, 0);
const [brands, setBrands] = useState([]);
const [brandId, setBrandId] = useState("");
const [recentTotal, setRecentTotal] = useState(0);
const DONUT = [
"#22c55e", // Android - Green
"#2563eb", // iOS - Blue
"#7c3aed", // Desktop - Purple
"#f59e0b", // Other - Orange
];
const [fromDate, setFromDate] = useState("");
const [toDate, setToDate] = useState("");
const params = new URLSearchParams();
if (fromDate && toDate) {
params.append("from_date", fromDate);
params.append("to_date", toDate);
} else {
params.append("days", String(days));
}
if (brandId) {
params.append("brand_id", brandId);
}
const query = params.toString();
const [recentPage, setRecentPage] = useState(1);
const [recentTotalPages, setRecentTotalPages] = useState(1);
const recentPageSize = 20;
const clearFilters = () => {
setBrandId("");
setFromDate("");
setToDate("");
setDays(30);
setRecentPage(1); // default preset
};
const [showAllProducts, setShowAllProducts] = useState(false);
useEffect(() => {
api("/analytics/brands").then(setBrands);
}, []);
useEffect(() => {
api(`/analytics/summary?${query}`).then(setS).catch(() => {});
api(`/analytics/trend?${query}`).then(setTrend).catch(() => {});
api(`/analytics/top-products?${query}`).then(setTopP).catch(() => {});
api(`/analytics/top-cities?${query}`).then(setCities).catch(() => {});
api(`/analytics/devices?${query}`).then(setDevices).catch(() => {});
api(`/analytics/geography?${query}`).then(setGeo).catch(() => {});
api(`/analytics/time-distribution?${query}`).then(setHeat).catch(() => {});
api(
`/analytics/recent-scans?${query}&page=${recentPage}&page_size=${recentPageSize}`
)
.then((res) => {
setRecent(res.items);
setRecentTotalPages(res.pages);
setRecentTotal(res.total);
})
.catch(() => {});
}, [days, fromDate, toDate,brandId, recentPage]);
const maxP = Math.max(1, ...topP.map((p) => p.scans));
const maxHeat = Math.max(1, ...heat.map((h) => h.count));
const startPage = Math.max(1, recentPage - 2);
const endPage = Math.min(recentTotalPages, startPage + 4);
const pages = [];
for (let i = startPage; i <= endPage; i++) {
pages.push(i);
}
const downloadCsv = async () => {
const token = localStorage.getItem("vp_token"); // or whatever key you use
const res = await fetch(`${apiBase}/analytics/export?${query}`, {
headers: {
Authorization: `Bearer ${token}`,
},
});
if (!res.ok) {
alert("Failed to download CSV");
return;
}
const blob = await res.blob();
const url = window.URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = "scans.csv";
document.body.appendChild(a);
a.click();
a.remove();
window.URL.revokeObjectURL(url);
};
return (
<div className="space-y-5">
<div className="flex items-center justify-between">
<h2 className="text-xl font-bold">Analytics & Insights</h2>
<div className="flex items-center gap-3">
<div className="flex rounded-lg border border-slate-200 overflow-hidden">
{PRESETS.map((p) => (
<button
key={p.days}
onClick={() => {
setDays(p.days);
setFromDate("");
setToDate("");
}}
className={`px-3 py-1.5 text-sm font-medium ${
days === p.days
? "bg-brand-600 text-white"
: "bg-white text-slate-600"
}`}
>
{p.label}
</button>
))}
</div>
<div className="flex items-center gap-2">
<input
type="date"
value={fromDate}
onChange={(e) => setFromDate(e.target.value)}
className="border rounded-lg px-3 py-2 text-sm"
/>
<span>to</span>
<input
type="date"
value={toDate}
onChange={(e) => setToDate(e.target.value)}
className="border rounded-lg px-3 py-2 text-sm"
/>
</div>
<select
value={brandId}
onChange={(e) => setBrandId(e.target.value)}
className="border rounded-lg px-3 py-2 text-sm"
>
<option value="">All Brands</option>
{brands.map((b:any) => (
<option key={b.id} value={b.id}>
{b.name}
</option>
))}
</select><button
onClick={clearFilters}
className="border border-slate-200 px-3 py-2 rounded-lg text-sm font-medium hover:bg-slate-50"
>
Clear Filters
</button>
<button
onClick={downloadCsv}
className="flex items-center gap-2 border border-slate-200 px-3 py-2 rounded-lg text-sm font-medium"
>
<Download className="h-4 w-4" />
CSV
</button>
</div>
</div>
{/* KPIs */}
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-6 gap-4">
<Kpi label="Total Scans" value={s?.total_scans ?? 0} icon={<ScanLine className="h-5 w-5" />} tint="bg-blue-50 text-blue-600" />
<Kpi label="Unique Consumers" value={s?.unique_consumers ?? 0} icon={<Users className="h-5 w-5" />} tint="bg-violet-50 text-violet-600" />
<Kpi label="Today" value={s?.today_scans ?? 0} icon={<ScanLine className="h-5 w-5" />} tint="bg-orange-50 text-orange-500" />
<Kpi label="Active Products" value={s?.active_products ?? 0} icon={<ShieldCheck className="h-5 w-5" />} tint="bg-emerald-50 text-emerald-600" />
<Kpi label="Avg Trust" value={`${s?.avg_trust_score ?? 0}%`} icon={<ShieldCheck className="h-5 w-5" />} tint="bg-blue-50 text-blue-600" />
<Kpi label="Countries" value={s?.countries ?? 0} icon={<Globe className="h-5 w-5" />} tint="bg-amber-50 text-amber-500" />
</div>
<div className="grid lg:grid-cols-3 gap-4">
<div className="card p-5 lg:col-span-1">
<h3 className="font-bold mb-2">Scan Trend</h3>
<ScanTrendChart data={trend} />
</div>
<div className="card p-5">
<h3 className="font-bold mb-5">Device Types</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 items-center">
{/* Donut Chart */}
<div className="flex justify-center">
<ResponsiveContainer width={200} height={200}>
<PieChart>
<Pie
data={devices}
dataKey="scans"
nameKey="device"
innerRadius={55}
outerRadius={80}
stroke="#fff"
strokeWidth={2}
>
{devices.map((_, i) => (
<Cell
key={i}
fill={DONUT[i % DONUT.length]}
/>
))}
</Pie>
<Tooltip
formatter={(value: any) => [
`${value} scans`,
"Scans",
]}
/>
</PieChart>
</ResponsiveContainer>
</div>
{/* Legend */}
<div className="space-y-4">
{devices.map((d, i) => {
const percent =
totalDevices === 0
? 0
: ((d.scans / totalDevices) * 100).toFixed(1);
return (
<div
key={d.device}
className="flex items-center justify-between"
>
<div className="flex items-center gap-2">
<span
className="h-2 w-1 rounded-full"
style={{
backgroundColor: DONUT[i % DONUT.length],
}}
/>
<span className="font-medium text-slate-700">
{d.device}
</span>
</div>
<span className="text-sm font-semibold text-slate-600">
{percent}% ({d.scans.toLocaleString()})
</span>
</div>
);
})}
</div>
</div>
</div>
<div className="card p-5">
<h3 className="font-bold mb-4">Scan Time Distribution</h3>
<div className="overflow-x-auto">
<div className="inline-grid gap-0.5" style={{ gridTemplateColumns: "28px repeat(24, 10px)" }}>
<div />
{Array.from({ length: 24 }).map((_, h) => (
<div key={h} className="text-[8px] text-slate-400 text-center">{h % 3 === 0 ? h : ""}</div>
))}
{DOW.map((day, d) => (
<Fragment key={`row-${d}`}>
<div className="text-[10px] text-slate-500 pr-1 flex items-center">{day}</div>
{Array.from({ length: 24 }).map((_, h) => {
const cell = heat.find((x) => x.dow === d && x.hour === h);
const v = cell?.count ?? 0;
const op = v === 0 ? 0.06 : 0.2 + (v / maxHeat) * 0.8;
return <div key={`${d}-${h}`} title={`${day} ${h}:00 — ${v}`}
className="h-[12px] w-[12px] rounded-[2px]" style={{ background: `rgba(37,99,235,${op})` }} />;
})}
</Fragment>
))}
</div>
</div>
</div>
</div>
<div className="grid lg:grid-cols-3 gap-4">
<div className="card p-5">
<div className="flex items-center justify-between mb-4">
<h3 className="font-bold">Top Products</h3>
{topP.length > 5 && (
<button
onClick={() => setShowAllProducts(!showAllProducts)}
className="text-sm font-medium text-blue-600 hover:underline"
>
{showAllProducts ? "Show Less" : "View All"}
</button>
)}
</div>
<div className="space-y-3">
{(showAllProducts ? topP : topP.slice(0, 5)).map((p) => (
<div key={p.name} className="flex items-center gap-3">
<div className="w-36 truncate text-sm text-slate-600">
{p.name}
</div>
<div className="flex-1 h-3 rounded-full bg-slate-100 overflow-hidden">
<div
className="h-full rounded-full bg-blue-500"
style={{
width: `${(p.scans / maxP) * 100}%`,
}}
/>
</div>
<div className="w-12 text-right text-sm font-semibold">
{p.scans}
</div>
</div>
))}
{!showAllProducts && topP.length > 5 && (
<button
onClick={() => setShowAllProducts(true)}
className="text-sm font-medium text-blue-600 hover:underline"
>
+{topP.length - 5} more products
</button>
)}
{topP.length === 0 && (
<p className="text-sm text-slate-400">No data.</p>
)}
</div>
</div>
<div className="card p-5">
<h3 className="font-bold mb-4">Top Cities</h3>
<ol className="space-y-2 text-sm">
{cities.map((c, i) => (
<li key={c.city} className="flex justify-between">
<span className="text-slate-600">{i + 1}. {c.city}</span>
<span className="font-semibold">{c.scans}</span>
</li>
))}
{cities.length === 0 && <li className="text-slate-400">No data.</li>}
</ol>
</div>
<div className="card p-5">
<h3 className="font-bold mb-4">
Geographic Overview
</h3>
<GeographicOverview data={geo} />
</div>
</div>
{/* Heatmap */}
{/* Recent scans */}
<div className="card overflow-x-auto">
<h3 className="font-bold p-5 pb-3">Recent Scans</h3>
<table className="w-full text-sm">
<thead className="bg-slate-50 text-slate-500 text-left">
<tr>
<th className="px-4 py-3 font-medium">Time</th>
<th className="px-4 py-3 font-medium">Code</th>
<th className="px-4 py-3 font-medium">City</th>
<th className="px-4 py-3 font-medium">Country</th>
<th className="px-4 py-3 font-medium">Device</th>
<th className="px-4 py-3 font-medium">IP Address</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-100">
{recent.map((r, i) => (
<tr key={i}>
<td className="px-4 py-3 text-slate-500">{new Date(r.at).toLocaleString()}</td>
<td className="px-4 py-3 font-mono text-slate-600">{r.code}</td>
<td className="px-4 py-3">{r.city ?? "—"}</td>
<td className="px-4 py-3">{r.country ?? "—"}</td>
<td className="px-4 py-3">{r.device ?? "—"}</td>
<td className="px-4 py-3">{r.ip ?? "—"}</td>
</tr>
))}
{recent.length === 0 && <tr><td colSpan={5} className="px-4 py-10 text-center text-slate-400">No scans yet.</td></tr>}
</tbody>
</table>
<div className="flex items-center justify-between px-4 py-3 text-sm text-slate-500 border-t">
<span>
Showing{" "}
{recent.length === 0
? 0
: (recentPage - 1) * recentPageSize + 1}{" "}
to{" "}
{Math.min(recentPage * recentPageSize, recentTotal)}{" "}
of {recentTotal}
</span>
<div className="flex gap-1">
<button
disabled={recentPage === 1}
onClick={() => setRecentPage(recentPage - 1)}
className="px-3 py-1 rounded border border-slate-200 disabled:opacity-40"
>
</button>
{pages.map((p) => (
<button
key={p}
onClick={() => setRecentPage(p)}
className={`px-3 py-1 rounded ${
recentPage === p
? "bg-brand-600 text-white"
: "border border-slate-200"
}`}
>
{p}
</button>
))}
<button
disabled={recentPage === recentTotalPages}
onClick={() => setRecentPage(recentPage + 1)}
className="px-3 py-1 rounded border border-slate-200 disabled:opacity-40"
>
</button>
</div>
</div>
</div>
</div>
);
}
function Kpi({ label, value, icon, tint }: any) {
return (
<div className="card p-4">
<div className="flex items-center gap-3">
<div className={`h-11 w-11 rounded-xl grid place-items-center shrink-0 ${tint}`}>{icon}</div>
<div className="min-w-0">
<div className="text-xl font-extrabold text-slate-900">{value}</div>
<div className="text-xs text-slate-500">{label}</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,199 @@
"use client";
import { useEffect, useState } from "react";
import { CheckCircle2, Download, CreditCard, RefreshCw, Tag } from "lucide-react";
import { api, apiBase } from "@/lib/api";
import { inr } from "@/lib/utils";
import { purchasePlan } from "@/lib/razorpay";
export default function Billing() {
const [sub, setSub] = useState<any>(null);
const [plans, setPlans] = useState<any[]>([]);
const [invoices, setInvoices] = useState<any[]>([]);
const [coupon, setCoupon] = useState("");
const [couponMsg, setCouponMsg] = useState("");
const [busy, setBusy] = useState("");
const [toast, setToast] = useState("");
async function load() {
api("/billing/subscription").then(setSub).catch(() => {});
api("/billing/plans").then(setPlans).catch(() => setPlans([]));
api("/billing/invoices").then(setInvoices).catch(() => {});
}
useEffect(() => { load(); }, []);
async function applyCoupon() {
setCouponMsg("");
try {
const r = await api("/billing/coupon/apply", { method: "POST", body: JSON.stringify({ code: coupon }) });
setCouponMsg(`${r.code}: ${r.percent_off}% off applied`);
} catch (e: any) {
setCouponMsg(typeof e.detail === "string" ? e.detail : "Invalid coupon");
}
}
async function choose(planId: string, planName: string) {
setBusy(planId);
try {
await purchasePlan(planId, coupon, (inv) => {
setToast(`${planName} activated${inv ? ` · Invoice ${inv}` : ""}`);
});
await load();
} catch (e: any) {
setToast(e?.message || "Payment failed");
} finally { setBusy(""); }
}
async function toggleAutoRenew() {
const next = !sub?.auto_renew;
await api("/billing/subscription/auto-renewal", { method: "PUT", body: JSON.stringify({ auto_renew: next }) });
load();
}
const u = sub?.usage;
return (
<div className="space-y-6">
{toast && (
<div className="card p-3 bg-emerald-50 border-emerald-200 text-emerald-800 text-sm flex items-center gap-2">
<CheckCircle2 className="h-4 w-4" /> {toast}
</div>
)}
<h2 className="text-xl font-bold">Billing & Subscription</h2>
{/* KPIs */}
<div className="grid sm:grid-cols-4 gap-4">
<Kpi label="Current Plan" value={sub?.plan?.name ?? "—"} />
<Kpi label="Status" value={(sub?.status ?? "—")} className="capitalize" />
<Kpi label="Days Remaining" value={sub?.days_remaining ?? "—"} />
<div className="card p-5">
<div className="text-sm text-slate-500">Auto-renewal</div>
<button onClick={toggleAutoRenew}
className={`mt-2 w-11 h-6 rounded-full relative transition ${sub?.auto_renew ? "bg-emerald-500" : "bg-slate-300"}`}>
<span className={`absolute top-0.5 h-5 w-5 rounded-full bg-white transition ${sub?.auto_renew ? "left-5" : "left-0.5"}`} />
</button>
</div>
</div>
{/* Usage bars */}
{u && (
<div className="card p-5">
<h3 className="font-bold mb-4">Usage</h3>
<div className="grid sm:grid-cols-3 gap-6">
<Usage label="Products" used={u.products} limit={u.product_limit} />
<Usage label="Users" used={u.users} limit={u.user_limit} />
<Usage label="Storage (GB)" used={u.storage_used_gb} limit={u.storage_total_gb} />
</div>
</div>
)}
{/* Coupon */}
<div className="card p-5">
<h3 className="font-bold mb-3 flex items-center gap-2"><Tag className="h-4 w-4" /> Have a coupon?</h3>
<div className="flex gap-2 max-w-md">
<input value={coupon} onChange={(e) => setCoupon(e.target.value)} placeholder="WELCOME10"
className="flex-1 px-3 py-2 rounded-lg border border-slate-200 text-sm" />
<button onClick={applyCoupon} className="px-4 py-2 rounded-lg bg-slate-100 text-sm font-medium">Apply</button>
</div>
{couponMsg && <p className="text-sm mt-2 text-slate-600">{couponMsg}</p>}
</div>
{/* Available plans */}
<div>
<h3 className="font-bold mb-3">Available Plans</h3>
<div className="grid md:grid-cols-3 lg:grid-cols-5 gap-4">
{plans.map((p) => {
const current = sub?.plan?.id === p.id;
return (
<div key={p.id} className={`card p-5 ${current ? "ring-2 ring-brand-500" : ""}`}>
<h4 className="font-bold">{p.name}</h4>
<div className="text-2xl font-extrabold mt-1">{p.price ? inr(p.price) : "Free"}
{p.price > 0 && <span className="text-xs font-normal text-slate-400">/mo</span>}</div>
<ul className="text-xs text-slate-500 mt-3 space-y-1">
<li>Products: {p.product_limit ?? "∞"}</li>
<li>Users: {p.user_limit ?? "∞"}</li>
<li>Storage: {p.storage_gb} GB</li>
</ul>
<button disabled={current || busy === p.id}
onClick={() => choose(p.id, p.name)}
className={`mt-4 w-full py-2 rounded-lg text-sm font-semibold ${current ? "bg-slate-100 text-slate-400" : "bg-brand-600 text-white"}`}>
{current ? "Current plan" : busy === p.id ? "Processing…" : "Choose"}
</button>
</div>
);
})}
{plans.length === 0 && <p className="text-sm text-slate-400">Plans load for billing admins.</p>}
</div>
</div>
{/* Payment history */}
<div>
<h3 className="font-bold mb-3">Payment History</h3>
<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-4 py-3 font-medium">Invoice</th>
<th className="px-4 py-3 font-medium">Plan</th>
<th className="px-4 py-3 font-medium">Subtotal</th>
<th className="px-4 py-3 font-medium">GST</th>
<th className="px-4 py-3 font-medium">Total</th>
<th className="px-4 py-3 font-medium">Status</th>
<th className="px-4 py-3"></th>
</tr>
</thead>
<tbody className="divide-y divide-slate-100">
{invoices.map((i) => (
<tr key={i.id} className="hover:bg-slate-50">
<td className="px-4 py-3 font-mono text-slate-700">{i.invoice_number}</td>
<td className="px-4 py-3">{i.plan_name}</td>
<td className="px-4 py-3">{inr(i.subtotal)}</td>
<td className="px-4 py-3">{inr(i.gst_amount)}</td>
<td className="px-4 py-3 font-semibold">{inr(i.total)}</td>
<td className="px-4 py-3">
<span className="text-xs font-semibold bg-emerald-50 text-emerald-700 px-2 py-0.5 rounded-full capitalize">{i.status}</span>
</td>
<td className="px-4 py-3 text-right">
<a href={`${apiBase}/billing/invoices/${i.id}/pdf`}
className="inline-flex items-center gap-1 text-brand-600 font-medium">
<Download className="h-4 w-4" /> PDF
</a>
</td>
</tr>
))}
{invoices.length === 0 && (
<tr><td colSpan={7} className="px-4 py-12 text-center text-slate-400">
<CreditCard className="h-8 w-8 mx-auto mb-2 opacity-50" /> No invoices yet.
</td></tr>
)}
</tbody>
</table>
</div>
</div>
</div>
);
}
function Kpi({ label, value, className }: any) {
return (
<div className="card p-5">
<div className="text-sm text-slate-500">{label}</div>
<div className={`text-2xl font-extrabold mt-1 ${className ?? ""}`}>{value}</div>
</div>
);
}
function Usage({ label, used, limit }: { label: string; used: number; limit: number | null }) {
const pct = limit == null ? 8 : Math.min(100, Math.round((used / limit) * 100));
return (
<div>
<div className="flex justify-between text-sm mb-1">
<span className="text-slate-600">{label}</span>
<span className="text-slate-500">{used} / {limit ?? "∞"}</span>
</div>
<div className="h-2 rounded-full bg-slate-100">
<div className="h-full rounded-full bg-brand-500" style={{ width: `${pct}%` }} />
</div>
</div>
);
}

View File

@@ -0,0 +1,888 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import {
Package, CheckCircle2, Clock, AlertTriangle, Calendar, Search,
Plus, Download, Eye, Pencil, Upload, MoreVertical, X,Trash2,
} from "lucide-react";
import { api, apiBase } from "@/lib/api";
import ComplianceDetailModal from "@/components/ComplianceDetailModal";
import jsPDF from "jspdf";
import autoTable from "jspdf-autotable";
function imgSrc(url?: string) {
if (!url) return "";
if (url.startsWith("data:") || url.startsWith("http")) return url;
return `${apiBase}${url}`;
}
const TYPES = ["FSSAI License", "Drug License", "GMP Certificate", "ISO Certificate",
"EPR Registration", "Factory License", "Import Export Code", "Trademark Registration"];
const STATUS: Record<string, string> = {
complete: "bg-emerald-50 text-emerald-700",
pending: "bg-amber-50 text-amber-700",
expiring: "bg-amber-50 text-amber-700",
expired: "bg-rose-50 text-rose-700",
};
export default function Compliance() {
const [rows, setRows] = useState<any[]>([]);
const [s, setS] = useState<any>(null);
const [products, setProducts] = useState<any[]>([]);
const [filters, setFilters] = useState({ search: "", status_filter: "", compliance_type: "", country: "" });
const [detailId, setDetailId] = useState<string | null>(null);
const [menuFor, setMenuFor] = useState<string | null>(null);
const [form, setForm] = useState<any>(null); // add/edit form state (null = closed)
const [page, setPage] = useState(1);
const pageSize = 25;
const [showAlerts, setShowAlerts] = useState(true);
const [deleteRow, setDeleteRow] = useState<any>(null);
const [deleting, setDeleting] = useState(false);
const [selectedRows, setSelectedRows] = useState<string[]>([]);
const [bulkDeleteOpen, setBulkDeleteOpen] = useState(false);
function load() {
const qs = new URLSearchParams(
Object.entries(filters).filter(([, v]) => v) as any
).toString();
api(`/compliance${qs ? "?" + qs : ""}`)
.then(setRows)
.catch(() => setRows([]));
api("/compliance/summary")
.then((data) => {
setS(data);
setShowAlerts(true);
})
.catch(() => {});
}
useEffect(() => { load(); }, [filters]);
useEffect(() => { api("/products").then((d) => setProducts(d.items ?? [])).catch(() => {}); }, []);
const paged = useMemo(() => rows.slice((page - 1) * pageSize, page * pageSize), [rows, page]);
const totalPages = Math.max(1, Math.ceil(rows.length / pageSize));
const set = (k: string, v: string) => { setPage(1); setFilters((f) => ({ ...f, [k]: v })); };
function handleExport() {
const exportRows =
selectedRows.length > 0
? rows.filter((r) => selectedRows.includes(r.id))
: rows;
const headers = [
"Product",
"SKU",
"Compliance Type",
"License Number",
"License Name",
"Issuing Authority",
"Issue Date",
"Expiry Date",
"Days Left",
"Status",
"Country",
"State",
"Category",
"Applicable To",
"Manufacturing Plant",
"Remarks",
"Created By",
"Created On",
"Last Updated",
"Document URL",
];
const csv = [
headers.join(","),
...exportRows.map((r) =>
[
`"${r.product_name ?? ""}"`,
`"${r.sku ?? ""}"`,
`"${r.compliance_type ?? ""}"`,
`"${r.license_number ?? ""}"`,
`"${r.license_name ?? ""}"`,
`"${r.issuing_authority ?? ""}"`,
`"${r.issue_date ?? ""}"`,
`"${r.expiry_date ?? ""}"`,
`"${r.days_left == null
? "-"
: r.days_left < 0
? "Expired"
: r.days_left === 0
? "Today"
: `${r.days_left} Days`
}"`,
`"${r.status ?? ""}"`,
`"${r.country ?? ""}"`,
`"${r.state ?? ""}"`,
`"${r.category ?? ""}"`,
`"${r.applicable_to ?? ""}"`,
`"${r.plant ?? ""}"`,
`"${r.remarks ?? ""}"`,
`"${r.created_by ?? ""}"`,
`"${r.created_at ? new Date(r.created_at).toLocaleString("en-IN") : ""}"`,
`"${r.updated_at ? new Date(r.updated_at).toLocaleString("en-IN") : ""}"`,
`"${r.document_url ?? ""}"`,
].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 =
selectedRows.length > 0
? `selected_compliance_${selectedRows.length}.csv`
: `compliance_${new Date().toISOString().slice(0, 10)}.csv`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
async function handleImport(
e: React.ChangeEvent<HTMLInputElement>
) {
const file = e.target.files?.[0];
if (!file) return;
const formData = new FormData();
formData.append("file", file);
try {
await fetch(`${apiBase}/compliance/import`, {
method: "POST",
body: formData,
headers: {
Authorization: `Bearer ${localStorage.getItem("vp_token")}`,
},
});
load();
alert("Import successful");
} catch {
alert("Import failed");
}
e.target.value = "";
}
async function handlePdfExport() {
const exportRows =
selectedRows.length > 0
? rows.filter((r) => selectedRows.includes(r.id))
: rows;
const doc = new jsPDF({
orientation: "landscape",
unit: "mm",
format: "a4",
});
// ===========================
// Logo
// ===========================
try {
const img = new Image();
img.src = "/logo.png";
await new Promise((resolve) => {
img.onload = resolve;
img.onerror = resolve;
});
doc.addImage(img, "PNG", 14, 10, 18, 18);
} catch {}
// ===========================
// Company Header
// ===========================
doc.setFont("helvetica", "bold");
doc.setFontSize(18);
doc.text("VerifyPack", 38, 18);
doc.setFont("helvetica", "normal");
doc.setFontSize(10);
doc.setTextColor(100);
doc.text("Digital Trust for Every Product", 38, 24);
// ===========================
// Report Title
// ===========================
doc.setDrawColor(220);
doc.line(14, 32, 283, 32);
doc.setTextColor(0);
doc.setFont("helvetica", "bold");
doc.setFontSize(15);
doc.text("Compliance Report", 14, 40);
doc.setFont("helvetica", "normal");
doc.setFontSize(10);
doc.text(
`Generated On : ${new Date().toLocaleString("en-IN")}`,
14,
47
);
doc.text(
`Total Records : ${exportRows.length}`,
250,
47,
{ align: "right" }
);
// ===========================
// Table
// ===========================
autoTable(doc, {
startY: 55,
head: [[
"Product",
"SKU",
"Compliance Type",
"License No",
"Issue Date",
"Expiry Date",
"Days Left",
"Status",
"Country",
"Authority",
]],
body: exportRows.map((r) => [
r.product_name || "",
r.sku || "",
r.compliance_type || "",
r.license_number || "",
r.issue_date
? new Date(r.issue_date).toLocaleDateString("en-IN")
: "-",
r.expiry_date
? new Date(r.expiry_date).toLocaleDateString("en-IN")
: "-",
r.days_left == null
? "-"
: r.days_left < 0
? "Expired"
: r.days_left === 0
? "Today"
: `${r.days_left} Days`,
r.days_left == null
? "Pending"
: r.days_left < 0
? "Expired"
: r.days_left <= 30
? "Expiring"
: "Complete",
r.country || "-",
r.issuing_authority || "-",
]),
styles: {
fontSize: 8,
cellPadding: 2,
},
headStyles: {
fillColor: [37, 99, 235],
textColor: 255,
fontStyle: "bold",
},
alternateRowStyles: {
fillColor: [245, 245, 245],
},
margin: {
left: 14,
right: 14,
},
didDrawPage: (data) => {
doc.setFontSize(9);
doc.setTextColor(120);
doc.text(
`Page ${data.pageNumber}`,
doc.internal.pageSize.getWidth() - 20,
doc.internal.pageSize.getHeight() - 8,
{
align: "right",
}
);
doc.text(
"Generated by VerifyPack",
14,
doc.internal.pageSize.getHeight() - 8
);
},
});
// ===========================
// Save PDF
// ===========================
doc.save(
selectedRows.length
? `selected_compliance_report.pdf`
: `compliance_report.pdf`
);
}
async function confirmDelete() {
if (!deleteRow) return;
try {
setDeleting(true);
await api(`/compliance/${deleteRow.id}`, {
method: "DELETE",
});
setDeleteRow(null);
load();
} catch (err) {
alert("Failed to delete compliance record.");
} finally {
setDeleting(false);
}
}
function toggleRow(id: string) {
setSelectedRows((prev) =>
prev.includes(id)
? prev.filter((x) => x !== id)
: [...prev, id]
);
}
const allSelected =
paged.length > 0 &&
paged.every((r) => selectedRows.includes(r.id));
function toggleAll() {
if (allSelected) {
setSelectedRows((prev) =>
prev.filter((id) => !paged.some((r) => r.id === id))
);
} else {
setSelectedRows((prev) => [
...new Set([...prev, ...paged.map((r) => r.id)]),
]);
}
}
async function deleteSelected() {
try {
setDeleting(true);
await Promise.all(
selectedRows.map((id) =>
api(`/compliance/${id}`, {
method: "DELETE",
})
)
);
setSelectedRows([]);
setBulkDeleteOpen(false);
load();
} finally {
setDeleting(false);
}
}
function openAdd() {
setForm({ product_id: "", compliance_type: TYPES[0], license_number: "", license_name: "",
issuing_authority: "", issue_date: "", expiry_date: "", country: "India", state: "",
category: "", applicable_to: "Manufacturing Plant", plant: "", remarks: "", _id: "" });
}
function openEdit(r: any) {
setDetailId(null);
setForm({ ...r, _id: r.id, product_id: r.product_id ?? "",
issue_date: r.issue_date ? r.issue_date.slice(0, 10) : "",
expiry_date: r.expiry_date ? r.expiry_date.slice(0, 10) : "" });
}
async function saveForm() {
const { _id, ...payload } = form;
if (_id) await api(`/compliance/${_id}`, { method: "PUT", body: JSON.stringify(payload) });
else await api("/compliance", { method: "POST", body: JSON.stringify(payload) });
setForm(null); load();
}
return (
<div className="space-y-5" onClick={() => setMenuFor(null)}>
<div className="flex items-center justify-between">
<h2 className="text-xl font-bold">Compliance Center</h2>
</div>
{/* KPIs */}
<div className="grid grid-cols-2 lg:grid-cols-5 gap-4">
<Kpi label="Total Products" value={s?.total_products ?? 0} sub="All products" icon={<Package className="h-5 w-5" />} tint="bg-blue-50 text-blue-600" />
<Kpi label="Complete" value={s?.complete ?? 0} sub={`${s?.complete_pct ?? 0}% of total`} subColor="text-emerald-600" icon={<CheckCircle2 className="h-5 w-5" />} tint="bg-emerald-50 text-emerald-600" />
<Kpi label="Pending" value={s?.pending ?? 0} sub={`${s?.pending_pct ?? 0}% of total`} subColor="text-amber-600" icon={<Clock className="h-5 w-5" />} tint="bg-amber-50 text-amber-600" />
<Kpi label="Expired" value={s?.expired ?? 0} sub={`${s?.expired_pct ?? 0}% of total`} subColor="text-rose-600" icon={<AlertTriangle className="h-5 w-5" />} tint="bg-rose-50 text-rose-500" />
<Kpi label="Upcoming Expiry" value={s?.expiring ?? 0} sub="Next 30 days" icon={<Calendar className="h-5 w-5" />} tint="bg-violet-50 text-violet-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 compliance…"
className="pl-9 pr-3 py-2 w-52 rounded-lg border border-slate-200 text-sm" />
</div>
<Filter label="Status" value={filters.status_filter} onChange={(v) => set("status_filter", v)} opts={["complete", "pending", "expiring", "expired"].map((x) => ({ value: x, label: x }))} all="All Status" />
<Filter label="Type" value={filters.compliance_type} onChange={(v) => set("compliance_type", v)} opts={TYPES.map((t) => ({ value: t, label: t }))} all="All Types" />
<Filter label="Country" value={filters.country} onChange={(v) => set("country", v)} opts={[{ value: "India", label: "India" }]} all="All Countries" />
</div>
{/* Alerts */}
{/* Alerts */}
{showAlerts && s?.alerts?.length > 0 && (
<div className="card p-4 bg-amber-50 border border-amber-200 rounded-xl relative">
<button
onClick={() => setShowAlerts(false)}
className="absolute top-3 right-3 rounded-md p-1 text-amber-700 hover:bg-amber-100"
>
<X className="h-4 w-4" />
</button>
<div className="font-semibold text-amber-800 text-sm flex items-center gap-2 mb-2">
<AlertTriangle className="h-4 w-4" />
Attention needed
</div>
<div className="space-y-1">
{s.alerts.map((a: any, i: number) => (
<div key={i} className="text-sm text-amber-700">
{a.type}
{a.number ? ` (${a.number})` : ""}
{" — "}
{a.status}
{a.expiry
? ` · expires ${new Date(a.expiry).toLocaleDateString()}`
: ""}
</div>
))}
</div>
</div>
)}
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<div className="text-sm text-slate-500">
Total: {rows.length}
</div>
{/* Import */}
<label className="flex items-center gap-2 px-4 py-2 rounded-lg border border-slate-200 bg-white hover:bg-slate-50 cursor-pointer text-sm font-medium">
<Upload className="h-4 w-4" />
Import
<input
type="file"
accept=".csv,.xlsx,.xls,.json"
className="hidden"
onChange={handleImport}
/>
</label>
{/* Export */}
<button
onClick={handleExport}
className="flex items-center gap-2 px-4 py-2 rounded-lg border border-slate-200 bg-white hover:bg-slate-50 text-sm font-medium"
>
<Download className="h-4 w-4" />
{selectedRows.length > 0
? `Export Selected (${selectedRows.length})`
: "Export"}
</button>
<button
onClick={handlePdfExport}
className="flex items-center gap-2 rounded-lg border border-slate-200 bg-white px-4 py-2 text-sm font-medium hover:bg-slate-50"
>
<Download className="h-4 w-4" />
{selectedRows.length
? `PDF (${selectedRows.length})`
: "PDF Report"}
</button>
{/* Add */}
<button
onClick={openAdd}
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" />
Add Compliance
</button>
</div>
{selectedRows.length > 0 && (
<div className="flex items-center gap-3">
<span className="text-sm font-medium text-brand-600">
{selectedRows.length} Selected
</span>
<button
onClick={() => setBulkDeleteOpen(true)}
className="rounded-lg bg-red-600 px-4 py-2 text-sm text-white"
>
Delete Selected
</button>
</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-4 py-3">
<input
type="checkbox"
checked={allSelected}
onChange={toggleAll}
/>
</th>
<th className="px-4 py-3 font-medium">Product</th>
<th className="px-4 py-3 font-medium">Type</th>
<th className="px-4 py-3 font-medium">License / Reg.</th>
<th className="px-4 py-3 font-medium">Issue Date</th>
<th className="px-4 py-3 font-medium">Expiry Date</th>
<th className="px-4 py-3 font-medium">Status</th>
<th className="px-4 py-3 font-medium">Country</th>
<th className="px-4 py-3 font-medium">Last Updated</th>
<th className="px-4 py-3 font-medium">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-100">
{paged.map((c) => (
<tr key={c.id} className="hover:bg-slate-50">
<td className="px-4 py-3">
<input
type="checkbox"
checked={selectedRows.includes(c.id)}
onChange={() => toggleRow(c.id)}
/>
</td>
<td className="px-4 py-3">
<div className="flex items-center gap-3">
{c.product_image
? <img src={imgSrc(c.product_image)} className="h-10 w-10 rounded object-contain border border-slate-100" />
: <div className="h-10 w-10 rounded bg-slate-100" />}
<div>
<div className="font-medium text-slate-800">{c.product_name ?? "—"}</div>
<div className="text-xs text-slate-400">{c.sku ?? ""}</div>
</div>
</div>
</td>
<td className="px-4 py-3">{c.compliance_type}</td>
<td className="px-4 py-3 font-mono text-slate-600">{c.license_number ?? "—"}</td>
<td className="px-4 py-3 text-slate-500">{c.issue_date ? new Date(c.issue_date).toLocaleDateString() : "—"}</td>
<td className="px-4 py-3">
{c.expiry_date ? (
<div>
<div className={c.days_left != null && c.days_left < 0 ? "text-rose-600" : c.days_left != null && c.days_left < 30 ? "text-amber-600" : "text-slate-700"}>
{new Date(c.expiry_date).toLocaleDateString()}
</div>
{c.days_left != null && <div className={`text-xs ${c.days_left < 0 ? "text-rose-500" : "text-slate-400"}`}>
{c.days_left < 0 ? "(Expired)" : `(In ${c.days_left} days)`}
</div>}
</div>
) : "—"}
</td>
<td className="px-4 py-3"><span className={`text-xs font-semibold px-2 py-0.5 rounded-full capitalize ${STATUS[c.status] ?? "bg-slate-100"}`}>{c.status}</span></td>
<td className="px-4 py-3">{c.country ?? "—"}</td>
<td className="px-4 py-3 text-slate-500">
{c.updated_at ? (
<div>
<div>
{new Date(c.updated_at).toLocaleDateString("en-IN")}
</div>
<div className="text-xs text-slate-400">
{new Date(c.updated_at).toLocaleTimeString("en-IN", {
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
})}
</div>
</div>
) : (
"—"
)}
</td>
<td className="px-4 py-3 relative">
<div className="flex items-center gap-1">
<button title="View" onClick={() => setDetailId(c.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 === c.id ? null : c.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 === c.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">
<button onClick={() => { openEdit(c); setMenuFor(null); }} className="w-full flex items-center gap-2 px-3 py-2 text-sm text-slate-700 hover:bg-slate-50"><Pencil className="h-4 w-4" /> Edit</button>
<button onClick={() => { setDetailId(c.id); setMenuFor(null); }} className="w-full flex items-center gap-2 px-3 py-2 text-sm text-slate-700 hover:bg-slate-50"><Upload className="h-4 w-4" /> Upload Document</button>
<div className="my-1 border-t border-slate-100"></div>
<button
onClick={() => {
setDeleteRow(c);
setMenuFor(null);
}}
className="w-full flex items-center gap-2 px-3 py-2 text-sm text-red-600 hover:bg-red-50"
>
<Trash2 className="h-4 w-4" />
Delete
</button>
</div>
)}
</td>
</tr>
))}
{paged.length === 0 && <tr><td colSpan={8} className="px-4 py-12 text-center text-slate-400">No compliance records.</td></tr>}
</tbody>
</table>
<div className="flex items-center justify-between px-4 py-3 text-sm text-slate-500">
<span>Showing {rows.length === 0 ? 0 : (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>
{/* Detail modal */}
{detailId && (
<ComplianceDetailModal cid={detailId}
onClose={() => setDetailId(null)}
onEdit={() => { const r = rows.find((x) => x.id === detailId); if (r) openEdit(r); }}
onChanged={load} />
)}
{deleteRow && (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4"
onClick={() => setDeleteRow(null)}
>
<div
onClick={(e) => e.stopPropagation()}
className="w-full max-w-md rounded-2xl bg-white shadow-xl"
>
<div className="border-b px-6 py-4">
<h2 className="text-lg font-bold text-slate-900">
Delete Compliance
</h2>
</div>
<div className="p-6 space-y-4">
<div className="flex items-center gap-4">
{deleteRow.product_image ? (
<img
src={imgSrc(deleteRow.product_image)}
className="h-16 w-16 rounded-lg border object-contain"
/>
) : (
<div className="h-16 w-16 rounded-lg bg-slate-100" />
)}
<div>
<div className="font-semibold text-slate-900">
{deleteRow.product_name}
</div>
<div className="text-sm text-slate-500">
{deleteRow.compliance_type}
</div>
<div className="text-xs text-slate-400">
{deleteRow.license_number || "No License Number"}
</div>
</div>
</div>
<div className="rounded-lg border border-red-200 bg-red-50 p-4">
<p className="text-sm text-red-700">
Are you sure you want to delete this compliance record?
</p>
<p className="mt-2 text-xs text-red-600">
This action cannot be undone.
</p>
</div>
</div>
<div className="flex justify-end gap-3 border-t px-6 py-4">
<button
onClick={() => setDeleteRow(null)}
className="rounded-lg border border-slate-200 px-4 py-2 text-sm"
>
Cancel
</button>
<button
onClick={confirmDelete}
disabled={deleting}
className="rounded-lg bg-red-600 px-5 py-2 text-sm font-semibold text-white hover:bg-red-700 disabled:opacity-50"
>
{deleting ? "Deleting..." : "Delete"}
</button>
</div>
</div>
</div>
)}
{bulkDeleteOpen && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
<div className="w-full max-w-md rounded-xl bg-white shadow-xl">
<div className="border-b px-6 py-4">
<h2 className="text-lg font-bold">
Delete Compliance Records
</h2>
</div>
<div className="p-6">
<p className="text-slate-700">
Are you sure you want to delete
<span className="font-bold text-red-600">
{" "}
{selectedRows.length} compliance record
{selectedRows.length > 1 ? "s" : ""}
</span>
?
</p>
<p className="mt-2 text-sm text-slate-500">
This action cannot be undone.
</p>
</div>
<div className="flex justify-end gap-3 border-t px-6 py-4">
<button
onClick={() => setBulkDeleteOpen(false)}
className="rounded-lg border px-4 py-2"
>
Cancel
</button>
<button
onClick={deleteSelected}
disabled={deleting}
className="rounded-lg bg-red-600 px-5 py-2 text-white"
>
{deleting
? "Deleting..."
: `Delete (${selectedRows.length})`}
</button>
</div>
</div>
</div>
)}
{/* Add/Edit form modal */}
{form && (
<div className="fixed inset-0 bg-black/40 z-50 grid place-items-center p-4 overflow-y-auto" onClick={() => setForm(null)}>
<div className="bg-white rounded-2xl w-full max-w-lg my-4 max-h-[92vh] overflow-y-auto" onClick={(e) => e.stopPropagation()}>
<div className="flex items-center justify-between px-5 py-4 border-b border-slate-100 sticky top-0 bg-white rounded-t-2xl">
<h3 className="font-bold">{form._id ? "Edit Compliance" : "Add Compliance"}</h3>
<button onClick={() => setForm(null)}><X className="h-5 w-5 text-slate-400" /></button>
</div>
<div className="p-5 grid grid-cols-2 gap-3">
<Fld label="Product" full>
<select value={form.product_id} onChange={(e) => setForm({ ...form, product_id: e.target.value })} className="input">
<option value="">Select product</option>
{products.map((p) => <option key={p.id} value={p.id}>{p.name}</option>)}
</select>
</Fld>
<Fld label="Compliance Type">
<select value={form.compliance_type} onChange={(e) => setForm({ ...form, compliance_type: e.target.value })} className="input">
{TYPES.map((t) => <option key={t}>{t}</option>)}
</select>
</Fld>
<Inp label="License / Reg. No." v={form.license_number} on={(x) => setForm({ ...form, license_number: x })} />
<Inp label="License Name" v={form.license_name} on={(x) => setForm({ ...form, license_name: x })} />
<Inp label="Issuing Authority" v={form.issuing_authority} on={(x) => setForm({ ...form, issuing_authority: x })} />
<Inp label="Issue Date" type="date" v={form.issue_date} on={(x) => setForm({ ...form, issue_date: x })} />
<Inp label="Expiry Date" type="date" v={form.expiry_date} on={(x) => setForm({ ...form, expiry_date: x })} />
<Inp label="Country" v={form.country} on={(x) => setForm({ ...form, country: x })} />
<Inp label="State" v={form.state} on={(x) => setForm({ ...form, state: x })} />
<Inp label="Category" v={form.category} on={(x) => setForm({ ...form, category: x })} />
<Inp label="Applicable To" v={form.applicable_to} on={(x) => setForm({ ...form, applicable_to: x })} />
<Inp label="Manufacturing Plant" v={form.plant} on={(x) => setForm({ ...form, plant: x })} />
<Inp label="Remarks" full v={form.remarks} on={(x) => setForm({ ...form, remarks: x })} />
</div>
<div className="flex justify-end gap-3 px-5 py-4 border-t border-slate-100 sticky bottom-0 bg-white">
<button onClick={() => setForm(null)} className="px-5 py-2 rounded-lg border border-slate-200 text-sm font-medium">Cancel</button>
<button onClick={saveForm} className="px-6 py-2 rounded-lg bg-brand-600 text-white text-sm font-semibold">Save</button>
</div>
</div>
</div>
)}
<style>{`.input{margin-top:.25rem;width:100%;padding:.5rem .75rem;border-radius:.5rem;border:1px solid #e2e8f0;font-size:.875rem}`}</style>
</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] capitalize">
<option value="">{all}</option>
{opts.map((o) => <option key={o.value} value={o.value}>{o.label}</option>)}
</select>
</label>
);
}
function Fld({ label, children, full }: any) {
return <label className={`block ${full ? "col-span-2" : ""}`}><span className="text-sm font-medium">{label}</span>{children}</label>;
}
function Inp({ label, v, on, type = "text", full }: { label: string; v: any; on: (x: string) => void; type?: string; full?: boolean }) {
return (
<label className={`block ${full ? "col-span-2" : ""}`}>
<span className="text-sm font-medium">{label}</span>
<input type={type} value={v ?? ""} onChange={(e) => on(e.target.value)} className="input" />
</label>
);
}

View File

@@ -0,0 +1,143 @@
"use client";
import { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import { Bell, HelpCircle, Search, Menu } from "lucide-react";
import Sidebar from "@/components/Sidebar";
import { api, clearToken } from "@/lib/api";
import { useRef } from "react";
import { LogOut } from "lucide-react";
export default function DashboardLayout({ children }: { children: React.ReactNode }) {
const router = useRouter();
const [me, setMe] = useState<any>(null);
const [org, setOrg] = useState<any>(null);
const [ready, setReady] = useState(false);
const [navOpen, setNavOpen] = useState(false);
const [profileOpen, setProfileOpen] = useState(false);
const profileRef = useRef<HTMLDivElement>(null);
function logout() {
clearToken();
router.replace("/login");
}
useEffect(() => {
function handleClick(e: MouseEvent) {
if (
profileRef.current &&
!profileRef.current.contains(e.target as Node)
) {
setProfileOpen(false);
}
}
document.addEventListener("mousedown", handleClick);
return () =>
document.removeEventListener("mousedown", handleClick);
}, []);
useEffect(() => {
api("/me")
.then((m) => {
setMe(m);
// Product Admins don't belong here — send them to the Super Admin Panel.
if (m.is_product_admin) {
router.replace("/admin");
return;
}
if (!m.has_org) {
router.replace("/onboarding");
return;
}
api("/organization").then(setOrg).catch(() => {});
setReady(true);
})
.catch((e) => {
clearToken();
router.replace("/login");
});
}, [router]);
if (!ready)
return <div className="min-h-screen grid place-items-center text-slate-400">Loading</div>;
const initial = (me?.name?.[0] ?? "U").toUpperCase();
return (
<div className="flex min-h-screen bg-slate-50">
<Sidebar org={org} open={navOpen} onClose={() => setNavOpen(false)} />
<div className="flex-1 min-w-0">
<header className="sticky top-0 z-10 bg-slate-50/80 backdrop-blur px-4 md:px-8 py-4 flex items-center gap-4">
<button onClick={() => setNavOpen(true)} className="md:hidden h-10 w-10 grid place-items-center rounded-full bg-white border border-slate-200">
<Menu className="h-4 w-4 text-slate-600" />
</button>
<div className="flex-1">
<h1 className="text-lg font-bold text-slate-900">
Welcome back, {me?.name ?? "there"}! 👋
</h1>
<p className="text-sm text-slate-500">
Here&apos;s what&apos;s happening with your products today.
</p>
</div>
<div className="relative hidden md:block">
<Search className="absolute left-3 top-2.5 h-4 w-4 text-slate-400" />
<input placeholder="Search anything..."
className="pl-9 pr-4 py-2 w-64 rounded-full border border-slate-200 bg-white text-sm
focus:outline-none focus:ring-2 focus:ring-brand-500/30" />
</div>
<button className="relative h-10 w-10 grid place-items-center rounded-full bg-white border border-slate-200">
<Bell className="h-4 w-4 text-slate-600" />
</button>
<button className="h-10 w-10 grid place-items-center rounded-full bg-white border border-slate-200">
<HelpCircle className="h-4 w-4 text-slate-600" />
</button>
<div className="relative" ref={profileRef}>
<button
onClick={() => setProfileOpen(!profileOpen)}
className="flex items-center gap-2 pl-2 rounded-lg hover:bg-slate-100 px-2 py-1"
>
<div className="h-9 w-9 rounded-full bg-brand-500 text-white grid place-items-center font-semibold text-sm">
{initial}
</div>
<div className="text-sm leading-tight hidden sm:block text-left">
<div className="font-semibold text-slate-800">
{me?.name}
</div>
<div className="text-[11px] text-slate-400 capitalize">
{me?.client_role}
</div>
</div>
</button>
{profileOpen && (
<div className="absolute right-0 mt-2 w-72 bg-white rounded-xl shadow-lg border border-slate-200 overflow-hidden z-50">
<div className="px-4 py-4">
<div className="font-semibold text-slate-900">
{me?.name}
</div>
<div className="text-sm text-slate-500 mt-1">
{me?.email}
</div>
</div>
<div className="border-t" />
<button
onClick={logout}
className="w-full flex items-center gap-2 px-4 py-3 hover:bg-red-50 text-red-600 font-medium transition-colors"
>
<LogOut className="h-4 w-4" />
Logout
</button>
</div>
)}
</div>
</header>
<main className="px-4 md:px-8 pb-10">{children}</main>
</div>
</div>
);
}

View File

@@ -0,0 +1,179 @@
"use client";
import { useEffect, useState } from "react";
import Link from "next/link";
import {
Package, QrCode, Boxes, ScanLine, ShieldCheck, CheckCircle2, FileText, Bell,
Plus, BarChart3, Layers, ArrowUpRight,
} from "lucide-react";
import { KpiCard, KpiSkeleton } from "@/components/KpiCard";
import ScanTrendChart from "@/components/ScanTrendChart";
import { api } from "@/lib/api";
const INDIA_DOTS = [
[62, 42], [55, 52], [48, 30], [58, 70], [60, 58], [44, 46],
];
export default function DashboardPage() {
const [s, setS] = useState<any>(null);
const [trend, setTrend] = useState<any[]>([]);
const [topP, setTopP] = useState<any[]>([]);
const [topL, setTopL] = useState<any[]>([]);
const [activity, setActivity] = useState<any[]>([]);
useEffect(() => {
api("/dashboard/summary").then(setS).catch(() => {});
api("/dashboard/scan-trend?days=7").then(setTrend).catch(() => {});
api("/dashboard/top-products").then(setTopP).catch(() => {});
api("/dashboard/top-locations").then(setTopL).catch(() => {});
api("/dashboard/recent-activity").then(setActivity).catch(() => {});
}, []);
const maxP = Math.max(1, ...topP.map((p) => p.scans));
return (
<div className="space-y-5">
{/* KPI row 1 */}
<div className="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-4 gap-4">
{!s ? (
[...Array(4)].map((_, i) => <KpiSkeleton key={i} />)
) : (
<>
<KpiCard title="Products" value={s.products} sub={`Limit ${s.product_limit ?? "∞"}`}
icon={<Package className="h-5 w-5" />} tint="bg-blue-50 text-blue-600" />
<KpiCard title="Active QR Codes" value={s.active_qr} sub="100% Active"
icon={<QrCode className="h-5 w-5" />} tint="bg-emerald-50 text-emerald-600" />
<KpiCard title="Total Batches" value={s.batches} sub="across all products" subColor="text-slate-400"
icon={<Boxes className="h-5 w-5" />} tint="bg-violet-50 text-violet-600" />
<KpiCard title="Today's Scans" value={s.scans_today} sub="↑ 12.5% vs yesterday"
icon={<ScanLine className="h-5 w-5" />} tint="bg-orange-50 text-orange-500" />
</>
)}
</div>
{/* KPI row 2 */}
<div className="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-4 gap-4">
{!s ? (
[...Array(4)].map((_, i) => <KpiSkeleton key={i} />)
) : (
<>
<KpiCard title="Trust Score" value={`${s.trust_score_avg}%`} sub="Excellent"
icon={<ShieldCheck className="h-5 w-5" />} tint="bg-blue-50 text-blue-600" />
<KpiCard title="Active QRs" value={s.active_qr} sub="Live & scannable"
subColor="text-slate-400" icon={<CheckCircle2 className="h-5 w-5" />}
tint="bg-emerald-50 text-emerald-600" />
<KpiCard title="Storage Used"
value={`${s.storage_used_gb} GB`} sub={`of ${s.storage_total_gb} GB`}
subColor="text-slate-400" icon={<FileText className="h-5 w-5" />}
tint="bg-amber-50 text-amber-500" />
<KpiCard title="Pending Tasks" value={5} sub="View All" subColor="text-blue-600"
icon={<Bell className="h-5 w-5" />} tint="bg-rose-50 text-rose-500" />
</>
)}
</div>
{/* Charts row */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4">
<div className="card p-5 lg:col-span-1">
<div className="flex items-center justify-between mb-2">
<h3 className="font-bold text-slate-800">Scan Trend</h3>
<span className="text-xs text-slate-500 border border-slate-200 rounded-lg px-2 py-1">This Week</span>
</div>
<ScanTrendChart data={trend} />
</div>
<div className="card p-5">
<h3 className="font-bold text-slate-800 mb-4">Top Products (By Scans)</h3>
<div className="space-y-3">
{topP.map((p) => (
<div key={p.name} className="flex items-center gap-3">
<div className="w-28 text-sm text-slate-600 truncate">{p.name}</div>
<div className="flex-1 h-3 rounded-full bg-slate-100 overflow-hidden">
<div className="h-full rounded-full bg-blue-400"
style={{ width: `${(p.scans / maxP) * 100}%` }} />
</div>
<div className="w-8 text-sm font-semibold text-slate-700 text-right">{p.scans}</div>
</div>
))}
{topP.length === 0 && <div className="text-sm text-slate-400">No scan data yet.</div>}
</div>
<Link href="/dashboard/products" className="text-blue-600 text-sm font-medium mt-4 inline-block">
View All Products
</Link>
</div>
<div className="card p-5">
<h3 className="font-bold text-slate-800 mb-4">Top Locations (By Scans)</h3>
<div className="flex gap-4">
<div className="relative w-24 h-32 rounded-lg bg-blue-50/70 shrink-0">
{INDIA_DOTS.map(([x, y], i) => (
<span key={i} className="absolute h-2 w-2 rounded-full bg-blue-500"
style={{ left: `${x}%`, top: `${y}%` }} />
))}
</div>
<ol className="flex-1 space-y-2 text-sm">
{topL.map((l, i) => (
<li key={l.city} className="flex justify-between">
<span className="text-slate-600">{i + 1}. {l.city}</span>
<span className="font-semibold text-slate-700">{l.scans}</span>
</li>
))}
{topL.length === 0 && <li className="text-slate-400">No locations yet.</li>}
</ol>
</div>
</div>
</div>
{/* Activity + Quick actions */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
<div className="card p-5">
<div className="flex items-center justify-between mb-3">
<h3 className="font-bold text-slate-800">Recent Activity</h3>
<Link href="/dashboard/analytics" className="text-blue-600 text-sm font-medium">View All</Link>
</div>
<div className="divide-y divide-slate-100">
{activity.map((a, i) => (
<div key={i} className="flex items-center gap-3 py-3">
<div className="h-8 w-8 rounded-lg bg-blue-50 text-blue-600 grid place-items-center">
<Layers className="h-4 w-4" />
</div>
<div className="flex-1 min-w-0">
<div className="text-sm font-medium text-slate-800 capitalize">
{a.action?.replace(".", " ")}
</div>
<div className="text-xs text-slate-500 truncate">{a.details}</div>
</div>
<div className="text-right text-xs text-slate-400">
<div className="text-slate-600">{a.user}</div>
<div>{a.at ? new Date(a.at).toLocaleString() : ""}</div>
</div>
</div>
))}
{activity.length === 0 && <div className="text-sm text-slate-400 py-6">No recent activity.</div>}
</div>
</div>
<div className="card p-5">
<h3 className="font-bold text-slate-800 mb-4">Quick Actions</h3>
<div className="grid grid-cols-3 gap-3">
<QuickAction href="/dashboard/products/new" icon={<Plus className="h-5 w-5" />} label="Add Product" tint="bg-blue-600 text-white" />
<QuickAction href="/dashboard/qr" icon={<QrCode className="h-5 w-5" />} label="Generate QR" tint="bg-emerald-50 text-emerald-600" />
<QuickAction href="/dashboard/products" icon={<Boxes className="h-5 w-5" />} label="Add Batch" tint="bg-violet-50 text-violet-600" />
<QuickAction href="/dashboard/analytics" icon={<BarChart3 className="h-5 w-5" />} label="View Analytics" tint="bg-amber-50 text-amber-600" />
<QuickAction href="/dashboard/compliance" icon={<ShieldCheck className="h-5 w-5" />} label="Compliance" tint="bg-rose-50 text-rose-500" />
<QuickAction href="/dashboard/settings" icon={<ArrowUpRight className="h-5 w-5" />} label="Settings" tint="bg-slate-100 text-slate-600" />
</div>
</div>
</div>
</div>
);
}
function QuickAction({ href, icon, label, tint }: any) {
return (
<Link href={href}
className="rounded-xl border border-slate-100 p-4 flex flex-col items-center gap-2 hover:shadow-sm transition text-center">
<div className={`h-12 w-12 rounded-full grid place-items-center ${tint}`}>{icon}</div>
<span className="text-xs font-medium text-slate-600">{label}</span>
</Link>
);
}

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

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

View File

@@ -0,0 +1,544 @@
"use client";
import { Suspense, useEffect, useState } from "react";
import Link from "next/link";
import { useSearchParams } from "next/navigation";
import {
Building2, Bell, CreditCard, FileText, Save, CheckCircle2, Users as UsersIcon,
} from "lucide-react";
import { api } from "@/lib/api";
import { Package } from "lucide-react";
import UsersPanel from "@/components/UsersPanel";
import BrandsPanel from "@/components/BrandsPanel";
import countryList from "react-select-country-list";
import TimezoneSelect from "react-timezone-select";
import { T, detectLang } from "@/lib/i18n";
import { useLanguage } from "@/contexts/LanguageContext";
import { apiBase } from "@/lib/api";
import { Download } from "lucide-react";
const countries = countryList().getData();
export const LANGS = [
{ code: "en", label: "English" },
{ code: "hi", label: "हिन्दी" },
{ code: "kn", label: "ಕನ್ನಡ" },
{ code: "ta", label: "தமிழ்" },
{ code: "bn", label: "বাংলা" },
];
function SettingsInner() {
const sp = useSearchParams();
const initial = sp.get("section") ?? "org";
const [active, setActive] = useState(initial);
const [org, setOrg] = useState<any>(null);
const [form, setForm] = useState<any>({});
const [saved, setSaved] = useState(false);
const [sub, setSub] = useState<any>(null);
const [logs, setLogs] = useState<any[]>([]);
const [notif, setNotif] = useState({ email: true, system: true, compliance: true, security: true });
const { t, setLang } = useLanguage();
const SECTIONS = [
{ id: "org", label: t.organization, icon: Building2 },
{ id: "users", label: t.users, icon: UsersIcon },
{ id: "brands", label: t.brands, icon: Package },
{ id: "notifications", label: t.notifications, icon: Bell },
{ id: "subscription", label: t.subscription, icon: CreditCard },
{ id: "audit", label: t.audit, icon: FileText },
];
const [logoPreview, setLogoPreview] = useState("");
const [logoFileName, setLogoFileName] = useState("");
const [filters, setFilters] = useState({
user: "",
module: "",
from: "",
to: "",
});
function downloadAuditLogs() {
if (!logs.length) return;
const rows = logs.map((l) => ({
Time: l.at
? new Date(l.at).toLocaleString(form.language || "en", {
timeZone: form.timezone || "Asia/Kolkata",
})
: "",
User: l.user,
Role: l.role,
Action: l.action,
Module: l.module,
Details: l.details,
IP: l.ip ?? "",
}));
const headers = Object.keys(rows[0]);
const csv = [
headers.join(","),
...rows.map((r) =>
headers
.map((h) => `"${String((r as any)[h] ?? "").replace(/"/g, '""')}"`)
.join(",")
),
].join("\n");
const blob = new Blob([csv], {
type: "text/csv;charset=utf-8;",
});
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = `audit_logs_${new Date()
.toISOString()
.slice(0, 10)}.csv`;
link.click();
URL.revokeObjectURL(url);
}
async function loadLogs() {
const params = new URLSearchParams();
if (filters.user) params.append("user", filters.user);
if (filters.module) params.append("module", filters.module);
if (filters.from) params.append("from_date", filters.from);
if (filters.to) params.append("to_date", filters.to);
const data = await api(`/audit-logs?${params.toString()}`);
setLogs(data);
}
useEffect(() => {
api("/organization").then((o) => {
setOrg(o);
setLogoPreview(o.logo_url || "");
localStorage.setItem("timezone", o.timezone);
setForm({ name: o.name, gstin: o.gstin ?? "", pan: o.pan ?? "",address: o.address ?? "", phone: o.phone ?? "",logo_url:o.logo_url ?? "",
website: o.website ?? "", timezone: o.timezone ?? "Asia/Kolkata", language: o.language ?? "en", contact_email: o.contact_email ?? "",country: o.country ?? "",});
}).catch(() => {});
api("/billing/subscription").then(setSub).catch(() => {});
loadLogs();
}, []);
async function uploadLogo(file: File) {
const fd = new FormData();
fd.append("file", file);
const token = localStorage.getItem("vp_token");
const res = await fetch(`${apiBase}/brands/upload-logo`, {
method: "POST",
headers: {
Authorization: `Bearer ${token}`,
},
body: fd,
});
const data = await res.json();
setLogoPreview(data.url);
setLogoFileName(file.name);
setForm({
...form,
logo_url: data.url,
});
}
async function saveOrg() {
await api("/organization", { method: "PUT", body: JSON.stringify(form) });
setLang(form.language);
localStorage.setItem("timezone", form.timezone);
setSaved(true);
setTimeout(() => setSaved(false), 2000);
}
useEffect(() => {
loadLogs();
}, [filters]);
return (
<div className="space-y-5">
<h2 className="text-xl font-bold">{t.settings}</h2>
<div className="grid md:grid-cols-[200px_1fr] gap-5">
<nav className="space-y-1">
{SECTIONS.map(({ id, label, icon: Icon }) => (
<button key={id} onClick={() => setActive(id)}
className={`w-full flex items-center gap-2 px-3 py-2 rounded-lg text-sm font-medium ${active === id ? "bg-brand-50 text-brand-700" : "text-slate-600 hover:bg-slate-50"}`}>
<Icon className="h-4 w-4" /> {label}
</button>
))}
</nav>
<div>
{active === "users" && <UsersPanel />}
{active === "brands" && <BrandsPanel />}
{active === "org" && (
<div className="card p-5 space-y-4 max-w-3xl">
<h3 className="font-bold">{t.organization}</h3>
{saved && <div className="text-sm text-emerald-600 flex items-center gap-1"><CheckCircle2 className="h-4 w-4" /> Saved</div>}
<div className="flex items-center gap-6 mb-6">
<div className="grid lg:grid-cols-[260px_1fr] gap-8">
{/* LEFT SIDE */}
<div className="border rounded-xl p-6 bg-slate-50 flex flex-col items-center">
<div className="w-40 h-40 rounded-xl border bg-white overflow-hidden flex items-center justify-center">
{logoPreview ? (
<img
src={logoPreview}
alt="Organization Logo"
className="w-full h-full object-contain"
/>
) : (
<span className="text-slate-400 text-sm">
No Logo
</span>
)}
</div>
<label className="mt-5 w-full">
<input
type="file"
accept="image/*"
className="hidden"
onChange={(e) => {
if (e.target.files?.[0]) {
uploadLogo(e.target.files[0]);
}
}}
/>
<div className="cursor-pointer text-center bg-brand-600 text-white rounded-lg py-2 font-medium hover:bg-brand-700">
Upload Logo
</div>
</label>
{logoFileName && (
<p className="mt-3 text-xs text-slate-500 break-all text-center">
{logoFileName}
</p>
)}
</div>
{/* RIGHT SIDE */}
<div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<Field
label="Organization Name"
value={form.name}
onChange={(v:string)=>setForm({...form,name:v})}
/>
<Field
label="Website"
value={form.website}
onChange={(v:string)=>setForm({...form,website:v})}
/>
<Field
label="GSTIN"
value={form.gstin}
onChange={(v:string)=>setForm({...form,gstin:v})}
/>
<Field
label="PAN"
value={form.pan}
onChange={(v:string)=>setForm({...form,pan:v})}
/>
<Field
label="Phone"
value={form.phone}
onChange={(v:string)=>setForm({...form,phone:v})}
/>
<Field
label="Contact Email"
value={form.contact_email}
onChange={(v:string)=>setForm({...form,contact_email:v})}
/>
<label>
<span className="text-sm font-medium">Country</span>
<select
value={form.country}
onChange={(e)=>setForm({...form,country:e.target.value})}
className="mt-1 w-full border rounded-lg px-3 py-2"
>
{countries.map((c)=>(
<option key={c.value} value={c.label}>
{c.label}
</option>
))}
</select>
</label>
<label>
<span className="text-sm font-medium">Language</span>
<select
value={form.language}
onChange={(e)=>setForm({...form,language:e.target.value})}
className="mt-1 w-full border rounded-lg px-3 py-2"
>
{LANGS.map((l)=>(
<option key={l.code} value={l.code}>
{l.label}
</option>
))}
</select>
</label>
<div className="md:col-span-2">
<label>
<span className="text-sm font-medium">Timezone</span>
<TimezoneSelect
value={form.timezone}
onChange={(tz:any)=>
setForm({
...form,
timezone:tz.value,
})
}
/>
</label>
</div>
<div className="md:col-span-2">
<Field
label="Address"
value={form.address}
onChange={(v:string)=>setForm({...form,address:v})}
/>
</div>
</div>
</div>
</div>
</div>
<button onClick={saveOrg} className="flex items-center gap-2 bg-brand-600 text-white px-5 py-2 rounded-lg text-sm font-semibold">
<Save className="h-4 w-4" /> {t.save}
</button>
</div>
)}
{active === "notifications" && (
<div className="card p-5 space-y-4 max-w-xl">
<h3 className="font-bold">{t.notifications}</h3>
{[
["email", "Email notifications"],
["system", "System alerts"],
["compliance", "Compliance expiry alerts"],
["security", "Security alerts"],
].map(([key, label]) => (
<div key={key} className="flex items-center justify-between py-2 border-b border-slate-100">
<span className="text-sm text-slate-700">{label}</span>
<button onClick={() => setNotif({ ...notif, [key]: !(notif as any)[key] })}
className={`w-11 h-6 rounded-full relative transition ${(notif as any)[key] ? "bg-emerald-500" : "bg-slate-300"}`}>
<span className={`absolute top-0.5 h-5 w-5 rounded-full bg-white transition ${(notif as any)[key] ? "left-5" : "left-0.5"}`} />
</button>
</div>
))}
<p className="text-xs text-slate-400">Preferences are illustrative in this build.</p>
</div>
)}
{active === "subscription" && (
<div className="card p-5 max-w-xl">
<h3 className="font-bold mb-3">{t.subscription}</h3>
<div className="space-y-2 text-sm">
<Row label="Current plan" value={sub?.plan?.name ?? "—"} />
<Row label="Status" value={sub?.status ?? "—"} />
<Row label="Days remaining" value={sub?.days_remaining ?? "—"} />
<Row label="Auto-renewal" value={sub?.auto_renew ? "On" : "Off"} />
</div>
<Link href="/dashboard/billing" className="inline-block mt-4 bg-brand-600 text-white px-5 py-2 rounded-lg text-sm font-semibold">
Manage billing
</Link>
</div>
)}
{active === "audit" && (
<div className="card overflow-x-auto">
<div className="flex justify-between items-center p-5 pb-3">
<h3 className="font-bold">Recent Activity</h3>
<button
onClick={downloadAuditLogs}
className="flex items-center gap-2 bg-brand-600 text-white px-4 py-2 rounded-lg hover:bg-brand-700"
>
<Download className="h-4 w-4" />
Download CSV
</button>
</div>
<div className="flex flex-wrap gap-3 p-5 border-b">
<input
type="date"
value={filters.from}
onChange={(e) =>
setFilters({ ...filters, from: e.target.value })
}
className="border rounded-lg px-3 py-2"
/>
<input
type="date"
value={filters.to}
onChange={(e) =>
setFilters({ ...filters, to: e.target.value })
}
className="border rounded-lg px-3 py-2"
/>
<select
value={filters.module}
onChange={(e) =>
setFilters({ ...filters, module: e.target.value })
}
className="border rounded-lg px-3 py-2"
>
<option value="">All Modules</option>
<option value="organization">Organization</option>
<option value="products">Product</option>
<option value="branding">Branding</option>
<option value="users">Users</option>
<option value="compliance">Compliance</option>
</select>
<input
placeholder="Search User"
value={filters.user}
onChange={(e) =>
setFilters({ ...filters, user: e.target.value })
}
className="border rounded-lg px-3 py-2"
/>
<button
onClick={() =>
setFilters({
user: "",
module: "",
from: "",
to: "",
})
}
className="px-4 py-2 rounded-lg bg-slate-200"
>
Clear
</button>
</div>
<table className="w-full text-sm">
<thead className="bg-slate-50 text-slate-500 text-left">
<tr>
<th className="px-4 py-3 font-medium">Time</th>
<th className="px-4 py-3 font-medium">User</th>
<th className="px-4 py-3 font-medium">Action</th>
<th className="px-4 py-3 font-medium">Details</th>
<th className="px-4 py-3 font-medium">Module</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-100">
{logs.map((l, i) => (
<tr key={i}>
<td className="px-4 py-3 text-slate-500">
{l.at
? new Date(l.at).toLocaleString(form.language || "en", {
timeZone: form.timezone || "Asia/Kolkata",
dateStyle: "medium",
timeStyle: "short",
})
: ""}
</td>
<td className="px-4 py-3">
<div className="font-medium">
{l.user ?? "—"}
</div>
<div className="text-xs text-slate-500 capitalize">
{l.role ?? "User"}
</div>
</td>
<td className="px-4 py-3 capitalize">{l.action?.replace(".", " ")}</td>
<td className="px-4 py-3 text-slate-500">{l.details}</td>
<td className="px-4 py-3">
<span
className={`px-2 py-1 rounded-full text-xs font-medium
${
l.module === "Product"
? "bg-blue-100 text-blue-700"
: l.module === "Organization"
? "bg-green-100 text-green-700"
: l.module === "Branding"
? "bg-purple-100 text-purple-700"
: l.module === "Notification"
? "bg-yellow-100 text-yellow-700"
: "bg-slate-100 text-slate-700"
}`}
>
{l.module}
</span>
</td>
</tr>
))}
{logs.length === 0 && <tr><td colSpan={4} className="px-4 py-10 text-center text-slate-400">No activity yet.</td></tr>}
</tbody>
</table>
</div>
)}
</div>
</div>
</div>
);
}
export default function Settings() {
return (
<Suspense fallback={<div className="text-slate-400">Loading</div>}>
<SettingsInner />
</Suspense>
);
}
function Field({ label, value, onChange }: any) {
return (
<label className="block">
<span className="text-sm font-medium">{label}</span>
<input value={value ?? ""} onChange={(e) => onChange(e.target.value)}
className="mt-1 w-full px-3 py-2 rounded-lg border border-slate-200 text-sm" />
</label>
);
}
function Row({ label, value }: any) {
return (
<div className="flex justify-between border-b border-slate-100 py-2">
<span className="text-slate-500">{label}</span>
<span className="font-medium text-slate-800 capitalize">{value}</span>
</div>
);
}

View File

@@ -0,0 +1,10 @@
"use client";
import { useEffect } from "react";
import { useRouter } from "next/navigation";
// Users management moved into Settings → Users.
export default function UsersRedirect() {
const r = useRouter();
useEffect(() => { r.replace("/dashboard/settings?section=users"); }, [r]);
return <div className="min-h-[40vh] grid place-items-center text-slate-400">Redirecting</div>;
}