889 lines
30 KiB
TypeScript
889 lines
30 KiB
TypeScript
"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>
|
||
);
|
||
}
|