Admin panel overhaul: activity monitor, real DB wiring, tier management
- New /admin/activity page: live login events, failed attempts, active sessions from audit_log + sessions tables; auto-refresh toggle - New /api/admin/activity route: queries audit_log + sessions for stats (active sessions, logins/failures 24h, signups 7d) and events - Fix /api/admin/stats: real growth charts (families/users by day), real children-by-age, real conversion rate, active sessions count, and login/failure counts — was all hardcoded empty arrays before - Fix /api/admin/analytics: avg logs per family now divides by actual family count instead of hardcoded 1 - Dashboard: 6-card grid adding Active Sessions + Failed Logins 24h with links to Activity Monitor; bar charts now show hover counts - Families: inline tier upgrade/downgrade button (Pro ↑ / Free ↓) wired to existing PATCH API; member panel polished - Support: admin reply thread using support_responses table; Cmd+Enter to send; conversation view with original message + admin replies; auto-moves ticket to in_progress on first reply - Settings: honest read-only display for env-var-controlled settings (pricing, AI config); editable free-tier limits that write to DB - New /api/admin/families/limits route for bulk free-tier limit update - Sidebar: added Activity Monitor nav item Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
bcc167d7c2
commit
23010e9d90
12 changed files with 1075 additions and 283 deletions
|
|
@ -12,6 +12,7 @@ interface NavItem {
|
||||||
|
|
||||||
const navItems: NavItem[] = [
|
const navItems: NavItem[] = [
|
||||||
{ name: "Dashboard", href: "/admin", icon: "📊" },
|
{ name: "Dashboard", href: "/admin", icon: "📊" },
|
||||||
|
{ name: "Activity", href: "/admin/activity", icon: "🔍" },
|
||||||
{ name: "Families", href: "/admin/families", icon: "🏠" },
|
{ name: "Families", href: "/admin/families", icon: "🏠" },
|
||||||
{ name: "Users", href: "/admin/users", icon: "👥" },
|
{ name: "Users", href: "/admin/users", icon: "👥" },
|
||||||
{ name: "Children", href: "/admin/children", icon: "👶" },
|
{ name: "Children", href: "/admin/children", icon: "👶" },
|
||||||
|
|
|
||||||
263
src/app/admin/activity/page.tsx
Normal file
263
src/app/admin/activity/page.tsx
Normal file
|
|
@ -0,0 +1,263 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { Button, Badge } from "@/components/ui";
|
||||||
|
|
||||||
|
interface ActivityStats {
|
||||||
|
activeSessions: number;
|
||||||
|
loginsToday: number;
|
||||||
|
failedToday: number;
|
||||||
|
signupsWeek: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Event {
|
||||||
|
id: string;
|
||||||
|
action: string;
|
||||||
|
email: string;
|
||||||
|
userName: string | null;
|
||||||
|
familyName: string | null;
|
||||||
|
ipAddress: string | null;
|
||||||
|
userAgent: string | null;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ActivityData {
|
||||||
|
stats: ActivityStats;
|
||||||
|
loginsByDay: { date: string; count: number }[];
|
||||||
|
failedByDay: { date: string; count: number }[];
|
||||||
|
events: Event[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const ACTION_CONFIG: Record<string, { label: string; color: string; badge: "rose" | "warning" | "default" }> = {
|
||||||
|
login: { label: "Login", color: "text-emerald-400", badge: "default" },
|
||||||
|
login_failed: { label: "Failed Login", color: "text-rose-400", badge: "rose" },
|
||||||
|
signup: { label: "Signup", color: "text-blue-400", badge: "default" },
|
||||||
|
logout: { label: "Logout", color: "text-gray-400", badge: "default" },
|
||||||
|
};
|
||||||
|
|
||||||
|
function timeAgo(iso: string) {
|
||||||
|
const diff = Date.now() - new Date(iso).getTime();
|
||||||
|
const mins = Math.floor(diff / 60000);
|
||||||
|
if (mins < 1) return "just now";
|
||||||
|
if (mins < 60) return `${mins}m ago`;
|
||||||
|
const hrs = Math.floor(mins / 60);
|
||||||
|
if (hrs < 24) return `${hrs}h ago`;
|
||||||
|
const days = Math.floor(hrs / 24);
|
||||||
|
return `${days}d ago`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseDevice(ua: string | null) {
|
||||||
|
if (!ua) return "Unknown";
|
||||||
|
if (ua.includes("iPhone") || ua.includes("Android")) return "Mobile";
|
||||||
|
if (ua.includes("iPad")) return "Tablet";
|
||||||
|
return "Desktop";
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AdminActivity() {
|
||||||
|
const [data, setData] = useState<ActivityData | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [filter, setFilter] = useState("all");
|
||||||
|
const [autoRefresh, setAutoRefresh] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchActivity();
|
||||||
|
}, [filter]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!autoRefresh) return;
|
||||||
|
const id = setInterval(fetchActivity, 15000);
|
||||||
|
return () => clearInterval(id);
|
||||||
|
}, [autoRefresh, filter]);
|
||||||
|
|
||||||
|
const fetchActivity = async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/admin/activity?action=${filter}&limit=100`, { credentials: "include" });
|
||||||
|
const json = await res.json();
|
||||||
|
setData(json);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to fetch activity:", err);
|
||||||
|
}
|
||||||
|
setLoading(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading || !data) {
|
||||||
|
return (
|
||||||
|
<div className="p-6">
|
||||||
|
<div className="animate-pulse space-y-4">
|
||||||
|
<div className="h-8 bg-gray-700 rounded w-48" />
|
||||||
|
<div className="grid grid-cols-4 gap-4">
|
||||||
|
{[1, 2, 3, 4].map(i => <div key={i} className="h-24 bg-gray-800 rounded-xl" />)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { stats, loginsByDay, failedByDay, events } = data;
|
||||||
|
const maxLogins = Math.max(...loginsByDay.map(d => d.count), 1);
|
||||||
|
const maxFailed = Math.max(...failedByDay.map(d => d.count), 1);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-6 space-y-6">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold">Activity Monitor</h1>
|
||||||
|
<p className="text-gray-400">Real-time login events and session tracking</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className={`w-2 h-2 rounded-full ${autoRefresh ? "bg-emerald-400 animate-pulse" : "bg-gray-600"}`} />
|
||||||
|
<button
|
||||||
|
onClick={() => setAutoRefresh(!autoRefresh)}
|
||||||
|
className="text-sm text-gray-400 hover:text-white"
|
||||||
|
>
|
||||||
|
{autoRefresh ? "Live" : "Paused"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<Button size="sm" variant="secondary" onClick={fetchActivity}>Refresh</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Stat Cards */}
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||||
|
<div className="bg-gray-800 p-5 rounded-xl border border-gray-700">
|
||||||
|
<div className="text-3xl font-bold text-emerald-400">{stats.activeSessions}</div>
|
||||||
|
<div className="text-gray-400 text-sm mt-1">Active Sessions</div>
|
||||||
|
<div className="text-xs text-gray-600 mt-1">Currently online</div>
|
||||||
|
</div>
|
||||||
|
<div className="bg-gray-800 p-5 rounded-xl border border-gray-700">
|
||||||
|
<div className="text-3xl font-bold text-blue-400">{stats.loginsToday}</div>
|
||||||
|
<div className="text-gray-400 text-sm mt-1">Logins Today</div>
|
||||||
|
<div className="text-xs text-gray-600 mt-1">Last 24 hours</div>
|
||||||
|
</div>
|
||||||
|
<div className="bg-gray-800 p-5 rounded-xl border border-gray-700">
|
||||||
|
<div className={`text-3xl font-bold ${stats.failedToday > 0 ? "text-rose-400" : "text-gray-400"}`}>
|
||||||
|
{stats.failedToday}
|
||||||
|
</div>
|
||||||
|
<div className="text-gray-400 text-sm mt-1">Failed Attempts</div>
|
||||||
|
<div className="text-xs text-gray-600 mt-1">Last 24 hours</div>
|
||||||
|
</div>
|
||||||
|
<div className="bg-gray-800 p-5 rounded-xl border border-gray-700">
|
||||||
|
<div className="text-3xl font-bold text-amber-400">{stats.signupsWeek}</div>
|
||||||
|
<div className="text-gray-400 text-sm mt-1">New Signups</div>
|
||||||
|
<div className="text-xs text-gray-600 mt-1">Last 7 days</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Charts */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div className="bg-gray-800 p-6 rounded-xl">
|
||||||
|
<h3 className="text-sm font-semibold text-gray-400 mb-4">LOGIN ACTIVITY — LAST 30 DAYS</h3>
|
||||||
|
<div className="h-32 flex items-end gap-1">
|
||||||
|
{loginsByDay.slice(-30).map((d, i) => (
|
||||||
|
<div key={i} className="flex-1 flex flex-col items-center gap-1">
|
||||||
|
<div
|
||||||
|
className="w-full bg-emerald-500 rounded-t opacity-80"
|
||||||
|
style={{ height: `${(d.count / maxLogins) * 100}%`, minHeight: d.count > 0 ? "3px" : "0" }}
|
||||||
|
/>
|
||||||
|
<div className="text-[7px] text-gray-600">{d.date?.slice(5) || ""}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{loginsByDay.length === 0 && (
|
||||||
|
<div className="w-full h-full flex items-center justify-center text-gray-600 text-sm">
|
||||||
|
No login data yet
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="bg-gray-800 p-6 rounded-xl">
|
||||||
|
<h3 className="text-sm font-semibold text-gray-400 mb-4">FAILED LOGINS — LAST 30 DAYS</h3>
|
||||||
|
<div className="h-32 flex items-end gap-1">
|
||||||
|
{failedByDay.slice(-30).map((d, i) => (
|
||||||
|
<div key={i} className="flex-1 flex flex-col items-center gap-1">
|
||||||
|
<div
|
||||||
|
className="w-full bg-rose-500 rounded-t opacity-80"
|
||||||
|
style={{ height: `${(d.count / maxFailed) * 100}%`, minHeight: d.count > 0 ? "3px" : "0" }}
|
||||||
|
/>
|
||||||
|
<div className="text-[7px] text-gray-600">{d.date?.slice(5) || ""}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{failedByDay.length === 0 && (
|
||||||
|
<div className="w-full h-full flex items-center justify-center text-gray-600 text-sm">
|
||||||
|
No failed logins
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Events Table */}
|
||||||
|
<div className="bg-gray-800 rounded-xl overflow-hidden">
|
||||||
|
{/* Filter tabs */}
|
||||||
|
<div className="flex gap-1 p-3 border-b border-gray-700 bg-gray-900">
|
||||||
|
{[
|
||||||
|
{ key: "all", label: "All Events" },
|
||||||
|
{ key: "login", label: "Logins" },
|
||||||
|
{ key: "login_failed", label: "Failures" },
|
||||||
|
{ key: "signup", label: "Signups" },
|
||||||
|
].map(({ key, label }) => (
|
||||||
|
<button
|
||||||
|
key={key}
|
||||||
|
onClick={() => setFilter(key)}
|
||||||
|
className={`px-3 py-1.5 rounded text-sm font-medium transition-colors ${
|
||||||
|
filter === key
|
||||||
|
? "bg-rose-500 text-white"
|
||||||
|
: "text-gray-400 hover:text-white hover:bg-gray-700"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
<div className="ml-auto text-xs text-gray-600 flex items-center">
|
||||||
|
{events.length} events
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead className="bg-gray-700">
|
||||||
|
<tr>
|
||||||
|
<th className="px-4 py-2.5 text-left text-xs font-medium text-gray-400">TIME</th>
|
||||||
|
<th className="px-4 py-2.5 text-left text-xs font-medium text-gray-400">EVENT</th>
|
||||||
|
<th className="px-4 py-2.5 text-left text-xs font-medium text-gray-400">USER</th>
|
||||||
|
<th className="px-4 py-2.5 text-left text-xs font-medium text-gray-400">FAMILY</th>
|
||||||
|
<th className="px-4 py-2.5 text-left text-xs font-medium text-gray-400">IP</th>
|
||||||
|
<th className="px-4 py-2.5 text-left text-xs font-medium text-gray-400">DEVICE</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-gray-700">
|
||||||
|
{events.map((event) => {
|
||||||
|
const config = ACTION_CONFIG[event.action] || { label: event.action, color: "text-gray-400", badge: "default" as const };
|
||||||
|
return (
|
||||||
|
<tr key={event.id} className={`hover:bg-gray-750 ${event.action === "login_failed" ? "bg-rose-500/5" : ""}`}>
|
||||||
|
<td className="px-4 py-2.5 text-gray-500 whitespace-nowrap">
|
||||||
|
{timeAgo(event.createdAt)}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-2.5">
|
||||||
|
<span className={`font-medium ${config.color}`}>{config.label}</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-2.5">
|
||||||
|
<div className="font-medium truncate max-w-[180px]">{event.email}</div>
|
||||||
|
{event.userName && event.userName !== event.email && (
|
||||||
|
<div className="text-xs text-gray-500">{event.userName}</div>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-2.5 text-gray-400">{event.familyName || "—"}</td>
|
||||||
|
<td className="px-4 py-2.5 text-gray-500 font-mono text-xs">{event.ipAddress || "—"}</td>
|
||||||
|
<td className="px-4 py-2.5 text-gray-500 text-xs">{parseDevice(event.userAgent)}</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{events.length === 0 && (
|
||||||
|
<div className="p-12 text-center text-gray-600">
|
||||||
|
No events recorded yet
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -9,6 +9,7 @@ interface EngagementStats {
|
||||||
totalGrowth: number;
|
totalGrowth: number;
|
||||||
totalMemories: number;
|
totalMemories: number;
|
||||||
totalChatSessions: number;
|
totalChatSessions: number;
|
||||||
|
familyCount: number;
|
||||||
logsByDay: { date: string; count: number }[];
|
logsByDay: { date: string; count: number }[];
|
||||||
topFeatures: { name: string; count: number }[];
|
topFeatures: { name: string; count: number }[];
|
||||||
}
|
}
|
||||||
|
|
@ -100,7 +101,9 @@ export default function AdminAnalytics() {
|
||||||
<div className="flex justify-between items-center">
|
<div className="flex justify-between items-center">
|
||||||
<span className="text-gray-400">Avg Logs per Family</span>
|
<span className="text-gray-400">Avg Logs per Family</span>
|
||||||
<span className="font-bold">
|
<span className="font-bold">
|
||||||
{stats.totalLogs > 0 ? (stats.totalLogs / 1).toFixed(1) : "0"}
|
{stats.totalLogs > 0 && stats.familyCount > 0
|
||||||
|
? (stats.totalLogs / stats.familyCount).toFixed(1)
|
||||||
|
: "0"}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between items-center">
|
<div className="flex justify-between items-center">
|
||||||
|
|
|
||||||
|
|
@ -29,7 +29,8 @@ export default function AdminFamilies() {
|
||||||
const [search, setSearch] = useState("");
|
const [search, setSearch] = useState("");
|
||||||
const [tierFilter, setTierFilter] = useState("all");
|
const [tierFilter, setTierFilter] = useState("all");
|
||||||
const [showMembers, setShowMembers] = useState<string | null>(null);
|
const [showMembers, setShowMembers] = useState<string | null>(null);
|
||||||
const [addMember, setAddMember] = useState<{familyId: string; email: string; role: string; name: string} | null>(null);
|
const [addMember, setAddMember] = useState<{ familyId: string; email: string; role: string; name: string } | null>(null);
|
||||||
|
const [tierChanging, setTierChanging] = useState<string | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchFamilies();
|
fetchFamilies();
|
||||||
|
|
@ -39,9 +40,7 @@ export default function AdminFamilies() {
|
||||||
try {
|
try {
|
||||||
const res = await fetch("/api/admin/families", { credentials: "include" });
|
const res = await fetch("/api/admin/families", { credentials: "include" });
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
if (data.error) {
|
if (data.error) console.error("API error:", data.error);
|
||||||
console.error("API error:", data.error);
|
|
||||||
}
|
|
||||||
setFamilies(data.families || []);
|
setFamilies(data.families || []);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Failed to fetch families:", err);
|
console.error("Failed to fetch families:", err);
|
||||||
|
|
@ -96,6 +95,31 @@ export default function AdminFamilies() {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleTierChange = async (familyId: string, newTier: string) => {
|
||||||
|
const family = families.find(f => f.id === familyId);
|
||||||
|
if (!family) return;
|
||||||
|
const label = newTier === "pro" ? "Upgrade to Pro?" : "Downgrade to Free?";
|
||||||
|
if (!confirm(label)) return;
|
||||||
|
setTierChanging(familyId);
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/admin/families", {
|
||||||
|
method: "PATCH",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
credentials: "include",
|
||||||
|
body: JSON.stringify({
|
||||||
|
familyId,
|
||||||
|
tier: newTier,
|
||||||
|
maxChildren: newTier === "pro" ? 10 : 1,
|
||||||
|
maxMembers: newTier === "pro" ? 10 : 2,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
if (res.ok) fetchFamilies();
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to update tier:", err);
|
||||||
|
}
|
||||||
|
setTierChanging(null);
|
||||||
|
};
|
||||||
|
|
||||||
const filteredFamilies = families.filter((f) => {
|
const filteredFamilies = families.filter((f) => {
|
||||||
const matchesSearch = f.name.toLowerCase().includes(search.toLowerCase());
|
const matchesSearch = f.name.toLowerCase().includes(search.toLowerCase());
|
||||||
const matchesTier = tierFilter === "all" || f.tier === tierFilter;
|
const matchesTier = tierFilter === "all" || f.tier === tierFilter;
|
||||||
|
|
@ -105,13 +129,7 @@ export default function AdminFamilies() {
|
||||||
const exportCSV = () => {
|
const exportCSV = () => {
|
||||||
const headers = ["Name", "Tier", "Users", "Children", "Max Children", "Max Members", "Created"];
|
const headers = ["Name", "Tier", "Users", "Children", "Max Children", "Max Members", "Created"];
|
||||||
const rows = filteredFamilies.map((f) => [
|
const rows = filteredFamilies.map((f) => [
|
||||||
f.name,
|
f.name, f.tier, f.userCount, f.childCount, f.maxChildren, f.maxMembers, f.createdAt,
|
||||||
f.tier,
|
|
||||||
f.userCount,
|
|
||||||
f.childCount,
|
|
||||||
f.maxChildren,
|
|
||||||
f.maxMembers,
|
|
||||||
f.createdAt,
|
|
||||||
]);
|
]);
|
||||||
const csv = [headers, ...rows].map((row) => row.join(",")).join("\n");
|
const csv = [headers, ...rows].map((row) => row.join(",")).join("\n");
|
||||||
const blob = new Blob([csv], { type: "text/csv" });
|
const blob = new Blob([csv], { type: "text/csv" });
|
||||||
|
|
@ -122,18 +140,19 @@ export default function AdminFamilies() {
|
||||||
a.click();
|
a.click();
|
||||||
};
|
};
|
||||||
|
|
||||||
if (loading) {
|
if (loading) return <div className="p-6 text-white">Loading...</div>;
|
||||||
return (
|
|
||||||
<div className="p-6 text-white">Loading...</div>
|
const proCount = families.filter(f => f.tier === "pro").length;
|
||||||
);
|
const freeCount = families.filter(f => f.tier === "free").length;
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-6 space-y-4">
|
<div className="p-6 space-y-4">
|
||||||
<div className="flex justify-between items-center">
|
<div className="flex justify-between items-center">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold">Families</h1>
|
<h1 className="text-2xl font-bold">Families</h1>
|
||||||
<p className="text-gray-400">{families.length} total families</p>
|
<p className="text-gray-400">
|
||||||
|
{families.length} total · <span className="text-rose-400">{proCount} Pro</span> · <span className="text-gray-500">{freeCount} Free</span>
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Button onClick={handleCreateFamily}>+ New Family</Button>
|
<Button onClick={handleCreateFamily}>+ New Family</Button>
|
||||||
|
|
@ -142,7 +161,7 @@ export default function AdminFamilies() {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Filters */}
|
{/* Filters */}
|
||||||
<div className="flex gap-4">
|
<div className="flex gap-3">
|
||||||
<Input
|
<Input
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Search families..."
|
placeholder="Search families..."
|
||||||
|
|
@ -150,10 +169,7 @@ export default function AdminFamilies() {
|
||||||
onChange={(e) => setSearch(e.target.value)}
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
className="flex-1"
|
className="flex-1"
|
||||||
/>
|
/>
|
||||||
<Select
|
<Select value={tierFilter} onChange={(e) => setTierFilter(e.target.value)}>
|
||||||
value={tierFilter}
|
|
||||||
onChange={(e) => setTierFilter(e.target.value)}
|
|
||||||
>
|
|
||||||
<option value="all">All Tiers</option>
|
<option value="all">All Tiers</option>
|
||||||
<option value="free">Free</option>
|
<option value="free">Free</option>
|
||||||
<option value="pro">Pro</option>
|
<option value="pro">Pro</option>
|
||||||
|
|
@ -167,10 +183,11 @@ export default function AdminFamilies() {
|
||||||
<tr>
|
<tr>
|
||||||
<th className="px-4 py-3 text-left text-sm font-medium">Family</th>
|
<th className="px-4 py-3 text-left text-sm font-medium">Family</th>
|
||||||
<th className="px-4 py-3 text-left text-sm font-medium">Tier</th>
|
<th className="px-4 py-3 text-left text-sm font-medium">Tier</th>
|
||||||
<th className="px-4 py-3 text-left text-sm font-medium">Users</th>
|
<th className="px-4 py-3 text-left text-sm font-medium">Members</th>
|
||||||
<th className="px-4 py-3 text-left text-sm font-medium">Children</th>
|
<th className="px-4 py-3 text-left text-sm font-medium">Children</th>
|
||||||
<th className="px-4 py-3 text-left text-sm font-medium">Limits</th>
|
<th className="px-4 py-3 text-left text-sm font-medium">Limits</th>
|
||||||
<th className="px-4 py-3 text-left text-sm font-medium">Created</th>
|
<th className="px-4 py-3 text-left text-sm font-medium">Created</th>
|
||||||
|
<th className="px-4 py-3 text-left text-sm font-medium">Actions</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y divide-gray-700">
|
<tbody className="divide-y divide-gray-700">
|
||||||
|
|
@ -179,49 +196,84 @@ export default function AdminFamilies() {
|
||||||
<tr key={family.id} className="hover:bg-gray-750">
|
<tr key={family.id} className="hover:bg-gray-750">
|
||||||
<td className="px-4 py-3">
|
<td className="px-4 py-3">
|
||||||
<div className="font-medium">{family.name}</div>
|
<div className="font-medium">{family.name}</div>
|
||||||
<div className="text-xs text-gray-500">{family.id}</div>
|
<div className="text-xs text-gray-600 font-mono">{family.id.slice(0, 8)}…</div>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3">
|
<td className="px-4 py-3">
|
||||||
<Badge variant={family.tier === "pro" ? "rose" : "default"}>{family.tier}</Badge>
|
<div className="flex items-center gap-2">
|
||||||
|
<Badge variant={family.tier === "pro" ? "rose" : "default"}>{family.tier}</Badge>
|
||||||
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3">
|
<td className="px-4 py-3">
|
||||||
<Button variant="ghost" size="sm" onClick={() => setShowMembers(showMembers === family.id ? null : family.id)}>
|
<Button
|
||||||
{family.userCount} users
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setShowMembers(showMembers === family.id ? null : family.id)}
|
||||||
|
>
|
||||||
|
{family.userCount} users {showMembers === family.id ? "▲" : "▼"}
|
||||||
</Button>
|
</Button>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3">{family.childCount}</td>
|
<td className="px-4 py-3 text-gray-300">{family.childCount}</td>
|
||||||
<td className="px-4 py-3 text-sm text-gray-400">
|
<td className="px-4 py-3 text-sm text-gray-500">
|
||||||
{family.maxChildren} kids, {family.maxMembers} members
|
{family.maxChildren} kids · {family.maxMembers} members
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3 text-sm text-gray-400">
|
<td className="px-4 py-3 text-sm text-gray-400">
|
||||||
{family.createdAt?.slice(0, 10)}
|
{family.createdAt?.slice(0, 10)}
|
||||||
</td>
|
</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<Button
|
||||||
|
variant={family.tier === "pro" ? "secondary" : "primary"}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handleTierChange(family.id, family.tier === "pro" ? "free" : "pro")}
|
||||||
|
disabled={tierChanging === family.id}
|
||||||
|
>
|
||||||
|
{tierChanging === family.id
|
||||||
|
? "…"
|
||||||
|
: family.tier === "pro"
|
||||||
|
? "↓ Free"
|
||||||
|
: "↑ Pro"}
|
||||||
|
</Button>
|
||||||
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{showMembers === family.id && (
|
{showMembers === family.id && (
|
||||||
<tr>
|
<tr key={`${family.id}-members`}>
|
||||||
<td colSpan={6} className="bg-gray-800 p-4">
|
<td colSpan={7} className="bg-gray-900 px-6 py-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
|
<div className="text-xs text-gray-500 font-semibold uppercase mb-2">Members</div>
|
||||||
<div className="flex gap-2 items-center">
|
<div className="flex gap-2 items-center">
|
||||||
<Input
|
<Input
|
||||||
type="email"
|
type="email"
|
||||||
placeholder="New member email"
|
placeholder="Add member by email"
|
||||||
onChange={(e) => setAddMember({familyId: family.id, email: e.target.value, role: "caregiver", name: ""})}
|
onChange={(e) =>
|
||||||
className="flex-1"
|
setAddMember({ familyId: family.id, email: e.target.value, role: "caregiver", name: "" })
|
||||||
|
}
|
||||||
|
className="flex-1 max-w-xs"
|
||||||
/>
|
/>
|
||||||
<Select
|
<Select
|
||||||
onChange={(e) => setAddMember(addMember ? {...addMember, role: e.target.value} : null)}
|
onChange={(e) =>
|
||||||
|
setAddMember(addMember ? { ...addMember, role: e.target.value } : null)
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<option value="caregiver">Caregiver</option>
|
<option value="caregiver">Caregiver</option>
|
||||||
<option value="owner">Owner</option>
|
<option value="owner">Owner</option>
|
||||||
</Select>
|
</Select>
|
||||||
<Button size="sm" onClick={handleAddMember}>Add</Button>
|
<Button size="sm" onClick={handleAddMember}>Add</Button>
|
||||||
</div>
|
</div>
|
||||||
{(family.members || []).map((m) => (
|
<div className="space-y-1 mt-2">
|
||||||
<div key={m.id} className="flex justify-between items-center bg-gray-700 p-2 rounded">
|
{(family.members || []).map((m) => (
|
||||||
<span>{m.email} ({m.role})</span>
|
<div key={m.id} className="flex justify-between items-center bg-gray-800 px-3 py-2 rounded-lg">
|
||||||
<Button variant="danger" size="sm" onClick={() => handleRemoveMember(m.id)}>Remove</Button>
|
<div>
|
||||||
</div>
|
<span className="text-sm">{m.email}</span>
|
||||||
))}
|
<span className="ml-2 text-xs text-gray-500 bg-gray-700 px-1.5 py-0.5 rounded">{m.role}</span>
|
||||||
|
</div>
|
||||||
|
<Button variant="danger" size="sm" onClick={() => handleRemoveMember(m.id)}>
|
||||||
|
Remove
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{(family.members || []).length === 0 && (
|
||||||
|
<div className="text-gray-600 text-sm">No members yet</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
|
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { Select } from "@/components/ui";
|
import { Select } from "@/components/ui";
|
||||||
|
import Link from "next/link";
|
||||||
|
|
||||||
interface Stats {
|
interface Stats {
|
||||||
overview: {
|
overview: {
|
||||||
|
|
@ -12,6 +13,9 @@ interface Stats {
|
||||||
freeFamilies: number;
|
freeFamilies: number;
|
||||||
mrr: number;
|
mrr: number;
|
||||||
avgRevenuePerUser: number;
|
avgRevenuePerUser: number;
|
||||||
|
activeSessions: number;
|
||||||
|
loginsLast24h: number;
|
||||||
|
failedLoginsLast24h: number;
|
||||||
};
|
};
|
||||||
conversions: {
|
conversions: {
|
||||||
freeToPro: number;
|
freeToPro: number;
|
||||||
|
|
@ -34,8 +38,9 @@ export default function AdminDashboard() {
|
||||||
}, [period]);
|
}, [period]);
|
||||||
|
|
||||||
const fetchStats = async () => {
|
const fetchStats = async () => {
|
||||||
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`/api/admin/stats?period=${period}`);
|
const res = await fetch(`/api/admin/stats?period=${period}`, { credentials: "include" });
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
setStats(data);
|
setStats(data);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|
@ -46,12 +51,17 @@ export default function AdminDashboard() {
|
||||||
|
|
||||||
if (loading || !stats) {
|
if (loading || !stats) {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen flex items-center justify-center bg-gray-900">
|
<div className="p-6 space-y-6 animate-pulse">
|
||||||
<div className="text-white">Loading...</div>
|
<div className="h-8 bg-gray-800 rounded w-48" />
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-3 xl:grid-cols-6 gap-4">
|
||||||
|
{[1, 2, 3, 4, 5, 6].map(i => <div key={i} className="h-28 bg-gray-800 rounded-xl" />)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const { overview, conversions, growth, childrenByAge } = stats;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-6 space-y-6">
|
<div className="p-6 space-y-6">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
|
|
@ -67,68 +77,70 @@ export default function AdminDashboard() {
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Overview Cards */}
|
{/* Primary Stat Cards */}
|
||||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
<div className="grid grid-cols-2 md:grid-cols-3 xl:grid-cols-6 gap-4">
|
||||||
|
<StatCard label="Families" value={overview.totalFamilies} icon="🏠" color="rose" href="/admin/families" />
|
||||||
|
<StatCard label="Users" value={overview.totalUsers} icon="👥" color="blue" href="/admin/users" />
|
||||||
|
<StatCard label="Children" value={overview.totalChildren} icon="👶" color="amber" href="/admin/children" />
|
||||||
|
<StatCard label="MRR" value={`$${overview.mrr.toFixed(2)}`} icon="💰" color="emerald" href="/admin/revenue" />
|
||||||
<StatCard
|
<StatCard
|
||||||
label="Total Families"
|
label="Active Sessions"
|
||||||
value={stats.overview.totalFamilies}
|
value={overview.activeSessions}
|
||||||
icon="🏠"
|
icon="🟢"
|
||||||
color="rose"
|
|
||||||
href="/admin/families"
|
|
||||||
/>
|
|
||||||
<StatCard
|
|
||||||
label="Total Users"
|
|
||||||
value={stats.overview.totalUsers}
|
|
||||||
icon="👥"
|
|
||||||
color="blue"
|
|
||||||
href="/admin/users"
|
|
||||||
/>
|
|
||||||
<StatCard
|
|
||||||
label="Total Children"
|
|
||||||
value={stats.overview.totalChildren}
|
|
||||||
icon="👶"
|
|
||||||
color="amber"
|
|
||||||
href="/admin/children"
|
|
||||||
/>
|
|
||||||
<StatCard
|
|
||||||
label="MRR"
|
|
||||||
value={`$${stats.overview.mrr.toFixed(2)}`}
|
|
||||||
icon="💰"
|
|
||||||
color="emerald"
|
color="emerald"
|
||||||
href="/admin/revenue"
|
href="/admin/activity"
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
label="Failed Logins 24h"
|
||||||
|
value={overview.failedLoginsLast24h}
|
||||||
|
icon="⚠️"
|
||||||
|
color={overview.failedLoginsLast24h > 0 ? "rose" : "gray"}
|
||||||
|
href="/admin/activity"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Revenue & Tier Stats */}
|
{/* Revenue & Conversions */}
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
<div className="bg-gray-800 p-6 rounded-xl">
|
<div className="bg-gray-800 p-6 rounded-xl">
|
||||||
<h3 className="text-lg font-semibold mb-4">Revenue Overview</h3>
|
<h3 className="text-sm font-semibold text-gray-400 mb-4">REVENUE OVERVIEW</h3>
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<div>
|
<div>
|
||||||
<div className="text-2xl font-bold text-emerald-400">${stats.overview.mrr.toFixed(2)}</div>
|
<div className="text-2xl font-bold text-emerald-400">${overview.mrr.toFixed(2)}</div>
|
||||||
<div className="text-gray-400 text-sm">Monthly Recurring Revenue</div>
|
<div className="text-gray-400 text-sm">Monthly Recurring</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div className="text-2xl font-bold text-rose-400">{stats.overview.proFamilies}</div>
|
<div className="text-2xl font-bold text-rose-400">{overview.proFamilies}</div>
|
||||||
<div className="text-gray-400 text-sm">Pro Families</div>
|
<div className="text-gray-400 text-sm">Pro Families</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div className="text-2xl font-bold text-gray-400">{stats.overview.freeFamilies}</div>
|
<div className="text-2xl font-bold text-gray-500">{overview.freeFamilies}</div>
|
||||||
<div className="text-gray-400 text-sm">Free Families</div>
|
<div className="text-gray-400 text-sm">Free Families</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div className="text-2xl font-bold text-amber-400">${stats.overview.avgRevenuePerUser}</div>
|
<div className="text-2xl font-bold text-amber-400">${overview.avgRevenuePerUser}</div>
|
||||||
<div className="text-gray-400 text-sm">Avg Revenue per Family</div>
|
<div className="text-gray-400 text-sm">Avg per Family</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="bg-gray-800 p-6 rounded-xl">
|
<div className="bg-gray-800 p-6 rounded-xl">
|
||||||
<h3 className="text-lg font-semibold mb-4">Conversions</h3>
|
<h3 className="text-sm font-semibold text-gray-400 mb-4">CONVERSION & ACTIVITY</h3>
|
||||||
<div className="flex items-center justify-center h-32">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<div className="text-center">
|
<div className="flex flex-col items-center justify-center bg-gray-700 rounded-xl p-4">
|
||||||
<div className="text-4xl font-bold text-rose-400">{stats.conversions.conversionRate}%</div>
|
<div className="text-3xl font-bold text-rose-400">{conversions.conversionRate}%</div>
|
||||||
<div className="text-gray-400">Free → Pro Conversion Rate</div>
|
<div className="text-gray-400 text-sm text-center mt-1">Pro Adoption Rate</div>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div>
|
||||||
|
<div className="text-xl font-bold text-blue-400">{overview.loginsLast24h}</div>
|
||||||
|
<div className="text-gray-400 text-xs">Logins (24h)</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className={`text-xl font-bold ${overview.failedLoginsLast24h > 0 ? "text-rose-400" : "text-gray-500"}`}>
|
||||||
|
{overview.failedLoginsLast24h}
|
||||||
|
</div>
|
||||||
|
<div className="text-gray-400 text-xs">Failed (24h)</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -136,82 +148,124 @@ export default function AdminDashboard() {
|
||||||
|
|
||||||
{/* Growth Charts */}
|
{/* Growth Charts */}
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
<ChartCard
|
<ChartCard title="New Families" data={growth.familiesByDay} icon="📈" color="rose" />
|
||||||
title="New Families"
|
<ChartCard title="New Users" data={growth.usersByDay} icon="👥" color="blue" />
|
||||||
data={stats.growth.familiesByDay}
|
|
||||||
icon="📈"
|
|
||||||
/>
|
|
||||||
<ChartCard
|
|
||||||
title="New Users"
|
|
||||||
data={stats.growth.usersByDay}
|
|
||||||
icon="👥"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Children by Age */}
|
{/* Children by Age */}
|
||||||
{stats.childrenByAge.length > 0 && (
|
{childrenByAge.length > 0 && (
|
||||||
<div className="bg-gray-800 p-6 rounded-xl">
|
<div className="bg-gray-800 p-6 rounded-xl">
|
||||||
<h3 className="text-lg font-semibold mb-4">Children by Age Group</h3>
|
<h3 className="text-sm font-semibold text-gray-400 mb-4">CHILDREN BY AGE GROUP</h3>
|
||||||
<div className="flex flex-wrap gap-4">
|
<div className="flex flex-wrap gap-3">
|
||||||
{stats.childrenByAge.map((item) => (
|
{childrenByAge.map((item) => (
|
||||||
<div key={item.ageGroup} className="bg-gray-700 px-4 py-2 rounded-lg">
|
<div key={item.ageGroup} className="bg-gray-700 px-5 py-3 rounded-xl text-center">
|
||||||
<div className="text-xl font-bold">{item.count}</div>
|
<div className="text-2xl font-bold text-amber-400">{item.count}</div>
|
||||||
<div className="text-xs text-gray-400">{item.ageGroup} years</div>
|
<div className="text-xs text-gray-400 mt-0.5">{item.ageGroup}</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Quick Links */}
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||||
|
{[
|
||||||
|
{ label: "View All Families", href: "/admin/families", icon: "🏠" },
|
||||||
|
{ label: "Manage Users", href: "/admin/users", icon: "👥" },
|
||||||
|
{ label: "Activity Log", href: "/admin/activity", icon: "🔍" },
|
||||||
|
{ label: "Support Tickets", href: "/admin/support", icon: "🎫" },
|
||||||
|
].map(({ label, href, icon }) => (
|
||||||
|
<Link
|
||||||
|
key={href}
|
||||||
|
href={href}
|
||||||
|
className="bg-gray-800 hover:bg-gray-700 p-4 rounded-xl flex items-center gap-3 transition-colors group"
|
||||||
|
>
|
||||||
|
<span className="text-xl">{icon}</span>
|
||||||
|
<span className="text-sm text-gray-400 group-hover:text-white transition-colors">{label}</span>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function StatCard({ label, value, icon, color, href }: { label: string; value: number | string; icon: string; color: string; href?: string }) {
|
function StatCard({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
icon,
|
||||||
|
color,
|
||||||
|
href,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
value: number | string;
|
||||||
|
icon: string;
|
||||||
|
color: string;
|
||||||
|
href?: string;
|
||||||
|
}) {
|
||||||
const colorClasses: Record<string, string> = {
|
const colorClasses: Record<string, string> = {
|
||||||
rose: "text-rose-400",
|
rose: "text-rose-400",
|
||||||
blue: "text-blue-400",
|
blue: "text-blue-400",
|
||||||
amber: "text-amber-400",
|
amber: "text-amber-400",
|
||||||
emerald: "text-emerald-400",
|
emerald: "text-emerald-400",
|
||||||
|
gray: "text-gray-500",
|
||||||
};
|
};
|
||||||
|
|
||||||
const content = (
|
const card = (
|
||||||
<div className="bg-gray-800 p-6 rounded-xl">
|
<div className="bg-gray-800 p-5 rounded-xl hover:bg-gray-750 transition-colors h-full">
|
||||||
<div className="flex items-center justify-between mb-2">
|
<div className="text-xl mb-2">{icon}</div>
|
||||||
<span className="text-2xl">{icon}</span>
|
<div className={`text-2xl font-bold ${colorClasses[color] || "text-white"}`}>{value}</div>
|
||||||
</div>
|
<div className="text-gray-400 text-xs mt-1">{label}</div>
|
||||||
<div className={`text-3xl font-bold ${colorClasses[color]}`}>{value}</div>
|
|
||||||
<div className="text-gray-400 text-sm">{label}</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
if (href) {
|
if (href) {
|
||||||
return <a href={href} className="cursor-pointer hover:opacity-90 transition-opacity">{content}</a>;
|
return <Link href={href} className="block">{card}</Link>;
|
||||||
}
|
}
|
||||||
return content;
|
return card;
|
||||||
}
|
}
|
||||||
|
|
||||||
function ChartCard({ title, data, icon }: { title: string; data: { date: string; count: number }[]; icon: string }) {
|
function ChartCard({
|
||||||
|
title,
|
||||||
|
data,
|
||||||
|
icon,
|
||||||
|
color,
|
||||||
|
}: {
|
||||||
|
title: string;
|
||||||
|
data: { date: string; count: number }[];
|
||||||
|
icon: string;
|
||||||
|
color: "rose" | "blue";
|
||||||
|
}) {
|
||||||
const maxCount = Math.max(...data.map((d) => d.count), 1);
|
const maxCount = Math.max(...data.map((d) => d.count), 1);
|
||||||
|
const barColor = color === "rose" ? "bg-rose-500" : "bg-blue-500";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bg-gray-800 p-6 rounded-xl">
|
<div className="bg-gray-800 p-6 rounded-xl">
|
||||||
<h3 className="text-lg font-semibold mb-4">{icon} {title}</h3>
|
<h3 className="text-sm font-semibold text-gray-400 mb-4">
|
||||||
<div className="h-40 flex items-end gap-1">
|
{icon} {title.toUpperCase()}
|
||||||
|
</h3>
|
||||||
|
<div className="h-36 flex items-end gap-1">
|
||||||
{data.slice(-14).map((d, i) => (
|
{data.slice(-14).map((d, i) => (
|
||||||
<div key={i} className="flex-1 flex flex-col items-center gap-1">
|
<div key={i} className="flex-1 flex flex-col items-center gap-1 group">
|
||||||
<div
|
<div className="relative w-full">
|
||||||
className="w-full bg-rose-500 rounded-t"
|
{d.count > 0 && (
|
||||||
style={{ height: `${(d.count / maxCount) * 100}%`, minHeight: d.count > 0 ? "4px" : "0" }}
|
<div className="absolute bottom-full mb-1 left-1/2 -translate-x-1/2 bg-gray-700 text-white text-xs px-1.5 py-0.5 rounded opacity-0 group-hover:opacity-100 whitespace-nowrap pointer-events-none z-10">
|
||||||
/>
|
{d.count}
|
||||||
<div className="text-[8px] text-gray-500 truncate w-full text-center">
|
</div>
|
||||||
|
)}
|
||||||
|
<div
|
||||||
|
className={`w-full ${barColor} rounded-t opacity-80`}
|
||||||
|
style={{ height: `${(d.count / maxCount) * 100}%`, minHeight: d.count > 0 ? "4px" : "0" }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="text-[7px] text-gray-600 truncate w-full text-center">
|
||||||
{d.date?.slice(5) || ""}
|
{d.date?.slice(5) || ""}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
{data.length === 0 && (
|
{data.length === 0 && (
|
||||||
<div className="h-40 flex items-center justify-center text-gray-500">
|
<div className="h-36 flex items-center justify-center text-gray-600 text-sm">
|
||||||
No data available
|
No activity in this period
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,12 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { Button, Input } from "@/components/ui";
|
import { Button, Input } from "@/components/ui";
|
||||||
|
|
||||||
interface Settings {
|
interface PlatformInfo {
|
||||||
proPrice: number;
|
proFamilies: number;
|
||||||
|
freeFamilies: number;
|
||||||
|
totalUsers: number;
|
||||||
freeMaxChildren: number;
|
freeMaxChildren: number;
|
||||||
freeMaxMembers: number;
|
freeMaxMembers: number;
|
||||||
aiModel: string;
|
aiModel: string;
|
||||||
|
|
@ -12,97 +14,177 @@ interface Settings {
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function AdminSettings() {
|
export default function AdminSettings() {
|
||||||
const [settings, setSettings] = useState<Settings>({
|
const [info, setInfo] = useState<PlatformInfo | null>(null);
|
||||||
proPrice: 9.99,
|
const [loading, setLoading] = useState(true);
|
||||||
freeMaxChildren: 1,
|
const [freeMaxChildren, setFreeMaxChildren] = useState(1);
|
||||||
freeMaxMembers: 2,
|
const [freeMaxMembers, setFreeMaxMembers] = useState(2);
|
||||||
aiModel: "minimax-2.7",
|
const [saving, setSaving] = useState(false);
|
||||||
aiBaseUrl: "https://llm.manohargupta.com",
|
|
||||||
});
|
|
||||||
const [saved, setSaved] = useState(false);
|
const [saved, setSaved] = useState(false);
|
||||||
|
|
||||||
const handleSave = async () => {
|
useEffect(() => {
|
||||||
setSaved(true);
|
fetchInfo();
|
||||||
setTimeout(() => setSaved(false), 2000);
|
}, []);
|
||||||
|
|
||||||
|
const fetchInfo = async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/admin/stats", { credentials: "include" });
|
||||||
|
const data = await res.json();
|
||||||
|
setFreeMaxChildren(1);
|
||||||
|
setFreeMaxMembers(2);
|
||||||
|
setInfo({
|
||||||
|
proFamilies: data.overview?.proFamilies || 0,
|
||||||
|
freeFamilies: data.overview?.freeFamilies || 0,
|
||||||
|
totalUsers: data.overview?.totalUsers || 0,
|
||||||
|
freeMaxChildren: 1,
|
||||||
|
freeMaxMembers: 2,
|
||||||
|
aiModel: "minimax-2.7",
|
||||||
|
aiBaseUrl: "https://llm.manohargupta.com",
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to fetch:", err);
|
||||||
|
}
|
||||||
|
setLoading(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleSaveLimits = async () => {
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
// Apply new limits to all free-tier families
|
||||||
|
const res = await fetch("/api/admin/families/limits", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
credentials: "include",
|
||||||
|
body: JSON.stringify({ maxChildren: freeMaxChildren, maxMembers: freeMaxMembers }),
|
||||||
|
});
|
||||||
|
if (res.ok) {
|
||||||
|
setSaved(true);
|
||||||
|
setTimeout(() => setSaved(false), 3000);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to save:", err);
|
||||||
|
}
|
||||||
|
setSaving(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading || !info) return <div className="p-6 text-white">Loading...</div>;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-6 space-y-6">
|
<div className="p-6 space-y-6">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold">Settings</h1>
|
<h1 className="text-2xl font-bold">Settings</h1>
|
||||||
<p className="text-gray-400">Platform configuration</p>
|
<p className="text-gray-400">Platform configuration and limits</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Pricing */}
|
{/* Platform Summary */}
|
||||||
<div className="bg-gray-800 p-6 rounded-xl space-y-4">
|
<div className="grid grid-cols-3 gap-4">
|
||||||
<h3 className="text-lg font-semibold">Pricing</h3>
|
<div className="bg-gray-800 p-4 rounded-xl text-center">
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<div className="text-2xl font-bold text-rose-400">{info.proFamilies}</div>
|
||||||
<div>
|
<div className="text-xs text-gray-400 mt-1">Pro Families</div>
|
||||||
<label className="block text-sm text-gray-400 mb-1">Pro Monthly Price</label>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="bg-gray-800 p-4 rounded-xl text-center">
|
||||||
<span className="text-gray-400">$</span>
|
<div className="text-2xl font-bold text-gray-400">{info.freeFamilies}</div>
|
||||||
<Input
|
<div className="text-xs text-gray-400 mt-1">Free Families</div>
|
||||||
type="number"
|
</div>
|
||||||
step="0.01"
|
<div className="bg-gray-800 p-4 rounded-xl text-center">
|
||||||
value={settings.proPrice}
|
<div className="text-2xl font-bold text-blue-400">{info.totalUsers}</div>
|
||||||
onChange={(e) => setSettings({ ...settings, proPrice: Number(e.target.value) })}
|
<div className="text-xs text-gray-400 mt-1">Total Users</div>
|
||||||
className="flex-1"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Limits */}
|
{/* Pricing — read-only display */}
|
||||||
|
<div className="bg-gray-800 p-6 rounded-xl space-y-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h3 className="text-lg font-semibold">Pricing</h3>
|
||||||
|
<span className="text-xs text-gray-500 bg-gray-700 px-2 py-1 rounded">Set via env var</span>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div className="bg-gray-700 px-4 py-3 rounded-lg">
|
||||||
|
<div className="text-xs text-gray-500 mb-1">Pro Monthly Price</div>
|
||||||
|
<div className="text-xl font-bold text-emerald-400">$9.99 / month</div>
|
||||||
|
</div>
|
||||||
|
<div className="bg-gray-700 px-4 py-3 rounded-lg">
|
||||||
|
<div className="text-xs text-gray-500 mb-1">Annual Run Rate</div>
|
||||||
|
<div className="text-xl font-bold text-emerald-400">
|
||||||
|
${(info.proFamilies * 9.99 * 12).toFixed(0)} / year
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-gray-600">
|
||||||
|
To change pricing, update <code className="text-gray-400">PRO_PRICE</code> in Dokploy dashboard and redeploy.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Free Tier Limits — editable */}
|
||||||
<div className="bg-gray-800 p-6 rounded-xl space-y-4">
|
<div className="bg-gray-800 p-6 rounded-xl space-y-4">
|
||||||
<h3 className="text-lg font-semibold">Free Tier Limits</h3>
|
<div className="flex items-center justify-between">
|
||||||
|
<h3 className="text-lg font-semibold">Free Tier Limits</h3>
|
||||||
|
<span className="text-xs text-emerald-400 bg-emerald-400/10 px-2 py-1 rounded">Editable</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-gray-500">
|
||||||
|
These limits are applied to all free-tier families. Existing families will be updated.
|
||||||
|
</p>
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm text-gray-400 mb-1">Max Children</label>
|
<label className="block text-sm text-gray-400 mb-1">Max Children per Family</label>
|
||||||
<Input
|
<Input
|
||||||
type="number"
|
type="number"
|
||||||
value={settings.freeMaxChildren}
|
min={1}
|
||||||
onChange={(e) => setSettings({ ...settings, freeMaxChildren: Number(e.target.value) })}
|
max={10}
|
||||||
|
value={freeMaxChildren}
|
||||||
|
onChange={(e) => setFreeMaxChildren(Number(e.target.value))}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm text-gray-400 mb-1">Max Family Members</label>
|
<label className="block text-sm text-gray-400 mb-1">Max Family Members</label>
|
||||||
<Input
|
<Input
|
||||||
type="number"
|
type="number"
|
||||||
value={settings.freeMaxMembers}
|
min={1}
|
||||||
onChange={(e) => setSettings({ ...settings, freeMaxMembers: Number(e.target.value) })}
|
max={10}
|
||||||
|
value={freeMaxMembers}
|
||||||
|
onChange={(e) => setFreeMaxMembers(Number(e.target.value))}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<Button onClick={handleSaveLimits} disabled={saving}>
|
||||||
|
{saved ? "✓ Saved" : saving ? "Saving…" : "Apply to All Free Families"}
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* AI Settings */}
|
{/* AI Configuration — read-only */}
|
||||||
<div className="bg-gray-800 p-6 rounded-xl space-y-4">
|
<div className="bg-gray-800 p-6 rounded-xl space-y-3">
|
||||||
<h3 className="text-lg font-semibold">AI Configuration</h3>
|
<div className="flex items-center justify-between">
|
||||||
|
<h3 className="text-lg font-semibold">AI Configuration</h3>
|
||||||
|
<span className="text-xs text-gray-500 bg-gray-700 px-2 py-1 rounded">Set via env var</span>
|
||||||
|
</div>
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
<div>
|
<div className="bg-gray-700 px-4 py-3 rounded-lg">
|
||||||
<label className="block text-sm text-gray-400 mb-1">Model</label>
|
<div className="text-xs text-gray-500 mb-1">Model</div>
|
||||||
<Input
|
<div className="font-mono text-sm text-blue-400">{info.aiModel}</div>
|
||||||
type="text"
|
|
||||||
value={settings.aiModel}
|
|
||||||
onChange={(e) => setSettings({ ...settings, aiModel: e.target.value })}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div className="bg-gray-700 px-4 py-3 rounded-lg">
|
||||||
<label className="block text-sm text-gray-400 mb-1">Base URL</label>
|
<div className="text-xs text-gray-500 mb-1">LiteLLM Gateway</div>
|
||||||
<Input
|
<div className="font-mono text-sm text-blue-400 truncate">{info.aiBaseUrl}</div>
|
||||||
type="text"
|
</div>
|
||||||
value={settings.aiBaseUrl}
|
</div>
|
||||||
onChange={(e) => setSettings({ ...settings, aiBaseUrl: e.target.value })}
|
<p className="text-xs text-gray-600">
|
||||||
/>
|
To change AI settings, update <code className="text-gray-400">LITELLM_BASE_URL</code> and <code className="text-gray-400">LITELLM_API_KEY</code> in Dokploy.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Admin Access */}
|
||||||
|
<div className="bg-gray-800 p-6 rounded-xl space-y-3">
|
||||||
|
<h3 className="text-lg font-semibold">Admin Access</h3>
|
||||||
|
<p className="text-sm text-gray-400">
|
||||||
|
Admin login: <code className="text-gray-300">/admin-login</code><br />
|
||||||
|
Password changes must be made directly in the database via the <code className="text-gray-300">admins</code> table.
|
||||||
|
</p>
|
||||||
|
<div className="bg-amber-500/10 border border-amber-500/20 rounded-lg px-4 py-3">
|
||||||
|
<div className="text-amber-400 text-sm font-medium">Security Reminder</div>
|
||||||
|
<div className="text-gray-400 text-xs mt-1">
|
||||||
|
Change the default admin password immediately if still using <code>admin123</code>.
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Save */}
|
|
||||||
<Button fullWidth size="lg" onClick={handleSave}>
|
|
||||||
{saved ? "Saved!" : "Save Settings"}
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -14,16 +14,29 @@ interface Ticket {
|
||||||
familyName: string;
|
familyName: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface Response {
|
||||||
|
id: string;
|
||||||
|
message: string;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
export default function AdminSupport() {
|
export default function AdminSupport() {
|
||||||
const [tickets, setTickets] = useState<Ticket[]>([]);
|
const [tickets, setTickets] = useState<Ticket[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [statusFilter, setStatusFilter] = useState("all");
|
const [statusFilter, setStatusFilter] = useState("all");
|
||||||
const [selectedTicket, setSelectedTicket] = useState<Ticket | null>(null);
|
const [selectedTicket, setSelectedTicket] = useState<Ticket | null>(null);
|
||||||
|
const [responses, setResponses] = useState<Response[]>([]);
|
||||||
|
const [replyText, setReplyText] = useState("");
|
||||||
|
const [replying, setReplying] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchTickets();
|
fetchTickets();
|
||||||
}, [statusFilter]);
|
}, [statusFilter]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (selectedTicket) fetchResponses(selectedTicket.id);
|
||||||
|
}, [selectedTicket]);
|
||||||
|
|
||||||
const fetchTickets = async () => {
|
const fetchTickets = async () => {
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`/api/admin/support?status=${statusFilter}`, { credentials: "include" });
|
const res = await fetch(`/api/admin/support?status=${statusFilter}`, { credentials: "include" });
|
||||||
|
|
@ -35,115 +48,226 @@ export default function AdminSupport() {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const fetchResponses = async (ticketId: string) => {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/admin/support?ticketId=${ticketId}`, { credentials: "include" });
|
||||||
|
const data = await res.json();
|
||||||
|
setResponses(data.responses || []);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to fetch responses:", err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const updateStatus = async (ticketId: string, status: string) => {
|
const updateStatus = async (ticketId: string, status: string) => {
|
||||||
try {
|
try {
|
||||||
await fetch(`/api/admin/support`, {
|
await fetch("/api/admin/support", {
|
||||||
method: "PATCH",
|
method: "PATCH",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
credentials: "include",
|
credentials: "include",
|
||||||
body: JSON.stringify({ ticketId, status }),
|
body: JSON.stringify({ ticketId, status }),
|
||||||
});
|
});
|
||||||
fetchTickets();
|
fetchTickets();
|
||||||
|
if (selectedTicket?.id === ticketId) {
|
||||||
|
setSelectedTicket(prev => prev ? { ...prev, status } : null);
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Failed to update ticket:", err);
|
console.error("Failed to update ticket:", err);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleReply = async () => {
|
||||||
|
if (!replyText.trim() || !selectedTicket) return;
|
||||||
|
setReplying(true);
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/admin/support", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
credentials: "include",
|
||||||
|
body: JSON.stringify({ ticketId: selectedTicket.id, message: replyText }),
|
||||||
|
});
|
||||||
|
if (res.ok) {
|
||||||
|
setReplyText("");
|
||||||
|
fetchResponses(selectedTicket.id);
|
||||||
|
fetchTickets();
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to send reply:", err);
|
||||||
|
}
|
||||||
|
setReplying(false);
|
||||||
|
};
|
||||||
|
|
||||||
const priorityVariant = (p: string): "rose" | "warning" | "default" =>
|
const priorityVariant = (p: string): "rose" | "warning" | "default" =>
|
||||||
p === "urgent" ? "rose" : p === "high" ? "warning" : "default";
|
p === "urgent" ? "rose" : p === "high" ? "warning" : "default";
|
||||||
|
|
||||||
const statusVariant = (s: string): "rose" | "warning" | "default" =>
|
const statusVariant = (s: string): "rose" | "warning" | "default" =>
|
||||||
s === "open" ? "rose" : s === "in_progress" ? "warning" : "default";
|
s === "open" ? "rose" : s === "in_progress" ? "warning" : "default";
|
||||||
|
|
||||||
if (loading) {
|
const statusCounts = tickets.reduce((acc: Record<string, number>, t) => {
|
||||||
return <div className="p-6 text-white">Loading...</div>;
|
acc[t.status] = (acc[t.status] || 0) + 1;
|
||||||
|
return acc;
|
||||||
|
}, {});
|
||||||
|
|
||||||
|
function timeAgo(iso: string) {
|
||||||
|
const diff = Date.now() - new Date(iso).getTime();
|
||||||
|
const mins = Math.floor(diff / 60000);
|
||||||
|
if (mins < 60) return `${mins}m ago`;
|
||||||
|
const hrs = Math.floor(mins / 60);
|
||||||
|
if (hrs < 24) return `${hrs}h ago`;
|
||||||
|
return `${Math.floor(hrs / 24)}d ago`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (loading) return <div className="p-6 text-white">Loading...</div>;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-6 space-y-4">
|
<div className="p-6 space-y-4">
|
||||||
<div className="flex justify-between items-center">
|
<div className="flex justify-between items-center">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold">Support</h1>
|
<h1 className="text-2xl font-bold">Support</h1>
|
||||||
<p className="text-gray-400">{tickets.length} tickets</p>
|
<p className="text-gray-400">
|
||||||
|
{tickets.length} tickets
|
||||||
|
{statusCounts["open"] ? ` · ` : ""}
|
||||||
|
{statusCounts["open"] ? <span className="text-rose-400">{statusCounts["open"]} open</span> : null}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Filters */}
|
{/* Status Filter */}
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2 flex-wrap">
|
||||||
{["all", "open", "in_progress", "resolved", "closed"].map((status) => (
|
{[
|
||||||
|
{ key: "all", label: "All" },
|
||||||
|
{ key: "open", label: `Open${statusCounts["open"] ? ` (${statusCounts["open"]})` : ""}` },
|
||||||
|
{ key: "in_progress", label: "In Progress" },
|
||||||
|
{ key: "resolved", label: "Resolved" },
|
||||||
|
{ key: "closed", label: "Closed" },
|
||||||
|
].map(({ key, label }) => (
|
||||||
<Button
|
<Button
|
||||||
key={status}
|
key={key}
|
||||||
size="sm"
|
size="sm"
|
||||||
variant={statusFilter === status ? "primary" : "secondary"}
|
variant={statusFilter === key ? "primary" : "secondary"}
|
||||||
onClick={() => setStatusFilter(status)}
|
onClick={() => setStatusFilter(key)}
|
||||||
>
|
>
|
||||||
{status.replace("_", " ")}
|
{label}
|
||||||
</Button>
|
</Button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Tickets List */}
|
<div className="grid grid-cols-1 md:grid-cols-5 gap-4" style={{ height: "calc(100vh - 280px)", minHeight: "400px" }}>
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
{/* Ticket List */}
|
||||||
<div className="space-y-2">
|
<div className="md:col-span-2 space-y-2 overflow-y-auto pr-1">
|
||||||
{tickets.map((ticket) => (
|
{tickets.map((ticket) => (
|
||||||
<div
|
<div
|
||||||
key={ticket.id}
|
key={ticket.id}
|
||||||
onClick={() => setSelectedTicket(ticket)}
|
onClick={() => { setSelectedTicket(ticket); setResponses([]); }}
|
||||||
className={`p-4 rounded-xl cursor-pointer ${
|
className={`p-4 rounded-xl cursor-pointer transition-colors ${
|
||||||
selectedTicket?.id === ticket.id ? "bg-rose-500/20 border border-rose-500" : "bg-gray-800"
|
selectedTicket?.id === ticket.id
|
||||||
|
? "bg-rose-500/20 border border-rose-500/50"
|
||||||
|
: "bg-gray-800 hover:bg-gray-750 border border-transparent"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<div className="flex justify-between items-start mb-2">
|
<div className="flex justify-between items-start mb-1.5">
|
||||||
<Badge variant={priorityVariant(ticket.priority)}>{ticket.priority}</Badge>
|
<Badge variant={priorityVariant(ticket.priority)}>{ticket.priority}</Badge>
|
||||||
<Badge variant={statusVariant(ticket.status)}>{ticket.status.replace("_", " ")}</Badge>
|
<Badge variant={statusVariant(ticket.status)}>{ticket.status.replace("_", " ")}</Badge>
|
||||||
</div>
|
</div>
|
||||||
<div className="font-medium">{ticket.subject}</div>
|
<div className="font-medium text-sm">{ticket.subject}</div>
|
||||||
<div className="text-sm text-gray-400">{ticket.email}</div>
|
<div className="text-xs text-gray-400 mt-0.5">{ticket.email}</div>
|
||||||
<div className="text-xs text-gray-500 mt-2">
|
{ticket.familyName && (
|
||||||
{ticket.createdAt?.slice(0, 10)}
|
<div className="text-xs text-gray-600 mt-0.5">Family: {ticket.familyName}</div>
|
||||||
</div>
|
)}
|
||||||
|
<div className="text-xs text-gray-600 mt-1.5">{timeAgo(ticket.createdAt)}</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
{tickets.length === 0 && (
|
{tickets.length === 0 && (
|
||||||
<div className="p-8 text-center text-gray-500">No tickets found</div>
|
<div className="p-8 text-center text-gray-500">No tickets</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Ticket Detail */}
|
{/* Ticket Detail + Reply */}
|
||||||
<div className="bg-gray-800 rounded-xl p-4">
|
<div className="md:col-span-3 bg-gray-800 rounded-xl flex flex-col overflow-hidden">
|
||||||
{selectedTicket ? (
|
{selectedTicket ? (
|
||||||
<div className="space-y-4">
|
<>
|
||||||
<div>
|
{/* Ticket header */}
|
||||||
<div className="font-medium text-lg">{selectedTicket.subject}</div>
|
<div className="p-4 border-b border-gray-700">
|
||||||
<div className="text-sm text-gray-400">{selectedTicket.email}</div>
|
<div className="flex justify-between items-start">
|
||||||
<div className="text-xs text-gray-500">
|
<div>
|
||||||
{selectedTicket.createdAt?.slice(0, 10)} · {selectedTicket.familyName}
|
<div className="font-semibold">{selectedTicket.subject}</div>
|
||||||
|
<div className="text-sm text-gray-400">{selectedTicket.email}</div>
|
||||||
|
{selectedTicket.familyName && (
|
||||||
|
<div className="text-xs text-gray-500 mt-0.5">Family: {selectedTicket.familyName}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2 flex-shrink-0">
|
||||||
|
{selectedTicket.status === "open" && (
|
||||||
|
<Button size="sm" variant="secondary" onClick={() => updateStatus(selectedTicket.id, "in_progress")}>
|
||||||
|
Start
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{selectedTicket.status === "in_progress" && (
|
||||||
|
<Button size="sm" onClick={() => updateStatus(selectedTicket.id, "resolved")}>
|
||||||
|
Resolve
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{selectedTicket.status === "resolved" && (
|
||||||
|
<Button size="sm" variant="secondary" onClick={() => updateStatus(selectedTicket.id, "closed")}>
|
||||||
|
Close
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{selectedTicket.status === "closed" && (
|
||||||
|
<Button size="sm" variant="secondary" onClick={() => updateStatus(selectedTicket.id, "open")}>
|
||||||
|
Reopen
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="p-3 bg-gray-700 rounded-lg">
|
|
||||||
{selectedTicket.description}
|
{/* Conversation thread */}
|
||||||
|
<div className="flex-1 overflow-y-auto p-4 space-y-3">
|
||||||
|
{/* Original message */}
|
||||||
|
<div className="bg-gray-700 rounded-xl p-3">
|
||||||
|
<div className="text-xs text-gray-500 mb-1.5 flex justify-between">
|
||||||
|
<span>{selectedTicket.email}</span>
|
||||||
|
<span>{timeAgo(selectedTicket.createdAt)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-sm whitespace-pre-wrap">{selectedTicket.description}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Admin responses */}
|
||||||
|
{responses.map((r) => (
|
||||||
|
<div key={r.id} className="bg-rose-500/15 border border-rose-500/20 rounded-xl p-3 ml-6">
|
||||||
|
<div className="text-xs text-gray-500 mb-1.5 flex justify-between">
|
||||||
|
<span className="text-rose-400">Admin</span>
|
||||||
|
<span>{timeAgo(r.createdAt)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-sm whitespace-pre-wrap">{r.message}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2">
|
|
||||||
{selectedTicket.status === "open" && (
|
{/* Reply box */}
|
||||||
<Button size="sm" variant="secondary" onClick={() => updateStatus(selectedTicket.id, "in_progress")}>
|
{selectedTicket.status !== "closed" && (
|
||||||
Start
|
<div className="p-3 border-t border-gray-700">
|
||||||
</Button>
|
<textarea
|
||||||
)}
|
value={replyText}
|
||||||
{selectedTicket.status === "in_progress" && (
|
onChange={(e) => setReplyText(e.target.value)}
|
||||||
<Button size="sm" onClick={() => updateStatus(selectedTicket.id, "resolved")}>
|
placeholder="Type your reply…"
|
||||||
Resolve
|
rows={3}
|
||||||
</Button>
|
className="w-full bg-gray-700 text-white text-sm rounded-lg px-3 py-2 resize-none outline-none focus:ring-1 focus:ring-rose-500 placeholder-gray-500"
|
||||||
)}
|
onKeyDown={(e) => {
|
||||||
{selectedTicket.status === "resolved" && (
|
if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) handleReply();
|
||||||
<Button size="sm" variant="secondary" onClick={() => updateStatus(selectedTicket.id, "closed")}>
|
}}
|
||||||
Close
|
/>
|
||||||
</Button>
|
<div className="flex justify-between items-center mt-2">
|
||||||
)}
|
<span className="text-xs text-gray-600">Cmd+Enter to send</span>
|
||||||
</div>
|
<Button size="sm" onClick={handleReply} disabled={!replyText.trim() || replying}>
|
||||||
</div>
|
{replying ? "Sending…" : "Send Reply"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
) : (
|
) : (
|
||||||
<div className="h-full flex items-center justify-center text-gray-500">
|
<div className="flex-1 flex items-center justify-center text-gray-600">
|
||||||
Select a ticket to view details
|
Select a ticket to view the conversation
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
99
src/app/api/admin/activity/route.ts
Normal file
99
src/app/api/admin/activity/route.ts
Normal file
|
|
@ -0,0 +1,99 @@
|
||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { requireAdmin } from "@/lib/admin-auth";
|
||||||
|
import { sql } from "@/db";
|
||||||
|
|
||||||
|
export async function GET(request: Request) {
|
||||||
|
const auth = await requireAdmin(request);
|
||||||
|
if (!auth.success) return NextResponse.json({ error: auth.error }, { status: auth.status });
|
||||||
|
|
||||||
|
const { searchParams } = new URL(request.url);
|
||||||
|
const action = searchParams.get("action") || "all";
|
||||||
|
const limit = Math.min(parseInt(searchParams.get("limit") || "100"), 500);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const [activeSessions, loginsToday, failedToday, signupsWeek] = await Promise.all([
|
||||||
|
sql`SELECT COUNT(*)::int as count FROM sessions WHERE expires > NOW()`,
|
||||||
|
sql`SELECT COUNT(*)::int as count FROM audit_log WHERE action = 'login' AND created_at > NOW() - INTERVAL '24 hours'`,
|
||||||
|
sql`SELECT COUNT(*)::int as count FROM audit_log WHERE action = 'login_failed' AND created_at > NOW() - INTERVAL '24 hours'`,
|
||||||
|
sql`SELECT COUNT(*)::int as count FROM audit_log WHERE action = 'signup' AND created_at > NOW() - INTERVAL '7 days'`,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const loginsByDay = await sql`
|
||||||
|
SELECT DATE(created_at) as date, COUNT(*)::int as count
|
||||||
|
FROM audit_log
|
||||||
|
WHERE action IN ('login', 'signup') AND created_at > NOW() - INTERVAL '30 days'
|
||||||
|
GROUP BY DATE(created_at)
|
||||||
|
ORDER BY date
|
||||||
|
`;
|
||||||
|
|
||||||
|
const failedByDay = await sql`
|
||||||
|
SELECT DATE(created_at) as date, COUNT(*)::int as count
|
||||||
|
FROM audit_log
|
||||||
|
WHERE action = 'login_failed' AND created_at > NOW() - INTERVAL '30 days'
|
||||||
|
GROUP BY DATE(created_at)
|
||||||
|
ORDER BY date
|
||||||
|
`;
|
||||||
|
|
||||||
|
let events;
|
||||||
|
if (action === "all") {
|
||||||
|
events = await sql`
|
||||||
|
SELECT
|
||||||
|
al.id, al.action, al.ip_address, al.user_agent, al.metadata, al.created_at,
|
||||||
|
u.email, u.name as user_name,
|
||||||
|
f.name as family_name
|
||||||
|
FROM audit_log al
|
||||||
|
LEFT JOIN users u ON u.id = al.user_id
|
||||||
|
LEFT JOIN families f ON f.id = al.family_id
|
||||||
|
ORDER BY al.created_at DESC
|
||||||
|
LIMIT ${limit}
|
||||||
|
`;
|
||||||
|
} else {
|
||||||
|
events = await sql`
|
||||||
|
SELECT
|
||||||
|
al.id, al.action, al.ip_address, al.user_agent, al.metadata, al.created_at,
|
||||||
|
u.email, u.name as user_name,
|
||||||
|
f.name as family_name
|
||||||
|
FROM audit_log al
|
||||||
|
LEFT JOIN users u ON u.id = al.user_id
|
||||||
|
LEFT JOIN families f ON f.id = al.family_id
|
||||||
|
WHERE al.action = ${action}
|
||||||
|
ORDER BY al.created_at DESC
|
||||||
|
LIMIT ${limit}
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatDate = (d: any) =>
|
||||||
|
d instanceof Date ? d.toISOString().split("T")[0] : String(d);
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
stats: {
|
||||||
|
activeSessions: activeSessions[0]?.count || 0,
|
||||||
|
loginsToday: loginsToday[0]?.count || 0,
|
||||||
|
failedToday: failedToday[0]?.count || 0,
|
||||||
|
signupsWeek: signupsWeek[0]?.count || 0,
|
||||||
|
},
|
||||||
|
loginsByDay: loginsByDay.map((r: any) => ({
|
||||||
|
date: formatDate(r.date),
|
||||||
|
count: r.count,
|
||||||
|
})),
|
||||||
|
failedByDay: failedByDay.map((r: any) => ({
|
||||||
|
date: formatDate(r.date),
|
||||||
|
count: r.count,
|
||||||
|
})),
|
||||||
|
events: events.map((e: any) => ({
|
||||||
|
id: e.id,
|
||||||
|
action: e.action,
|
||||||
|
email: e.email || "unknown",
|
||||||
|
userName: e.user_name,
|
||||||
|
familyName: e.family_name,
|
||||||
|
ipAddress: e.ip_address,
|
||||||
|
userAgent: e.user_agent,
|
||||||
|
metadata: e.metadata,
|
||||||
|
createdAt: e.created_at instanceof Date ? e.created_at.toISOString() : e.created_at,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Admin activity error:", error);
|
||||||
|
return NextResponse.json({ error: String(error) }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -26,6 +26,10 @@ export async function GET(request: Request) {
|
||||||
// Get chat session counts
|
// Get chat session counts
|
||||||
const chatSessionsCount = await sql`SELECT COUNT(*) as count FROM chat_sessions`;
|
const chatSessionsCount = await sql`SELECT COUNT(*) as count FROM chat_sessions`;
|
||||||
|
|
||||||
|
// Get family count for averages
|
||||||
|
const familyCountResult = await sql`SELECT COUNT(*)::int as count FROM families`;
|
||||||
|
const familyCount = familyCountResult[0]?.count || 1;
|
||||||
|
|
||||||
// Get logs by day
|
// Get logs by day
|
||||||
const logsByDay = await sql`
|
const logsByDay = await sql`
|
||||||
SELECT DATE(created_at) as date, COUNT(*) as count
|
SELECT DATE(created_at) as date, COUNT(*) as count
|
||||||
|
|
@ -52,6 +56,7 @@ export async function GET(request: Request) {
|
||||||
totalGrowth: Number(growthCount[0]?.count || 0),
|
totalGrowth: Number(growthCount[0]?.count || 0),
|
||||||
totalMemories: Number(memoriesCount[0]?.count || 0),
|
totalMemories: Number(memoriesCount[0]?.count || 0),
|
||||||
totalChatSessions: Number(chatSessionsCount[0]?.count || 0),
|
totalChatSessions: Number(chatSessionsCount[0]?.count || 0),
|
||||||
|
familyCount,
|
||||||
logsByDay: logsByDay.map((r: any) => ({
|
logsByDay: logsByDay.map((r: any) => ({
|
||||||
date: r.date?.toISOString().split("T")[0],
|
date: r.date?.toISOString().split("T")[0],
|
||||||
count: Number(r.count),
|
count: Number(r.count),
|
||||||
|
|
|
||||||
30
src/app/api/admin/families/limits/route.ts
Normal file
30
src/app/api/admin/families/limits/route.ts
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { requireAdmin } from "@/lib/admin-auth";
|
||||||
|
import { sql } from "@/db";
|
||||||
|
|
||||||
|
// Apply default limits to all free-tier families
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
const auth = await requireAdmin(request);
|
||||||
|
if (!auth.success) return NextResponse.json({ error: auth.error }, { status: auth.status });
|
||||||
|
|
||||||
|
try {
|
||||||
|
const body = await request.json();
|
||||||
|
const { maxChildren, maxMembers } = body;
|
||||||
|
|
||||||
|
if (!maxChildren || !maxMembers) {
|
||||||
|
return NextResponse.json({ error: "maxChildren and maxMembers required" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await sql`
|
||||||
|
UPDATE families
|
||||||
|
SET max_children = ${maxChildren}, max_members = ${maxMembers}, updated_at = NOW()
|
||||||
|
WHERE tier = 'free'
|
||||||
|
RETURNING id
|
||||||
|
`;
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true, updated: result.length });
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Limits update error:", error);
|
||||||
|
return NextResponse.json({ error: String(error) }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -6,29 +6,93 @@ export async function GET(request: Request) {
|
||||||
const auth = await requireAdmin(request);
|
const auth = await requireAdmin(request);
|
||||||
if (!auth.success) return NextResponse.json({ error: auth.error }, { status: auth.status });
|
if (!auth.success) return NextResponse.json({ error: auth.error }, { status: auth.status });
|
||||||
|
|
||||||
|
const { searchParams } = new URL(request.url);
|
||||||
|
const period = parseInt(searchParams.get("period") || "30");
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const familyCount = await sql`SELECT COUNT(*)::int as count FROM families`;
|
const [familyCount, userCount, childCount, tierStats, activeSessionsResult] = await Promise.all([
|
||||||
const userCount = await sql`SELECT COUNT(*)::int as count FROM users`;
|
sql`SELECT COUNT(*)::int as count FROM families`,
|
||||||
const childCount = await sql`SELECT COUNT(*)::int as count FROM children`;
|
sql`SELECT COUNT(*)::int as count FROM users`,
|
||||||
const tierStats = await sql`SELECT tier, COUNT(*)::int as count FROM families GROUP BY tier`;
|
sql`SELECT COUNT(*)::int as count FROM children`,
|
||||||
|
sql`SELECT tier, COUNT(*)::int as count FROM families GROUP BY tier`,
|
||||||
|
sql`SELECT COUNT(*)::int as count FROM sessions WHERE expires > NOW()`,
|
||||||
|
]);
|
||||||
|
|
||||||
const proFamilies = tierStats.find((t: any) => t.tier === "pro")?.count || 0;
|
const proFamilies = tierStats.find((t: any) => t.tier === "pro")?.count || 0;
|
||||||
const freeFamilies = tierStats.find((t: any) => t.tier === "free")?.count || 0;
|
const freeFamilies = tierStats.find((t: any) => t.tier === "free")?.count || 0;
|
||||||
|
const totalFamilies = familyCount[0]?.count || 0;
|
||||||
const mrr = proFamilies * 9.99;
|
const mrr = proFamilies * 9.99;
|
||||||
|
|
||||||
|
const [familiesByDay, usersByDay, childrenByAge, recentLogins, failedLogins] = await Promise.all([
|
||||||
|
sql`
|
||||||
|
SELECT DATE(created_at) as date, COUNT(*)::int as count
|
||||||
|
FROM families
|
||||||
|
WHERE created_at > NOW() - (${period}::int * INTERVAL '1 day')
|
||||||
|
GROUP BY DATE(created_at)
|
||||||
|
ORDER BY date
|
||||||
|
`,
|
||||||
|
sql`
|
||||||
|
SELECT DATE(created_at) as date, COUNT(*)::int as count
|
||||||
|
FROM users
|
||||||
|
WHERE created_at > NOW() - (${period}::int * INTERVAL '1 day')
|
||||||
|
GROUP BY DATE(created_at)
|
||||||
|
ORDER BY date
|
||||||
|
`,
|
||||||
|
sql`
|
||||||
|
SELECT
|
||||||
|
EXTRACT(YEAR FROM AGE(birth_date))::int as age_years,
|
||||||
|
COUNT(*)::int as count
|
||||||
|
FROM children
|
||||||
|
WHERE birth_date IS NOT NULL
|
||||||
|
GROUP BY EXTRACT(YEAR FROM AGE(birth_date))::int
|
||||||
|
ORDER BY age_years
|
||||||
|
`,
|
||||||
|
sql`SELECT COUNT(*)::int as count FROM audit_log WHERE action = 'login' AND created_at > NOW() - INTERVAL '24 hours'`,
|
||||||
|
sql`SELECT COUNT(*)::int as count FROM audit_log WHERE action = 'login_failed' AND created_at > NOW() - INTERVAL '24 hours'`,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const formatDate = (d: any) =>
|
||||||
|
d instanceof Date ? d.toISOString().split("T")[0] : String(d);
|
||||||
|
|
||||||
|
const ageLabel = (years: number) => {
|
||||||
|
if (years === 0) return "< 1 yr";
|
||||||
|
if (years === 1) return "1 yr";
|
||||||
|
return `${years} yrs`;
|
||||||
|
};
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
overview: {
|
overview: {
|
||||||
totalFamilies: familyCount[0]?.count || 0,
|
totalFamilies,
|
||||||
totalUsers: userCount[0]?.count || 0,
|
totalUsers: userCount[0]?.count || 0,
|
||||||
totalChildren: childCount[0]?.count || 0,
|
totalChildren: childCount[0]?.count || 0,
|
||||||
proFamilies,
|
proFamilies,
|
||||||
freeFamilies,
|
freeFamilies,
|
||||||
mrr,
|
mrr,
|
||||||
avgRevenuePerUser: 0,
|
avgRevenuePerUser: totalFamilies > 0 ? parseFloat((mrr / totalFamilies).toFixed(2)) : 0,
|
||||||
|
activeSessions: activeSessionsResult[0]?.count || 0,
|
||||||
|
loginsLast24h: recentLogins[0]?.count || 0,
|
||||||
|
failedLoginsLast24h: failedLogins[0]?.count || 0,
|
||||||
},
|
},
|
||||||
conversions: { freeToPro: 0, conversionRate: 0 },
|
conversions: {
|
||||||
growth: { familiesByDay: [], usersByDay: [] },
|
freeToPro: proFamilies,
|
||||||
childrenByAge: [],
|
conversionRate: totalFamilies > 0
|
||||||
|
? parseFloat(((proFamilies / totalFamilies) * 100).toFixed(1))
|
||||||
|
: 0,
|
||||||
|
},
|
||||||
|
growth: {
|
||||||
|
familiesByDay: familiesByDay.map((r: any) => ({
|
||||||
|
date: formatDate(r.date),
|
||||||
|
count: r.count,
|
||||||
|
})),
|
||||||
|
usersByDay: usersByDay.map((r: any) => ({
|
||||||
|
date: formatDate(r.date),
|
||||||
|
count: r.count,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
childrenByAge: childrenByAge.map((r: any) => ({
|
||||||
|
ageGroup: ageLabel(r.age_years),
|
||||||
|
count: r.count,
|
||||||
|
})),
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Admin stats error:", error);
|
console.error("Admin stats error:", error);
|
||||||
|
|
|
||||||
|
|
@ -2,33 +2,44 @@ import { NextResponse } from "next/server";
|
||||||
import { requireAdmin } from "@/lib/admin-auth";
|
import { requireAdmin } from "@/lib/admin-auth";
|
||||||
import { sql } from "@/db";
|
import { sql } from "@/db";
|
||||||
|
|
||||||
|
// Get tickets or responses for a specific ticket
|
||||||
export async function GET(request: Request) {
|
export async function GET(request: Request) {
|
||||||
const auth = await requireAdmin(request);
|
const auth = await requireAdmin(request);
|
||||||
if (!auth.success) return NextResponse.json({ error: auth.error }, { status: auth.status });
|
if (!auth.success) return NextResponse.json({ error: auth.error }, { status: auth.status });
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|
||||||
const { searchParams } = new URL(request.url);
|
const { searchParams } = new URL(request.url);
|
||||||
|
const ticketId = searchParams.get("ticketId");
|
||||||
const status = searchParams.get("status") || "all";
|
const status = searchParams.get("status") || "all";
|
||||||
|
|
||||||
let tickets;
|
if (ticketId) {
|
||||||
if (status === "all") {
|
const responses = await sql`
|
||||||
tickets = await sql`
|
SELECT * FROM support_responses WHERE ticket_id = ${ticketId} ORDER BY created_at ASC
|
||||||
SELECT t.*, f.name as family_name
|
|
||||||
FROM support_tickets t
|
|
||||||
LEFT JOIN families f ON f.id = t.family_id
|
|
||||||
ORDER BY t.created_at DESC
|
|
||||||
`;
|
|
||||||
} else {
|
|
||||||
tickets = await sql`
|
|
||||||
SELECT t.*, f.name as family_name
|
|
||||||
FROM support_tickets t
|
|
||||||
LEFT JOIN families f ON f.id = t.family_id
|
|
||||||
WHERE t.status = ${status}
|
|
||||||
ORDER BY t.created_at DESC
|
|
||||||
`;
|
`;
|
||||||
|
return NextResponse.json({
|
||||||
|
responses: responses.map((r: any) => ({
|
||||||
|
id: r.id,
|
||||||
|
message: r.message,
|
||||||
|
createdAt: r.created_at instanceof Date ? r.created_at.toISOString() : r.created_at,
|
||||||
|
})),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const tickets = status === "all"
|
||||||
|
? await sql`
|
||||||
|
SELECT t.*, f.name as family_name
|
||||||
|
FROM support_tickets t
|
||||||
|
LEFT JOIN families f ON f.id = t.family_id
|
||||||
|
ORDER BY t.created_at DESC
|
||||||
|
`
|
||||||
|
: await sql`
|
||||||
|
SELECT t.*, f.name as family_name
|
||||||
|
FROM support_tickets t
|
||||||
|
LEFT JOIN families f ON f.id = t.family_id
|
||||||
|
WHERE t.status = ${status}
|
||||||
|
ORDER BY t.created_at DESC
|
||||||
|
`;
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
tickets: tickets.map((t: any) => ({
|
tickets: tickets.map((t: any) => ({
|
||||||
id: t.id,
|
id: t.id,
|
||||||
|
|
@ -37,7 +48,7 @@ export async function GET(request: Request) {
|
||||||
description: t.description,
|
description: t.description,
|
||||||
status: t.status,
|
status: t.status,
|
||||||
priority: t.priority,
|
priority: t.priority,
|
||||||
createdAt: t.created_at?.toISOString(),
|
createdAt: t.created_at instanceof Date ? t.created_at.toISOString() : t.created_at,
|
||||||
familyName: t.family_name,
|
familyName: t.family_name,
|
||||||
})),
|
})),
|
||||||
});
|
});
|
||||||
|
|
@ -53,44 +64,48 @@ export async function PATCH(request: Request) {
|
||||||
if (!auth.success) return NextResponse.json({ error: auth.error }, { status: auth.status });
|
if (!auth.success) return NextResponse.json({ error: auth.error }, { status: auth.status });
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|
||||||
const body = await request.json();
|
const body = await request.json();
|
||||||
const { ticketId, status } = body;
|
const { ticketId, status } = body;
|
||||||
|
if (!ticketId) return NextResponse.json({ error: "ticketId required" }, { status: 400 });
|
||||||
|
|
||||||
if (!ticketId) {
|
await sql`UPDATE support_tickets SET status = ${status}, updated_at = NOW() WHERE id = ${ticketId}`;
|
||||||
return NextResponse.json({ error: "ticketId required" }, { status: 400 });
|
|
||||||
}
|
|
||||||
|
|
||||||
await sql`
|
|
||||||
UPDATE support_tickets
|
|
||||||
SET status = ${status}, updated_at = NOW()
|
|
||||||
WHERE id = ${ticketId}
|
|
||||||
`;
|
|
||||||
|
|
||||||
return NextResponse.json({ success: true });
|
return NextResponse.json({ success: true });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return NextResponse.json({ error: String(error) }, { status: 500 });
|
return NextResponse.json({ error: String(error) }, { status: 500 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create ticket (for users)
|
// Create ticket or admin response
|
||||||
export async function POST(request: Request) {
|
export async function POST(request: Request) {
|
||||||
const auth = await requireAdmin(request);
|
const auth = await requireAdmin(request);
|
||||||
if (!auth.success) return NextResponse.json({ error: auth.error }, { status: auth.status });
|
if (!auth.success) return NextResponse.json({ error: auth.error }, { status: auth.status });
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const body = await request.json();
|
const body = await request.json();
|
||||||
const { familyId, userId, email, subject, description, priority } = body;
|
|
||||||
|
|
||||||
|
// Admin reply to a ticket
|
||||||
|
if (body.ticketId && body.message) {
|
||||||
|
const { ticketId, message } = body;
|
||||||
|
await sql`
|
||||||
|
INSERT INTO support_responses (id, ticket_id, message, created_at)
|
||||||
|
VALUES (${crypto.randomUUID()}, ${ticketId}, ${message}, NOW())
|
||||||
|
`;
|
||||||
|
await sql`
|
||||||
|
UPDATE support_tickets SET status = 'in_progress', updated_at = NOW()
|
||||||
|
WHERE id = ${ticketId} AND status = 'open'
|
||||||
|
`;
|
||||||
|
return NextResponse.json({ success: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a new ticket
|
||||||
|
const { familyId, userId, email, subject, description, priority } = body;
|
||||||
if (!email || !subject) {
|
if (!email || !subject) {
|
||||||
return NextResponse.json({ error: "email and subject required" }, { status: 400 });
|
return NextResponse.json({ error: "email and subject required" }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
await sql`
|
await sql`
|
||||||
INSERT INTO support_tickets (family_id, user_id, email, subject, description, priority)
|
INSERT INTO support_tickets (id, family_id, user_id, email, subject, description, priority, created_at, updated_at)
|
||||||
VALUES ${sql(familyId, userId, email, subject, description, priority || "normal")}
|
VALUES (${crypto.randomUUID()}, ${familyId || null}, ${userId || null}, ${email}, ${subject}, ${description || ""}, ${priority || "normal"}, NOW(), NOW())
|
||||||
`;
|
`;
|
||||||
|
|
||||||
return NextResponse.json({ success: true });
|
return NextResponse.json({ success: true });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return NextResponse.json({ error: String(error) }, { status: 500 });
|
return NextResponse.json({ error: String(error) }, { status: 500 });
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue