feat: admin orphaned family management + user journey analytics
- Orphaned families (0 members) now visible with amber badge in /admin/families with filter tab and explicit "Delete Family + Data" cascade delete button - Delete confirmation shows exact counts: children, logs, memories - Delete child button added to /admin/children with count confirmation - New DELETE /api/admin/families/[id] — full cascade delete (children, feeds, diapers_logs, sleeps, vaccinations, growth, memories, chat, etc.) - New GET/DELETE /api/admin/children/[id] — cascade child delete with counts - Extended families GET to include logCount + memoryCount per family - New /api/admin/engagement — feature adoption %, per-family engagement table, AI usage stats (30d), daily activity chart using correct table names - /admin/analytics fully redesigned: adoption funnel bars, per-family engagement table (sortable, filterable by activity), AI cost tab with INR breakdown - Fixes wrong table names in old analytics (activity_logs, growth_records → real tables) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
c2695435f2
commit
23a365309b
7 changed files with 721 additions and 115 deletions
|
|
@ -2,133 +2,313 @@
|
|||
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
interface EngagementStats {
|
||||
totalLogs: number;
|
||||
totalMedicines: number;
|
||||
totalVaccinations: number;
|
||||
totalGrowth: number;
|
||||
totalMemories: number;
|
||||
totalChatSessions: number;
|
||||
familyCount: number;
|
||||
logsByDay: { date: string; count: number }[];
|
||||
topFeatures: { name: string; count: number }[];
|
||||
interface FeatureAdoption {
|
||||
name: string;
|
||||
count: number;
|
||||
pct: number;
|
||||
}
|
||||
|
||||
interface FamilyEngagement {
|
||||
id: string;
|
||||
name: string;
|
||||
tier: string;
|
||||
createdAt: string | null;
|
||||
lastActivity: string | null;
|
||||
activeStatus: "7d" | "30d" | "inactive" | "never";
|
||||
feedCount: number;
|
||||
diaperCount: number;
|
||||
sleepCount: number;
|
||||
vaccinationCount: number;
|
||||
growthCount: number;
|
||||
memoryCount: number;
|
||||
chatCount: number;
|
||||
totalLogs: number;
|
||||
}
|
||||
|
||||
interface EngagementData {
|
||||
totalFamilies: number;
|
||||
featureAdoption: FeatureAdoption[];
|
||||
families: FamilyEngagement[];
|
||||
activitySummary: { active7d: number; active30d: number; neverActive: number; total: number };
|
||||
aiUsage: { totalCalls: number; totalTokens: number; totalCostPaise: number; familiesUsingAI: number };
|
||||
logsByDay: { date: string; count: number }[];
|
||||
}
|
||||
|
||||
type Tab = "overview" | "families" | "ai";
|
||||
type SortField = "lastActivity" | "totalLogs" | "name";
|
||||
type ActivityFilter = "all" | "7d" | "30d" | "inactive" | "never";
|
||||
|
||||
export default function AdminAnalytics() {
|
||||
const [stats, setStats] = useState<EngagementStats | null>(null);
|
||||
const [data, setData] = useState<EngagementData | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [tab, setTab] = useState<Tab>("overview");
|
||||
const [sortField, setSortField] = useState<SortField>("lastActivity");
|
||||
const [sortAsc, setSortAsc] = useState(false);
|
||||
const [activityFilter, setActivityFilter] = useState<ActivityFilter>("all");
|
||||
|
||||
useEffect(() => {
|
||||
fetchAnalytics();
|
||||
fetch("/api/admin/engagement", { credentials: "include" })
|
||||
.then(r => r.json())
|
||||
.then(d => { setData(d); setLoading(false); })
|
||||
.catch(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
const fetchAnalytics = async () => {
|
||||
try {
|
||||
const res = await fetch("/api/admin/analytics", { credentials: "include" });
|
||||
const data = await res.json();
|
||||
setStats(data);
|
||||
} catch (err) {
|
||||
console.error("Failed to fetch analytics:", err);
|
||||
}
|
||||
setLoading(false);
|
||||
if (loading) return <div className="p-6 text-white">Loading...</div>;
|
||||
if (!data) return <div className="p-6 text-red-400">Failed to load analytics</div>;
|
||||
|
||||
const handleSort = (field: SortField) => {
|
||||
if (sortField === field) setSortAsc(a => !a);
|
||||
else { setSortField(field); setSortAsc(false); }
|
||||
};
|
||||
|
||||
if (loading || !stats) {
|
||||
return <div className="p-6 text-white">Loading...</div>;
|
||||
}
|
||||
const sortedFamilies = [...data.families]
|
||||
.filter(f => activityFilter === "all" || f.activeStatus === activityFilter)
|
||||
.sort((a, b) => {
|
||||
let diff = 0;
|
||||
if (sortField === "lastActivity") {
|
||||
const ta = a.lastActivity ? new Date(a.lastActivity).getTime() : 0;
|
||||
const tb = b.lastActivity ? new Date(b.lastActivity).getTime() : 0;
|
||||
diff = tb - ta;
|
||||
} else if (sortField === "totalLogs") {
|
||||
diff = b.totalLogs - a.totalLogs;
|
||||
} else {
|
||||
diff = a.name.localeCompare(b.name);
|
||||
}
|
||||
return sortAsc ? -diff : diff;
|
||||
});
|
||||
|
||||
const maxLog = Math.max(...data.logsByDay.map(d => d.count), 1);
|
||||
const { aiUsage, activitySummary } = data;
|
||||
const costINR = (aiUsage.totalCostPaise / 100).toFixed(2);
|
||||
const costPerFamily = aiUsage.familiesUsingAI > 0
|
||||
? (aiUsage.totalCostPaise / 100 / aiUsage.familiesUsingAI).toFixed(2)
|
||||
: "0.00";
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">Analytics</h1>
|
||||
<p className="text-gray-400">Feature usage and engagement</p>
|
||||
</div>
|
||||
|
||||
{/* Usage Overview */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-4">
|
||||
<StatCard label="Activity Logs" value={stats.totalLogs} icon="📝" />
|
||||
<StatCard label="Medicines" value={stats.totalMedicines} icon="💊" />
|
||||
<StatCard label="Vaccinations" value={stats.totalVaccinations} icon="💉" />
|
||||
<StatCard label="Growth" value={stats.totalGrowth} icon="📏" />
|
||||
<StatCard label="Memories" value={stats.totalMemories} icon="📸" />
|
||||
<StatCard label="Chat Sessions" value={stats.totalChatSessions} icon="💬" />
|
||||
</div>
|
||||
|
||||
{/* Activity Chart */}
|
||||
<div className="bg-gray-800 p-6 rounded-xl">
|
||||
<h3 className="text-lg font-semibold mb-4">Daily Activity (Last 30 days)</h3>
|
||||
<div className="h-48 flex items-end gap-1">
|
||||
{stats.logsByDay.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"
|
||||
style={{
|
||||
height: `${Math.min((d.count / Math.max(...stats.logsByDay.map(l => l.count), 1)) * 100, 100)}%`,
|
||||
minHeight: d.count > 0 ? "4px" : "0"
|
||||
}}
|
||||
/>
|
||||
<div className="text-[8px] text-gray-500">{d.date?.slice(5) || ""}</div>
|
||||
</div>
|
||||
<div className="flex justify-between items-start">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">Analytics</h1>
|
||||
<p className="text-gray-400">User journey and feature engagement</p>
|
||||
</div>
|
||||
<div className="flex gap-1 bg-gray-800 p-1 rounded-lg">
|
||||
{(["overview", "families", "ai"] as Tab[]).map(t => (
|
||||
<button
|
||||
key={t}
|
||||
onClick={() => setTab(t)}
|
||||
className={`px-3 py-1.5 rounded-md text-sm font-medium capitalize transition-colors ${tab === t ? "bg-gray-600 text-white" : "text-gray-400 hover:text-white"}`}
|
||||
>
|
||||
{t === "ai" ? "AI Usage" : t}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{stats.logsByDay.length === 0 && (
|
||||
<div className="h-48 flex items-center justify-center text-gray-500">
|
||||
No activity data yet
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Feature Breakdown */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="bg-gray-800 p-6 rounded-xl">
|
||||
<h3 className="text-lg font-semibold mb-4">Top Features</h3>
|
||||
<div className="space-y-3">
|
||||
{stats.topFeatures.map((feature, i) => (
|
||||
<div key={i} className="flex justify-between items-center">
|
||||
<span>{feature.name}</span>
|
||||
<span className="font-bold text-rose-400">{feature.count}</span>
|
||||
</div>
|
||||
{/* Activity Summary Cards — always visible */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<SummaryCard label="Active 7d" value={activitySummary.active7d} color="text-emerald-400" sub={`of ${activitySummary.total}`} />
|
||||
<SummaryCard label="Active 30d" value={activitySummary.active30d} color="text-blue-400" sub={`of ${activitySummary.total}`} />
|
||||
<SummaryCard label="Inactive" value={activitySummary.total - activitySummary.active7d - activitySummary.active30d - activitySummary.neverActive} color="text-amber-400" sub="no activity >30d" />
|
||||
<SummaryCard label="Never Active" value={activitySummary.neverActive} color="text-gray-500" sub="0 logs ever" />
|
||||
</div>
|
||||
|
||||
{tab === "overview" && (
|
||||
<>
|
||||
{/* Feature Adoption Funnel */}
|
||||
<div className="bg-gray-800 p-6 rounded-xl">
|
||||
<h3 className="text-lg font-semibold mb-5">Feature Adoption</h3>
|
||||
<div className="space-y-3">
|
||||
{data.featureAdoption.map(f => (
|
||||
<div key={f.name} className="flex items-center gap-4">
|
||||
<div className="w-32 text-sm text-gray-300 shrink-0">{f.name}</div>
|
||||
<div className="flex-1 bg-gray-700 rounded-full h-5 relative overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-rose-500 rounded-full transition-all"
|
||||
style={{ width: `${f.pct}%` }}
|
||||
/>
|
||||
</div>
|
||||
<div className="w-24 text-right text-sm">
|
||||
<span className="font-bold text-white">{f.pct}%</span>
|
||||
<span className="text-gray-500 ml-1">({f.count})</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{data.featureAdoption.length === 0 && (
|
||||
<div className="text-gray-500 text-sm">No data yet</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Daily Activity Chart */}
|
||||
<div className="bg-gray-800 p-6 rounded-xl">
|
||||
<h3 className="text-lg font-semibold mb-4">Daily Activity — Last 30 Days</h3>
|
||||
<div className="h-40 flex items-end gap-1">
|
||||
{data.logsByDay.slice(-30).map((d, i) => (
|
||||
<div key={i} title={`${d.date}: ${d.count}`} className="flex-1 flex flex-col items-center gap-1 group">
|
||||
<div
|
||||
className="w-full bg-rose-500 rounded-t group-hover:bg-rose-400 transition-colors"
|
||||
style={{
|
||||
height: `${Math.min((d.count / maxLog) * 100, 100)}%`,
|
||||
minHeight: d.count > 0 ? "4px" : "0"
|
||||
}}
|
||||
/>
|
||||
<div className="text-[8px] text-gray-500">{d.date?.slice(5) || ""}</div>
|
||||
</div>
|
||||
))}
|
||||
{data.logsByDay.length === 0 && (
|
||||
<div className="w-full text-center text-gray-500 text-sm self-center">No activity in the last 30 days</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{tab === "families" && (
|
||||
<div className="bg-gray-800 rounded-xl overflow-hidden">
|
||||
<div className="px-4 py-3 border-b border-gray-700 flex gap-2 flex-wrap">
|
||||
{(["all", "7d", "30d", "inactive", "never"] as ActivityFilter[]).map(f => (
|
||||
<button
|
||||
key={f}
|
||||
onClick={() => setActivityFilter(f)}
|
||||
className={`px-3 py-1 rounded-lg text-sm transition-colors ${activityFilter === f ? "bg-rose-500 text-white" : "bg-gray-700 text-gray-300 hover:bg-gray-600"}`}
|
||||
>
|
||||
{f === "all" ? "All" : f === "7d" ? "Active 7d" : f === "30d" ? "Active 30d" : f === "inactive" ? "Inactive >30d" : "Never Active"}
|
||||
{f !== "all" && (
|
||||
<span className="ml-1.5 text-xs opacity-70">
|
||||
{data.families.filter(x => x.activeStatus === f).length}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
{stats.topFeatures.length === 0 && (
|
||||
<div className="text-gray-500">No data yet</div>
|
||||
<span className="text-gray-500 text-sm self-center ml-auto">{sortedFamilies.length} families</span>
|
||||
</div>
|
||||
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead className="bg-gray-700">
|
||||
<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">Tier</th>
|
||||
<SortHeader label="Last Active" field="lastActivity" current={sortField} asc={sortAsc} onSort={handleSort} />
|
||||
<SortHeader label="Total Logs" field="totalLogs" current={sortField} asc={sortAsc} onSort={handleSort} />
|
||||
<th className="px-4 py-3 text-left text-sm font-medium">Features Used</th>
|
||||
<th className="px-4 py-3 text-left text-sm font-medium">AI Chats</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-700">
|
||||
{sortedFamilies.map(f => (
|
||||
<tr key={f.id} className="hover:bg-gray-750">
|
||||
<td className="px-4 py-3">
|
||||
<div className="font-medium">{f.name}</div>
|
||||
<div className="text-xs text-gray-500">{f.createdAt?.slice(0, 10)}</div>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className={`text-xs px-2 py-0.5 rounded font-medium ${f.tier === "pro" ? "bg-rose-500/20 text-rose-400" : "bg-gray-700 text-gray-400"}`}>
|
||||
{f.tier}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm">
|
||||
{f.lastActivity ? (
|
||||
<span className={f.activeStatus === "7d" ? "text-emerald-400" : f.activeStatus === "30d" ? "text-blue-400" : "text-gray-500"}>
|
||||
{f.lastActivity.slice(0, 10)}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-gray-600">Never</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm font-medium">{f.totalLogs}</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex gap-1 flex-wrap">
|
||||
{f.feedCount > 0 && <FeatureBadge emoji="🍼" label="Feed" />}
|
||||
{f.sleepCount > 0 && <FeatureBadge emoji="💤" label="Sleep" />}
|
||||
{f.diaperCount > 0 && <FeatureBadge emoji="🚼" label="Diaper" />}
|
||||
{f.vaccinationCount > 0 && <FeatureBadge emoji="💉" label="Vacc" />}
|
||||
{f.growthCount > 0 && <FeatureBadge emoji="📏" label="Growth" />}
|
||||
{f.memoryCount > 0 && <FeatureBadge emoji="📸" label="Memory" />}
|
||||
{f.chatCount > 0 && <FeatureBadge emoji="💬" label="AI" />}
|
||||
{f.totalLogs === 0 && f.memoryCount === 0 && f.chatCount === 0 && (
|
||||
<span className="text-gray-600 text-xs">None</span>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-gray-300">{f.chatCount || "—"}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
{sortedFamilies.length === 0 && (
|
||||
<div className="p-8 text-center text-gray-500">No families match this filter</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="bg-gray-800 p-6 rounded-xl">
|
||||
<h3 className="text-lg font-semibold mb-4">Engagement Summary</h3>
|
||||
<div className="space-y-3">
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-gray-400">Avg Logs per Family</span>
|
||||
{tab === "ai" && (
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<SummaryCard label="API Calls (30d)" value={aiUsage.totalCalls} color="text-blue-400" />
|
||||
<SummaryCard label="Total Tokens" value={aiUsage.totalTokens.toLocaleString()} color="text-purple-400" />
|
||||
<SummaryCard label="Cost (30d)" value={`₹${costINR}`} color="text-amber-400" />
|
||||
<SummaryCard label="Families Using AI" value={aiUsage.familiesUsingAI} color="text-emerald-400" />
|
||||
</div>
|
||||
|
||||
<div className="bg-gray-800 p-6 rounded-xl space-y-3">
|
||||
<h3 className="text-lg font-semibold">AI Cost Breakdown</h3>
|
||||
<div className="flex justify-between text-sm py-2 border-b border-gray-700">
|
||||
<span className="text-gray-400">Total cost (30 days)</span>
|
||||
<span className="font-bold">₹{costINR}</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm py-2 border-b border-gray-700">
|
||||
<span className="text-gray-400">Avg cost per AI-using family</span>
|
||||
<span className="font-bold">₹{costPerFamily}</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm py-2 border-b border-gray-700">
|
||||
<span className="text-gray-400">Total tokens used</span>
|
||||
<span className="font-bold">{aiUsage.totalTokens.toLocaleString()}</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm py-2">
|
||||
<span className="text-gray-400">Families using AI</span>
|
||||
<span className="font-bold">
|
||||
{stats.totalLogs > 0 && stats.familyCount > 0
|
||||
? (stats.totalLogs / stats.familyCount).toFixed(1)
|
||||
: "0"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-gray-400">Avg Children per Family</span>
|
||||
<span className="font-bold">1.0</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-gray-400">Chat Adoption</span>
|
||||
<span className="font-bold text-emerald-400">
|
||||
{stats.totalChatSessions > 0 ? "Active" : "None"}
|
||||
{aiUsage.familiesUsingAI} / {data.totalFamilies}
|
||||
<span className="text-gray-500 ml-1 text-xs">
|
||||
({data.totalFamilies > 0 ? Math.round(aiUsage.familiesUsingAI / data.totalFamilies * 100) : 0}%)
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StatCard({ label, value, icon }: { label: string; value: number; icon: string }) {
|
||||
function SummaryCard({ label, value, color, sub }: { label: string; value: string | number; color: string; sub?: string }) {
|
||||
return (
|
||||
<div className="bg-gray-800 p-4 rounded-xl">
|
||||
<div className="text-2xl mb-1">{icon}</div>
|
||||
<div className="text-xl font-bold">{value}</div>
|
||||
<div className="text-xs text-gray-400">{label}</div>
|
||||
<div className={`text-2xl font-bold ${color}`}>{value}</div>
|
||||
<div className="text-sm text-gray-300 mt-0.5">{label}</div>
|
||||
{sub && <div className="text-xs text-gray-500 mt-0.5">{sub}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function FeatureBadge({ emoji, label }: { emoji: string; label: string }) {
|
||||
return (
|
||||
<span title={label} className="text-xs bg-gray-700 px-1.5 py-0.5 rounded">
|
||||
{emoji}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function SortHeader({ label, field, current, asc, onSort }: {
|
||||
label: string; field: SortField; current: SortField; asc: boolean; onSort: (f: SortField) => void;
|
||||
}) {
|
||||
return (
|
||||
<th
|
||||
className="px-4 py-3 text-left text-sm font-medium cursor-pointer hover:text-white select-none"
|
||||
onClick={() => onSort(field)}
|
||||
>
|
||||
{label} {current === field ? (asc ? "↑" : "↓") : "↕"}
|
||||
</th>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,11 +16,19 @@ export default function AdminChildren() {
|
|||
const [children, setChildren] = useState<Child[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [search, setSearch] = useState("");
|
||||
const [deletingId, setDeletingId] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [success, setSuccess] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
fetchChildren();
|
||||
}, []);
|
||||
|
||||
const showMessage = (msg: string, type: "success" | "error") => {
|
||||
if (type === "success") { setSuccess(msg); setTimeout(() => setSuccess(null), 3000); }
|
||||
else { setError(msg); setTimeout(() => setError(null), 5000); }
|
||||
};
|
||||
|
||||
const fetchChildren = async () => {
|
||||
try {
|
||||
const res = await fetch("/api/admin/children", { credentials: "include" });
|
||||
|
|
@ -32,9 +40,48 @@ export default function AdminChildren() {
|
|||
setLoading(false);
|
||||
};
|
||||
|
||||
const handleDeleteChild = async (child: Child) => {
|
||||
// Fetch counts for the confirmation message
|
||||
let confirmMsg = `Delete ${child.name}? This will permanently delete all their logs, growth records, vaccinations, and associated data. This cannot be undone.`;
|
||||
try {
|
||||
const res = await fetch(`/api/admin/children/${child.id}`, { credentials: "include" });
|
||||
if (res.ok) {
|
||||
const counts = await res.json();
|
||||
const total =
|
||||
Number(counts.feed_count || 0) +
|
||||
Number(counts.diaper_count || 0) +
|
||||
Number(counts.sleep_count || 0);
|
||||
const parts: string[] = [];
|
||||
if (total > 0) parts.push(`${total} activity logs`);
|
||||
if (Number(counts.growth_count || 0) > 0) parts.push(`${counts.growth_count} growth records`);
|
||||
if (Number(counts.vaccination_count || 0) > 0) parts.push(`${counts.vaccination_count} vaccination records`);
|
||||
if (parts.length > 0) {
|
||||
confirmMsg = `Delete ${child.name}? This will permanently delete ${parts.join(", ")}, and all associated data. This cannot be undone.`;
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
|
||||
if (!window.confirm(confirmMsg)) return;
|
||||
|
||||
setDeletingId(child.id);
|
||||
try {
|
||||
const res = await fetch(`/api/admin/children/${child.id}`, {
|
||||
method: "DELETE",
|
||||
credentials: "include",
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.error || "Delete failed");
|
||||
showMessage(`${child.name} and all data deleted`, "success");
|
||||
setChildren(prev => prev.filter(c => c.id !== child.id));
|
||||
} catch (err: any) {
|
||||
showMessage(err.message, "error");
|
||||
}
|
||||
setDeletingId(null);
|
||||
};
|
||||
|
||||
const filteredChildren = children.filter((c) =>
|
||||
c.name.toLowerCase().includes(search.toLowerCase()) ||
|
||||
c.familyName.toLowerCase().includes(search.toLowerCase())
|
||||
(c.familyName || "").toLowerCase().includes(search.toLowerCase())
|
||||
);
|
||||
|
||||
const exportCSV = () => {
|
||||
|
|
@ -49,12 +96,22 @@ export default function AdminChildren() {
|
|||
a.click();
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return <div className="p-6 text-white">Loading...</div>;
|
||||
}
|
||||
if (loading) return <div className="p-6 text-white">Loading...</div>;
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-4">
|
||||
{error && (
|
||||
<div className="bg-red-500/15 border border-red-500/30 text-red-400 px-4 py-3 rounded-xl text-sm flex justify-between items-center">
|
||||
<span>⚠ {error}</span>
|
||||
<button onClick={() => setError(null)} className="ml-4 opacity-60 hover:opacity-100">✕</button>
|
||||
</div>
|
||||
)}
|
||||
{success && (
|
||||
<div className="bg-emerald-500/15 border border-emerald-500/30 text-emerald-400 px-4 py-3 rounded-xl text-sm">
|
||||
✓ {success}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex justify-between items-center">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">Children</h1>
|
||||
|
|
@ -78,6 +135,7 @@ export default function AdminChildren() {
|
|||
<th className="px-4 py-3 text-left text-sm font-medium">Birth Date</th>
|
||||
<th className="px-4 py-3 text-left text-sm font-medium">Age</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">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-700">
|
||||
|
|
@ -89,6 +147,17 @@ export default function AdminChildren() {
|
|||
</td>
|
||||
<td className="px-4 py-3 text-sm text-gray-400">{child.age}</td>
|
||||
<td className="px-4 py-3">{child.familyName}</td>
|
||||
<td className="px-4 py-3">
|
||||
<Button
|
||||
variant="danger"
|
||||
size="sm"
|
||||
loading={deletingId === child.id}
|
||||
disabled={!!deletingId}
|
||||
onClick={() => handleDeleteChild(child)}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
|
|
@ -99,4 +168,4 @@ export default function AdminChildren() {
|
|||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -20,6 +20,8 @@ interface Family {
|
|||
createdAt: string;
|
||||
userCount: number;
|
||||
childCount: number;
|
||||
logCount: number;
|
||||
memoryCount: number;
|
||||
members: Member[];
|
||||
}
|
||||
|
||||
|
|
@ -31,6 +33,7 @@ export default function AdminFamilies() {
|
|||
const [showMembers, setShowMembers] = useState<string | null>(null);
|
||||
const [tierChanging, setTierChanging] = useState<string | null>(null);
|
||||
const [removingMemberId, setRemovingMemberId] = useState<string | null>(null);
|
||||
const [deletingFamilyId, setDeletingFamilyId] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [success, setSuccess] = useState<string | null>(null);
|
||||
|
||||
|
|
@ -39,7 +42,7 @@ export default function AdminFamilies() {
|
|||
const [newFamilyName, setNewFamilyName] = useState("");
|
||||
const [creatingFamily, setCreatingFamily] = useState(false);
|
||||
|
||||
// Add member form — separate email/role state to avoid lost-role bug
|
||||
// Add member form
|
||||
const [memberForms, setMemberForms] = useState<Record<string, { email: string; role: string }>>({});
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -123,6 +126,30 @@ export default function AdminFamilies() {
|
|||
setRemovingMemberId(null);
|
||||
};
|
||||
|
||||
const handleDeleteFamily = async (family: Family) => {
|
||||
const parts: string[] = [];
|
||||
if (family.childCount > 0) parts.push(`${family.childCount} ${family.childCount === 1 ? "child" : "children"}`);
|
||||
if (family.logCount > 0) parts.push(`${family.logCount} activity logs`);
|
||||
if (family.memoryCount > 0) parts.push(`${family.memoryCount} memories`);
|
||||
const detail = parts.length > 0 ? ` This will permanently delete ${parts.join(", ")}, and all associated data.` : "";
|
||||
if (!window.confirm(`Delete "${family.name}"?${detail} This cannot be undone.`)) return;
|
||||
|
||||
setDeletingFamilyId(family.id);
|
||||
try {
|
||||
const res = await fetch(`/api/admin/families/${family.id}`, {
|
||||
method: "DELETE",
|
||||
credentials: "include",
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.error || "Failed to delete family");
|
||||
showMessage(`"${family.name}" and all data deleted`, "success");
|
||||
setFamilies(prev => prev.filter(f => f.id !== family.id));
|
||||
} catch (err: any) {
|
||||
showMessage(err.message, "error");
|
||||
}
|
||||
setDeletingFamilyId(null);
|
||||
};
|
||||
|
||||
const handleTierChange = async (familyId: string, currentTier: string) => {
|
||||
const newTier = currentTier === "pro" ? "free" : "pro";
|
||||
const label = newTier === "pro" ? "Upgrade to Pro?" : "Downgrade to Free?";
|
||||
|
|
@ -159,14 +186,16 @@ export default function AdminFamilies() {
|
|||
|
||||
const filteredFamilies = families.filter((f) => {
|
||||
const matchesSearch = f.name.toLowerCase().includes(search.toLowerCase());
|
||||
const isOrphaned = f.userCount === 0;
|
||||
if (tierFilter === "orphaned") return matchesSearch && isOrphaned;
|
||||
const matchesTier = tierFilter === "all" || f.tier === tierFilter;
|
||||
return matchesSearch && matchesTier;
|
||||
});
|
||||
|
||||
const exportCSV = () => {
|
||||
const headers = ["Name", "Tier", "Users", "Children", "Max Children", "Max Members", "Created"];
|
||||
const headers = ["Name", "Tier", "Users", "Children", "Logs", "Max Children", "Max Members", "Created"];
|
||||
const rows = filteredFamilies.map((f) => [
|
||||
f.name, f.tier, f.userCount, f.childCount, f.maxChildren, f.maxMembers, f.createdAt,
|
||||
f.name, f.tier, f.userCount, f.childCount, f.logCount, f.maxChildren, f.maxMembers, f.createdAt,
|
||||
]);
|
||||
const csv = [headers, ...rows].map((row) => row.join(",")).join("\n");
|
||||
const blob = new Blob([csv], { type: "text/csv" });
|
||||
|
|
@ -181,6 +210,7 @@ export default function AdminFamilies() {
|
|||
|
||||
const proCount = families.filter(f => f.tier === "pro").length;
|
||||
const freeCount = families.filter(f => f.tier !== "pro").length;
|
||||
const orphanedCount = families.filter(f => f.userCount === 0).length;
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-4">
|
||||
|
|
@ -201,6 +231,7 @@ export default function AdminFamilies() {
|
|||
<h1 className="text-2xl font-bold">Families</h1>
|
||||
<p className="text-gray-400">
|
||||
{families.length} total · <span className="text-rose-400">{proCount} Pro</span> · <span className="text-gray-500">{freeCount} Free</span>
|
||||
{orphanedCount > 0 && <> · <span className="text-amber-400">{orphanedCount} Orphaned</span></>}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
|
|
@ -245,6 +276,7 @@ export default function AdminFamilies() {
|
|||
<option value="all">All Tiers</option>
|
||||
<option value="free">Free</option>
|
||||
<option value="pro">Pro</option>
|
||||
<option value="orphaned">Orphaned (0 members)</option>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
|
|
@ -257,6 +289,7 @@ export default function AdminFamilies() {
|
|||
<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">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">Logs</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">Actions</th>
|
||||
|
|
@ -265,12 +298,18 @@ export default function AdminFamilies() {
|
|||
<tbody className="divide-y divide-gray-700">
|
||||
{filteredFamilies.map((family) => {
|
||||
const memberForm = memberForms[family.id] || { email: "", role: "caregiver" };
|
||||
const isOrphaned = family.userCount === 0;
|
||||
return (
|
||||
<>
|
||||
<tr key={family.id} className="hover:bg-gray-750">
|
||||
<tr key={family.id} className={`hover:bg-gray-750 ${isOrphaned ? "bg-amber-950/20" : ""}`}>
|
||||
<td className="px-4 py-3">
|
||||
<div className="font-medium">{family.name}</div>
|
||||
<div className="text-xs text-gray-600 font-mono">{family.id.slice(0, 8)}…</div>
|
||||
{isOrphaned && (
|
||||
<span className="text-xs text-amber-400 bg-amber-400/10 px-1.5 py-0.5 rounded mt-0.5 inline-block">
|
||||
No members
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<Badge variant={family.tier === "pro" ? "rose" : "default"}>{family.tier}</Badge>
|
||||
|
|
@ -285,6 +324,7 @@ export default function AdminFamilies() {
|
|||
</Button>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-gray-300">{family.childCount}</td>
|
||||
<td className="px-4 py-3 text-sm text-gray-400">{family.logCount}</td>
|
||||
<td className="px-4 py-3 text-sm text-gray-500">
|
||||
{family.maxChildren} kids · {family.maxMembers} members
|
||||
</td>
|
||||
|
|
@ -292,21 +332,34 @@ export default function AdminFamilies() {
|
|||
{family.createdAt?.slice(0, 10)}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<Button
|
||||
variant={family.tier === "pro" ? "secondary" : "primary"}
|
||||
size="sm"
|
||||
onClick={() => handleTierChange(family.id, family.tier)}
|
||||
loading={tierChanging === family.id}
|
||||
disabled={!!tierChanging}
|
||||
>
|
||||
{family.tier === "pro" ? "↓ Free" : "↑ Pro"}
|
||||
</Button>
|
||||
<div className="flex gap-1.5">
|
||||
<Button
|
||||
variant={family.tier === "pro" ? "secondary" : "primary"}
|
||||
size="sm"
|
||||
onClick={() => handleTierChange(family.id, family.tier)}
|
||||
loading={tierChanging === family.id}
|
||||
disabled={!!tierChanging || !!deletingFamilyId}
|
||||
>
|
||||
{family.tier === "pro" ? "↓ Free" : "↑ Pro"}
|
||||
</Button>
|
||||
{isOrphaned && (
|
||||
<Button
|
||||
variant="danger"
|
||||
size="sm"
|
||||
onClick={() => handleDeleteFamily(family)}
|
||||
loading={deletingFamilyId === family.id}
|
||||
disabled={!!deletingFamilyId || !!tierChanging}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
{showMembers === family.id && (
|
||||
<tr key={`${family.id}-members`}>
|
||||
<td colSpan={7} className="bg-gray-900 px-6 py-4">
|
||||
<td colSpan={8} className="bg-gray-900 px-6 py-4">
|
||||
<div className="space-y-3">
|
||||
<div className="text-xs text-gray-500 font-semibold uppercase">Members</div>
|
||||
|
||||
|
|
|
|||
71
src/app/api/admin/children/[id]/route.ts
Normal file
71
src/app/api/admin/children/[id]/route.ts
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
import { NextResponse } from "next/server";
|
||||
import { requireAdmin } from "@/lib/admin-auth";
|
||||
import { sql } from "@/db";
|
||||
|
||||
export async function GET(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const auth = await requireAdmin(request);
|
||||
if (!auth.success) return NextResponse.json({ error: auth.error }, { status: auth.status });
|
||||
|
||||
try {
|
||||
const { id: childId } = await params;
|
||||
const rows = await sql`
|
||||
SELECT
|
||||
COUNT(DISTINCT fd.id) as feed_count,
|
||||
COUNT(DISTINCT dl.id) as diaper_count,
|
||||
COUNT(DISTINCT sl.id) as sleep_count,
|
||||
COUNT(DISTINCT v.id) as vaccination_count,
|
||||
COUNT(DISTINCT g.id) as growth_count
|
||||
FROM children c
|
||||
LEFT JOIN feeds fd ON fd.child_id = c.id
|
||||
LEFT JOIN diapers_logs dl ON dl.child_id = c.id
|
||||
LEFT JOIN sleeps sl ON sl.child_id = c.id
|
||||
LEFT JOIN vaccinations v ON v.child_id = c.id
|
||||
LEFT JOIN growth g ON g.child_id = c.id
|
||||
WHERE c.id = ${childId}
|
||||
`;
|
||||
return NextResponse.json(rows[0] || {});
|
||||
} catch (error) {
|
||||
return NextResponse.json({ error: String(error) }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const auth = await requireAdmin(request);
|
||||
if (!auth.success) return NextResponse.json({ error: auth.error }, { status: auth.status });
|
||||
|
||||
try {
|
||||
const { id: childId } = await params;
|
||||
if (!childId) return NextResponse.json({ error: "childId required" }, { status: 400 });
|
||||
|
||||
// Delete chat messages first (FK on session_id)
|
||||
const sessionRows = await sql`SELECT id FROM chat_sessions WHERE child_id = ${childId}`;
|
||||
const sessionIds = sessionRows.map((r: any) => r.id);
|
||||
if (sessionIds.length > 0) {
|
||||
await sql`DELETE FROM chat_messages WHERE session_id = ANY(${sessionIds})`;
|
||||
}
|
||||
await sql`DELETE FROM chat_sessions WHERE child_id = ${childId}`;
|
||||
await sql`DELETE FROM milestone_achievements WHERE child_id = ${childId}`;
|
||||
await sql`DELETE FROM illness_logs WHERE child_id = ${childId}`;
|
||||
await sql`DELETE FROM doctor_visits WHERE child_id = ${childId}`;
|
||||
await sql`DELETE FROM allergies WHERE child_id = ${childId}`;
|
||||
await sql`DELETE FROM medicines WHERE child_id = ${childId}`;
|
||||
await sql`DELETE FROM medications WHERE child_id = ${childId}`;
|
||||
await sql`DELETE FROM growth WHERE child_id = ${childId}`;
|
||||
await sql`DELETE FROM vaccinations WHERE child_id = ${childId}`;
|
||||
await sql`DELETE FROM sleeps WHERE child_id = ${childId}`;
|
||||
await sql`DELETE FROM diapers_logs WHERE child_id = ${childId}`;
|
||||
await sql`DELETE FROM feeds WHERE child_id = ${childId}`;
|
||||
await sql`DELETE FROM children WHERE id = ${childId}`;
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error) {
|
||||
console.error("Admin child delete error:", error);
|
||||
return NextResponse.json({ error: String(error) }, { status: 500 });
|
||||
}
|
||||
}
|
||||
162
src/app/api/admin/engagement/route.ts
Normal file
162
src/app/api/admin/engagement/route.ts
Normal file
|
|
@ -0,0 +1,162 @@
|
|||
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 });
|
||||
|
||||
try {
|
||||
// A. Feature adoption — % of families that have used each feature at least once
|
||||
const adoptionRows = await sql`
|
||||
SELECT
|
||||
COUNT(DISTINCT f.id)::int as total_families,
|
||||
COUNT(DISTINCT f.id) FILTER (WHERE fd.id IS NOT NULL)::int as families_feeding,
|
||||
COUNT(DISTINCT f.id) FILTER (WHERE dl.id IS NOT NULL)::int as families_diapers,
|
||||
COUNT(DISTINCT f.id) FILTER (WHERE sl.id IS NOT NULL)::int as families_sleeping,
|
||||
COUNT(DISTINCT f.id) FILTER (WHERE v.id IS NOT NULL)::int as families_vaccinating,
|
||||
COUNT(DISTINCT f.id) FILTER (WHERE g.id IS NOT NULL)::int as families_growth,
|
||||
COUNT(DISTINCT f.id) FILTER (WHERE mem.id IS NOT NULL)::int as families_memories,
|
||||
COUNT(DISTINCT f.id) FILTER (WHERE cs.id IS NOT NULL)::int as families_ai
|
||||
FROM families f
|
||||
LEFT JOIN children c ON c.family_id = f.id
|
||||
LEFT JOIN LATERAL (SELECT id FROM feeds WHERE child_id = c.id LIMIT 1) fd ON true
|
||||
LEFT JOIN LATERAL (SELECT id FROM diapers_logs WHERE child_id = c.id LIMIT 1) dl ON true
|
||||
LEFT JOIN LATERAL (SELECT id FROM sleeps WHERE child_id = c.id LIMIT 1) sl ON true
|
||||
LEFT JOIN LATERAL (SELECT id FROM vaccinations WHERE child_id = c.id LIMIT 1) v ON true
|
||||
LEFT JOIN LATERAL (SELECT id FROM growth WHERE child_id = c.id LIMIT 1) g ON true
|
||||
LEFT JOIN LATERAL (SELECT id FROM memories WHERE family_id = f.id LIMIT 1) mem ON true
|
||||
LEFT JOIN LATERAL (SELECT id FROM chat_sessions WHERE child_id = c.id LIMIT 1) cs ON true
|
||||
`;
|
||||
|
||||
const adoption = adoptionRows[0] || {};
|
||||
const total = Number(adoption.total_families) || 1;
|
||||
|
||||
const featureAdoption = [
|
||||
{ name: "Feed Logs", count: Number(adoption.families_feeding) || 0, pct: Math.round((Number(adoption.families_feeding) || 0) / total * 100) },
|
||||
{ name: "Diaper Logs", count: Number(adoption.families_diapers) || 0, pct: Math.round((Number(adoption.families_diapers) || 0) / total * 100) },
|
||||
{ name: "Sleep Logs", count: Number(adoption.families_sleeping) || 0, pct: Math.round((Number(adoption.families_sleeping) || 0) / total * 100) },
|
||||
{ name: "Vaccinations", count: Number(adoption.families_vaccinating) || 0, pct: Math.round((Number(adoption.families_vaccinating) || 0) / total * 100) },
|
||||
{ name: "Growth Tracking", count: Number(adoption.families_growth) || 0, pct: Math.round((Number(adoption.families_growth) || 0) / total * 100) },
|
||||
{ name: "Memories", count: Number(adoption.families_memories) || 0, pct: Math.round((Number(adoption.families_memories) || 0) / total * 100) },
|
||||
{ name: "AI Chat", count: Number(adoption.families_ai) || 0, pct: Math.round((Number(adoption.families_ai) || 0) / total * 100) },
|
||||
].sort((a, b) => b.pct - a.pct);
|
||||
|
||||
// B. Per-family engagement table
|
||||
const familyRows = await sql`
|
||||
SELECT
|
||||
f.id,
|
||||
f.name,
|
||||
f.tier,
|
||||
f.created_at,
|
||||
GREATEST(
|
||||
MAX(fd.logged_at),
|
||||
MAX(dl.logged_at),
|
||||
MAX(sl.started_at),
|
||||
MAX(mem.created_at)
|
||||
) as last_activity,
|
||||
COUNT(DISTINCT fd.id)::int as feed_count,
|
||||
COUNT(DISTINCT dl.id)::int as diaper_count,
|
||||
COUNT(DISTINCT sl.id)::int as sleep_count,
|
||||
COUNT(DISTINCT v.id)::int as vaccination_count,
|
||||
COUNT(DISTINCT g.id)::int as growth_count,
|
||||
COUNT(DISTINCT mem.id)::int as memory_count,
|
||||
COUNT(DISTINCT cs.id)::int as chat_count
|
||||
FROM families f
|
||||
LEFT JOIN children c ON c.family_id = f.id
|
||||
LEFT JOIN feeds fd ON fd.child_id = c.id
|
||||
LEFT JOIN diapers_logs dl ON dl.child_id = c.id
|
||||
LEFT JOIN sleeps sl ON sl.child_id = c.id
|
||||
LEFT JOIN vaccinations v ON v.child_id = c.id
|
||||
LEFT JOIN growth g ON g.child_id = c.id
|
||||
LEFT JOIN memories mem ON mem.family_id = f.id
|
||||
LEFT JOIN chat_sessions cs ON cs.child_id = c.id
|
||||
GROUP BY f.id, f.name, f.tier, f.created_at
|
||||
ORDER BY last_activity DESC NULLS LAST
|
||||
`;
|
||||
|
||||
const now = Date.now();
|
||||
const families = familyRows.map((f: any) => {
|
||||
const lastActivity = f.last_activity ? new Date(f.last_activity).toISOString() : null;
|
||||
const msSince = lastActivity ? now - new Date(lastActivity).getTime() : Infinity;
|
||||
const activeStatus = !lastActivity ? "never" : msSince < 7 * 86400_000 ? "7d" : msSince < 30 * 86400_000 ? "30d" : "inactive";
|
||||
return {
|
||||
id: f.id,
|
||||
name: f.name,
|
||||
tier: f.tier || "free",
|
||||
createdAt: f.created_at ? new Date(f.created_at).toISOString() : null,
|
||||
lastActivity,
|
||||
activeStatus,
|
||||
feedCount: Number(f.feed_count) || 0,
|
||||
diaperCount: Number(f.diaper_count) || 0,
|
||||
sleepCount: Number(f.sleep_count) || 0,
|
||||
vaccinationCount: Number(f.vaccination_count) || 0,
|
||||
growthCount: Number(f.growth_count) || 0,
|
||||
memoryCount: Number(f.memory_count) || 0,
|
||||
chatCount: Number(f.chat_count) || 0,
|
||||
totalLogs:
|
||||
Number(f.feed_count) + Number(f.diaper_count) + Number(f.sleep_count) +
|
||||
Number(f.vaccination_count) + Number(f.growth_count),
|
||||
};
|
||||
});
|
||||
|
||||
// C. Activity summary counts
|
||||
const active7d = families.filter(f => f.activeStatus === "7d").length;
|
||||
const active30d = families.filter(f => f.activeStatus === "30d").length;
|
||||
const neverActive = families.filter(f => f.activeStatus === "never").length;
|
||||
|
||||
// D. AI usage last 30 days
|
||||
let aiUsage = { totalCalls: 0, totalTokens: 0, totalCostPaise: 0, familiesUsingAI: 0 };
|
||||
try {
|
||||
const aiRows = await sql`
|
||||
SELECT
|
||||
COUNT(*)::int as total_calls,
|
||||
COALESCE(SUM(total_tokens), 0)::int as total_tokens,
|
||||
COALESCE(SUM(cost_estimate_paise), 0)::numeric as total_cost_paise,
|
||||
COUNT(DISTINCT family_id)::int as families_using_ai
|
||||
FROM ai_usage
|
||||
WHERE created_at > NOW() - INTERVAL '30 days'
|
||||
`;
|
||||
const row = aiRows[0] || {};
|
||||
aiUsage = {
|
||||
totalCalls: Number(row.total_calls) || 0,
|
||||
totalTokens: Number(row.total_tokens) || 0,
|
||||
totalCostPaise: Number(row.total_cost_paise) || 0,
|
||||
familiesUsingAI: Number(row.families_using_ai) || 0,
|
||||
};
|
||||
} catch {}
|
||||
|
||||
// E. Daily activity for chart (last 30 days, all log types combined)
|
||||
const dailyRows = await sql`
|
||||
SELECT day::date as date, SUM(cnt)::int as count
|
||||
FROM (
|
||||
SELECT DATE(logged_at) as day, COUNT(*) as cnt FROM feeds WHERE logged_at > NOW() - INTERVAL '30 days' GROUP BY day
|
||||
UNION ALL
|
||||
SELECT DATE(logged_at) as day, COUNT(*) as cnt FROM diapers_logs WHERE logged_at > NOW() - INTERVAL '30 days' GROUP BY day
|
||||
UNION ALL
|
||||
SELECT DATE(started_at) as day, COUNT(*) as cnt FROM sleeps WHERE started_at > NOW() - INTERVAL '30 days' GROUP BY day
|
||||
UNION ALL
|
||||
SELECT DATE(created_at) as day, COUNT(*) as cnt FROM memories WHERE created_at > NOW() - INTERVAL '30 days' GROUP BY day
|
||||
) sub
|
||||
GROUP BY day
|
||||
ORDER BY day
|
||||
`;
|
||||
|
||||
const logsByDay = dailyRows.map((r: any) => ({
|
||||
date: r.date instanceof Date ? r.date.toISOString().split("T")[0] : String(r.date).split("T")[0],
|
||||
count: Number(r.count),
|
||||
}));
|
||||
|
||||
return NextResponse.json({
|
||||
totalFamilies: total,
|
||||
featureAdoption,
|
||||
families,
|
||||
activitySummary: { active7d, active30d, neverActive, total },
|
||||
aiUsage,
|
||||
logsByDay,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Admin engagement error:", error);
|
||||
return NextResponse.json({ error: String(error) }, { status: 500 });
|
||||
}
|
||||
}
|
||||
63
src/app/api/admin/families/[id]/route.ts
Normal file
63
src/app/api/admin/families/[id]/route.ts
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
import { NextResponse } from "next/server";
|
||||
import { requireAdmin } from "@/lib/admin-auth";
|
||||
import { sql } from "@/db";
|
||||
|
||||
export async function DELETE(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const auth = await requireAdmin(request);
|
||||
if (!auth.success) return NextResponse.json({ error: auth.error }, { status: auth.status });
|
||||
|
||||
try {
|
||||
const { id: familyId } = await params;
|
||||
if (!familyId) return NextResponse.json({ error: "familyId required" }, { status: 400 });
|
||||
|
||||
// Get child IDs before deleting
|
||||
const childRows = await sql`SELECT id FROM children WHERE family_id = ${familyId}`;
|
||||
const childIds = childRows.map((r: any) => r.id);
|
||||
|
||||
if (childIds.length > 0) {
|
||||
// Delete child data in FK order
|
||||
const chatSessionRows = await sql`SELECT id FROM chat_sessions WHERE child_id = ANY(${childIds})`;
|
||||
const sessionIds = chatSessionRows.map((r: any) => r.id);
|
||||
if (sessionIds.length > 0) {
|
||||
await sql`DELETE FROM chat_messages WHERE session_id = ANY(${sessionIds})`;
|
||||
}
|
||||
await sql`DELETE FROM chat_sessions WHERE child_id = ANY(${childIds})`;
|
||||
await sql`DELETE FROM milestone_achievements WHERE child_id = ANY(${childIds})`;
|
||||
await sql`DELETE FROM illness_logs WHERE child_id = ANY(${childIds})`;
|
||||
await sql`DELETE FROM doctor_visits WHERE child_id = ANY(${childIds})`;
|
||||
await sql`DELETE FROM allergies WHERE child_id = ANY(${childIds})`;
|
||||
await sql`DELETE FROM medicines WHERE child_id = ANY(${childIds})`;
|
||||
await sql`DELETE FROM medications WHERE child_id = ANY(${childIds})`;
|
||||
await sql`DELETE FROM growth WHERE child_id = ANY(${childIds})`;
|
||||
await sql`DELETE FROM vaccinations WHERE child_id = ANY(${childIds})`;
|
||||
await sql`DELETE FROM sleeps WHERE child_id = ANY(${childIds})`;
|
||||
await sql`DELETE FROM diapers_logs WHERE child_id = ANY(${childIds})`;
|
||||
await sql`DELETE FROM feeds WHERE child_id = ANY(${childIds})`;
|
||||
await sql`DELETE FROM children WHERE family_id = ${familyId}`;
|
||||
}
|
||||
|
||||
// Delete family-level data
|
||||
await sql`DELETE FROM family_invites WHERE family_id = ${familyId}`;
|
||||
await sql`DELETE FROM medication_doses WHERE family_id = ${familyId}`;
|
||||
const profileRows = await sql`SELECT id FROM member_profiles WHERE family_id = ${familyId}`;
|
||||
const profileIds = profileRows.map((r: any) => r.id);
|
||||
if (profileIds.length > 0) {
|
||||
await sql`DELETE FROM recommended_products WHERE profile_id = ANY(${profileIds})`;
|
||||
}
|
||||
await sql`DELETE FROM member_profiles WHERE family_id = ${familyId}`;
|
||||
await sql`DELETE FROM memories WHERE family_id = ${familyId}`;
|
||||
await sql`DELETE FROM attachments WHERE family_id = ${familyId}`;
|
||||
await sql`UPDATE support_tickets SET family_id = NULL WHERE family_id = ${familyId}`;
|
||||
await sql`UPDATE audit_log SET family_id = NULL WHERE family_id = ${familyId}`;
|
||||
await sql`DELETE FROM family_members WHERE family_id = ${familyId}`;
|
||||
await sql`DELETE FROM families WHERE id = ${familyId}`;
|
||||
|
||||
return NextResponse.json({ success: true, deletedChildren: childIds.length });
|
||||
} catch (error) {
|
||||
console.error("Admin family delete error:", error);
|
||||
return NextResponse.json({ error: String(error) }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
|
@ -17,10 +17,16 @@ export async function GET(request: Request) {
|
|||
f.max_members,
|
||||
f.created_at,
|
||||
COUNT(DISTINCT fm.user_id) as user_count,
|
||||
COUNT(DISTINCT c.id) as child_count
|
||||
COUNT(DISTINCT c.id) as child_count,
|
||||
COUNT(DISTINCT fd.id) + COUNT(DISTINCT dl.id) + COUNT(DISTINCT sl.id) as log_count,
|
||||
COUNT(DISTINCT mem.id) as memory_count
|
||||
FROM families f
|
||||
LEFT JOIN family_members fm ON fm.family_id = f.id
|
||||
LEFT JOIN children c ON c.family_id = f.id
|
||||
LEFT JOIN feeds fd ON fd.child_id = c.id
|
||||
LEFT JOIN diapers_logs dl ON dl.child_id = c.id
|
||||
LEFT JOIN sleeps sl ON sl.child_id = c.id
|
||||
LEFT JOIN memories mem ON mem.family_id = f.id
|
||||
GROUP BY f.id
|
||||
ORDER BY f.created_at DESC
|
||||
`;
|
||||
|
|
@ -58,6 +64,8 @@ export async function GET(request: Request) {
|
|||
createdAt: f.created_at ? new Date(f.created_at).toISOString() : null,
|
||||
userCount: Number(f.user_count) || 0,
|
||||
childCount: Number(f.child_count) || 0,
|
||||
logCount: Number(f.log_count) || 0,
|
||||
memoryCount: Number(f.memory_count) || 0,
|
||||
members: memberMap.get(f.id) || [],
|
||||
})),
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue