545 lines
16 KiB
TypeScript
545 lines
16 KiB
TypeScript
"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>
|
|
);
|
|
}
|