Replace raw <input>/<button>/<select> elements with Button, Card, Input, Select, Modal, Badge, and ConfirmDialog from @/components/ui in all non-admin and admin pages. Removes ~550 lines of inline Tailwind utility classes from form elements while keeping all business logic intact. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
239 lines
8.2 KiB
TypeScript
239 lines
8.2 KiB
TypeScript
"use client";
|
|
|
|
import { useEffect, useState } from "react";
|
|
import { Button, Input, Select, Badge } from "@/components/ui";
|
|
|
|
interface Member {
|
|
id: string;
|
|
userId: string;
|
|
email: string;
|
|
role: string;
|
|
displayName: string;
|
|
}
|
|
|
|
interface Family {
|
|
id: string;
|
|
name: string;
|
|
tier: string;
|
|
maxChildren: number;
|
|
maxMembers: number;
|
|
createdAt: string;
|
|
userCount: number;
|
|
childCount: number;
|
|
members: Member[];
|
|
}
|
|
|
|
export default function AdminFamilies() {
|
|
const [families, setFamilies] = useState<Family[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const [search, setSearch] = useState("");
|
|
const [tierFilter, setTierFilter] = useState("all");
|
|
const [showMembers, setShowMembers] = useState<string | null>(null);
|
|
const [addMember, setAddMember] = useState<{familyId: string; email: string; role: string; name: string} | null>(null);
|
|
|
|
useEffect(() => {
|
|
fetchFamilies();
|
|
}, []);
|
|
|
|
const fetchFamilies = async () => {
|
|
try {
|
|
const res = await fetch("/api/admin/families", { credentials: "include" });
|
|
const data = await res.json();
|
|
if (data.error) {
|
|
console.error("API error:", data.error);
|
|
}
|
|
setFamilies(data.families || []);
|
|
} catch (err) {
|
|
console.error("Failed to fetch families:", err);
|
|
}
|
|
setLoading(false);
|
|
};
|
|
|
|
const handleCreateFamily = async () => {
|
|
const name = prompt("Family name:");
|
|
if (!name) return;
|
|
try {
|
|
const res = await fetch("/api/admin/families", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
credentials: "include",
|
|
body: JSON.stringify({ name }),
|
|
});
|
|
if (res.ok) fetchFamilies();
|
|
} catch (err) {
|
|
console.error("Failed to create:", err);
|
|
}
|
|
};
|
|
|
|
const handleAddMember = async () => {
|
|
if (!addMember?.email || !addMember?.familyId) return;
|
|
try {
|
|
const res = await fetch("/api/admin/families", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
credentials: "include",
|
|
body: JSON.stringify(addMember),
|
|
});
|
|
if (res.ok) {
|
|
fetchFamilies();
|
|
setAddMember(null);
|
|
}
|
|
} catch (err) {
|
|
console.error("Failed to add member:", err);
|
|
}
|
|
};
|
|
|
|
const handleRemoveMember = async (memberId: string) => {
|
|
if (!confirm("Remove this member?")) return;
|
|
try {
|
|
const res = await fetch(`/api/admin/families?memberId=${memberId}`, {
|
|
method: "DELETE",
|
|
credentials: "include",
|
|
});
|
|
if (res.ok) fetchFamilies();
|
|
} catch (err) {
|
|
console.error("Failed to remove:", err);
|
|
}
|
|
};
|
|
|
|
const filteredFamilies = families.filter((f) => {
|
|
const matchesSearch = f.name.toLowerCase().includes(search.toLowerCase());
|
|
const matchesTier = tierFilter === "all" || f.tier === tierFilter;
|
|
return matchesSearch && matchesTier;
|
|
});
|
|
|
|
const exportCSV = () => {
|
|
const headers = ["Name", "Tier", "Users", "Children", "Max Children", "Max Members", "Created"];
|
|
const rows = filteredFamilies.map((f) => [
|
|
f.name,
|
|
f.tier,
|
|
f.userCount,
|
|
f.childCount,
|
|
f.maxChildren,
|
|
f.maxMembers,
|
|
f.createdAt,
|
|
]);
|
|
const csv = [headers, ...rows].map((row) => row.join(",")).join("\n");
|
|
const blob = new Blob([csv], { type: "text/csv" });
|
|
const url = URL.createObjectURL(blob);
|
|
const a = document.createElement("a");
|
|
a.href = url;
|
|
a.download = "families.csv";
|
|
a.click();
|
|
};
|
|
|
|
if (loading) {
|
|
return (
|
|
<div className="p-6 text-white">Loading...</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="p-6 space-y-4">
|
|
<div className="flex justify-between items-center">
|
|
<div>
|
|
<h1 className="text-2xl font-bold">Families</h1>
|
|
<p className="text-gray-400">{families.length} total families</p>
|
|
</div>
|
|
<div className="flex gap-2">
|
|
<Button onClick={handleCreateFamily}>+ New Family</Button>
|
|
<Button variant="secondary" onClick={exportCSV}>Export CSV</Button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Filters */}
|
|
<div className="flex gap-4">
|
|
<Input
|
|
type="text"
|
|
placeholder="Search families..."
|
|
value={search}
|
|
onChange={(e) => setSearch(e.target.value)}
|
|
className="flex-1"
|
|
/>
|
|
<Select
|
|
value={tierFilter}
|
|
onChange={(e) => setTierFilter(e.target.value)}
|
|
>
|
|
<option value="all">All Tiers</option>
|
|
<option value="free">Free</option>
|
|
<option value="pro">Pro</option>
|
|
</Select>
|
|
</div>
|
|
|
|
{/* Table */}
|
|
<div className="bg-gray-800 rounded-xl overflow-hidden">
|
|
<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>
|
|
<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">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">Created</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody className="divide-y divide-gray-700">
|
|
{filteredFamilies.map((family) => (
|
|
<>
|
|
<tr key={family.id} className="hover:bg-gray-750">
|
|
<td className="px-4 py-3">
|
|
<div className="font-medium">{family.name}</div>
|
|
<div className="text-xs text-gray-500">{family.id}</div>
|
|
</td>
|
|
<td className="px-4 py-3">
|
|
<Badge variant={family.tier === "pro" ? "rose" : "default"}>{family.tier}</Badge>
|
|
</td>
|
|
<td className="px-4 py-3">
|
|
<Button variant="ghost" size="sm" onClick={() => setShowMembers(showMembers === family.id ? null : family.id)}>
|
|
{family.userCount} users
|
|
</Button>
|
|
</td>
|
|
<td className="px-4 py-3">{family.childCount}</td>
|
|
<td className="px-4 py-3 text-sm text-gray-400">
|
|
{family.maxChildren} kids, {family.maxMembers} members
|
|
</td>
|
|
<td className="px-4 py-3 text-sm text-gray-400">
|
|
{family.createdAt?.slice(0, 10)}
|
|
</td>
|
|
</tr>
|
|
{showMembers === family.id && (
|
|
<tr>
|
|
<td colSpan={6} className="bg-gray-800 p-4">
|
|
<div className="space-y-2">
|
|
<div className="flex gap-2 items-center">
|
|
<Input
|
|
type="email"
|
|
placeholder="New member email"
|
|
onChange={(e) => setAddMember({familyId: family.id, email: e.target.value, role: "caregiver", name: ""})}
|
|
className="flex-1"
|
|
/>
|
|
<Select
|
|
onChange={(e) => setAddMember(addMember ? {...addMember, role: e.target.value} : null)}
|
|
>
|
|
<option value="caregiver">Caregiver</option>
|
|
<option value="owner">Owner</option>
|
|
</Select>
|
|
<Button size="sm" onClick={handleAddMember}>Add</Button>
|
|
</div>
|
|
{(family.members || []).map((m) => (
|
|
<div key={m.id} className="flex justify-between items-center bg-gray-700 p-2 rounded">
|
|
<span>{m.email} ({m.role})</span>
|
|
<Button variant="danger" size="sm" onClick={() => handleRemoveMember(m.id)}>Remove</Button>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
)}
|
|
</>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
{filteredFamilies.length === 0 && (
|
|
<div className="p-8 text-center text-gray-500">No families found</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|