507 lines
20 KiB
TypeScript
507 lines
20 KiB
TypeScript
import { useEffect, useState } from "react";
|
|
import { useNavigate } from "react-router-dom";
|
|
import { supabase } from "@/integrations/supabase/client";
|
|
import { useAuth } from "@/hooks/useAuth";
|
|
import { AppLayout } from "@/components/AppLayout";
|
|
import { Card, CardContent } from "@/components/ui/card";
|
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
|
import { Badge } from "@/components/ui/badge";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
|
import { Skeleton } from "@/components/ui/skeleton";
|
|
import { Input } from "@/components/ui/input";
|
|
import { formatDateTime } from "@/lib/format";
|
|
import { Eye, Shield, ShieldOff, Search, X, Trash2 } from "lucide-react";
|
|
import { toast } from "@/hooks/use-toast";
|
|
import {
|
|
AlertDialog,
|
|
AlertDialogAction,
|
|
AlertDialogCancel,
|
|
AlertDialogContent,
|
|
AlertDialogDescription,
|
|
AlertDialogFooter,
|
|
AlertDialogHeader,
|
|
AlertDialogTitle,
|
|
} from "@/components/ui/alert-dialog";
|
|
|
|
interface Member {
|
|
id: string;
|
|
email: string | null;
|
|
name: string | null;
|
|
created_at: string;
|
|
isAdmin?: boolean;
|
|
}
|
|
|
|
interface UserAccess {
|
|
id: string;
|
|
granted_at: string;
|
|
product: { title: string };
|
|
}
|
|
|
|
export default function AdminMembers() {
|
|
const { user, isAdmin, loading: authLoading } = useAuth();
|
|
const navigate = useNavigate();
|
|
const [members, setMembers] = useState<Member[]>([]);
|
|
const [adminIds, setAdminIds] = useState<Set<string>>(new Set());
|
|
const [loading, setLoading] = useState(true);
|
|
const [selectedMember, setSelectedMember] = useState<Member | null>(null);
|
|
const [memberAccess, setMemberAccess] = useState<UserAccess[]>([]);
|
|
const [dialogOpen, setDialogOpen] = useState(false);
|
|
const [searchQuery, setSearchQuery] = useState('');
|
|
const [filterRole, setFilterRole] = useState<string>('all');
|
|
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
|
const [memberToDelete, setMemberToDelete] = useState<Member | null>(null);
|
|
const [isDeleting, setIsDeleting] = useState(false);
|
|
|
|
useEffect(() => {
|
|
if (!authLoading) {
|
|
if (!user) navigate("/auth");
|
|
else if (!isAdmin) navigate("/dashboard");
|
|
else fetchMembers();
|
|
}
|
|
}, [user, isAdmin, authLoading]);
|
|
|
|
const fetchMembers = async () => {
|
|
const [profilesRes, rolesRes] = await Promise.all([
|
|
supabase.from("profiles").select("*").order("created_at", { ascending: false }),
|
|
supabase.from("user_roles").select("user_id").eq("role", "admin"),
|
|
]);
|
|
|
|
const admins = new Set((rolesRes.data || []).map((r) => r.user_id));
|
|
setAdminIds(admins);
|
|
|
|
if (profilesRes.data) {
|
|
setMembers(profilesRes.data.map((p) => ({ ...p, isAdmin: admins.has(p.id) })));
|
|
}
|
|
setLoading(false);
|
|
};
|
|
|
|
// Filter members based on search and role
|
|
const filteredMembers = members.filter((member) => {
|
|
const matchesSearch =
|
|
member.name?.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
|
member.email?.toLowerCase().includes(searchQuery.toLowerCase());
|
|
|
|
const matchesRole =
|
|
filterRole === 'all' ||
|
|
(filterRole === 'admin' && adminIds.has(member.id)) ||
|
|
(filterRole === 'member' && !adminIds.has(member.id));
|
|
|
|
return matchesSearch && matchesRole;
|
|
});
|
|
|
|
const clearFilters = () => {
|
|
setSearchQuery('');
|
|
setFilterRole('all');
|
|
};
|
|
|
|
const viewMemberDetails = async (member: Member) => {
|
|
setSelectedMember(member);
|
|
const { data } = await supabase.from("user_access").select("*, product:products(title)").eq("user_id", member.id);
|
|
setMemberAccess((data as unknown as UserAccess[]) || []);
|
|
setDialogOpen(true);
|
|
};
|
|
|
|
const toggleAdminRole = async (memberId: string, currentlyAdmin: boolean) => {
|
|
if (currentlyAdmin) {
|
|
const { error } = await supabase.from("user_roles").delete().eq("user_id", memberId).eq("role", "admin");
|
|
if (error) toast({ title: "Error", description: "Gagal menghapus role admin", variant: "destructive" });
|
|
else {
|
|
toast({ title: "Berhasil", description: "Role admin dihapus" });
|
|
fetchMembers();
|
|
}
|
|
} else {
|
|
const { error } = await supabase.from("user_roles").insert({ user_id: memberId, role: "admin" });
|
|
if (error) toast({ title: "Error", description: error.message, variant: "destructive" });
|
|
else {
|
|
toast({ title: "Berhasil", description: "Role admin ditambahkan" });
|
|
fetchMembers();
|
|
}
|
|
}
|
|
};
|
|
|
|
const confirmDeleteMember = (member: Member) => {
|
|
if (member.id === user?.id) {
|
|
toast({ title: "Error", description: "Tidak bisa menghapus akun sendiri", variant: "destructive" });
|
|
return;
|
|
}
|
|
setMemberToDelete(member);
|
|
setDeleteDialogOpen(true);
|
|
};
|
|
|
|
const deleteMember = async () => {
|
|
if (!memberToDelete) return;
|
|
|
|
setIsDeleting(true);
|
|
try {
|
|
const userId = memberToDelete.id;
|
|
|
|
// Step 1: Delete auth_otps
|
|
await supabase.from("auth_otps").delete().eq("user_id", userId);
|
|
|
|
// Step 2: Delete order_items (first to avoid FK issues)
|
|
const { data: orders } = await supabase.from("orders").select("id").eq("user_id", userId);
|
|
if (orders && orders.length > 0) {
|
|
const orderIds = orders.map(o => o.id);
|
|
await supabase.from("order_items").delete().in("order_id", orderIds);
|
|
}
|
|
|
|
// Step 3: Delete orders
|
|
await supabase.from("orders").delete().eq("user_id", userId);
|
|
|
|
// Step 4: Delete user_access
|
|
await supabase.from("user_access").delete().eq("user_id", userId);
|
|
|
|
// Step 5: Delete video_progress
|
|
await supabase.from("video_progress").delete().eq("user_id", userId);
|
|
|
|
// Step 6: Delete collaboration withdrawals + wallet records
|
|
await supabase.from("withdrawals").delete().eq("user_id", userId);
|
|
await supabase.from("wallet_transactions").delete().eq("user_id", userId);
|
|
await supabase.from("collaborator_wallets").delete().eq("user_id", userId);
|
|
|
|
// Step 7: Delete consulting_slots
|
|
await supabase.from("consulting_slots").delete().eq("user_id", userId);
|
|
|
|
// Step 8: Delete calendar_events
|
|
await supabase.from("calendar_events").delete().eq("user_id", userId);
|
|
|
|
// Step 9: Delete user_roles
|
|
await supabase.from("user_roles").delete().eq("user_id", userId);
|
|
|
|
// Step 10: Delete profile
|
|
await supabase.from("profiles").delete().eq("id", userId);
|
|
|
|
// Step 11: Delete from auth.users using edge function
|
|
const { error: deleteError } = await supabase.functions.invoke('delete-user', {
|
|
body: { user_id: userId }
|
|
});
|
|
|
|
if (deleteError) {
|
|
console.error('Error deleting from auth.users:', deleteError);
|
|
throw new Error(`Gagal menghapus user dari auth: ${deleteError.message}`);
|
|
}
|
|
|
|
toast({
|
|
title: "Berhasil",
|
|
description: `Member ${memberToDelete.email || memberToDelete.name} berhasil dihapus beserta semua data terkait`
|
|
});
|
|
|
|
setDeleteDialogOpen(false);
|
|
setMemberToDelete(null);
|
|
fetchMembers();
|
|
} catch (error: unknown) {
|
|
const message = error instanceof Error ? error.message : "Gagal menghapus member";
|
|
console.error('Delete member error:', error);
|
|
toast({
|
|
title: "Error",
|
|
description: message,
|
|
variant: "destructive"
|
|
});
|
|
} finally {
|
|
setIsDeleting(false);
|
|
}
|
|
};
|
|
|
|
if (authLoading || loading) {
|
|
return (
|
|
<AppLayout>
|
|
<div className="container mx-auto px-4 py-8">
|
|
<Skeleton className="h-10 w-1/3 mb-8" />
|
|
<Skeleton className="h-64 w-full" />
|
|
</div>
|
|
</AppLayout>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<AppLayout>
|
|
<div className="container mx-auto px-4 py-8">
|
|
<h1 className="text-4xl font-bold mb-2">Manajemen Member</h1>
|
|
<p className="text-muted-foreground mb-8">Kelola semua pengguna</p>
|
|
|
|
{/* Search & Filter */}
|
|
<Card className="border-2 border-border mb-6">
|
|
<CardContent className="pt-6">
|
|
<div className="space-y-4">
|
|
<div className="relative">
|
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
|
|
<Input
|
|
placeholder="Cari nama atau email..."
|
|
value={searchQuery}
|
|
onChange={(e) => setSearchQuery(e.target.value)}
|
|
className="pl-10 border-2"
|
|
/>
|
|
{searchQuery && (
|
|
<button
|
|
onClick={() => setSearchQuery('')}
|
|
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
|
>
|
|
<X className="w-4 h-4" />
|
|
</button>
|
|
)}
|
|
</div>
|
|
|
|
<div className="flex flex-wrap gap-2 items-center">
|
|
<span className="text-sm font-medium text-muted-foreground">Role:</span>
|
|
<Button
|
|
variant={filterRole === 'all' ? 'default' : 'outline'}
|
|
size="sm"
|
|
onClick={() => setFilterRole('all')}
|
|
className={filterRole === 'all' ? 'shadow-sm' : 'border-2'}
|
|
>
|
|
Semua
|
|
</Button>
|
|
<Button
|
|
variant={filterRole === 'admin' ? 'default' : 'outline'}
|
|
size="sm"
|
|
onClick={() => setFilterRole('admin')}
|
|
className={filterRole === 'admin' ? 'shadow-sm' : 'border-2'}
|
|
>
|
|
Admin
|
|
</Button>
|
|
<Button
|
|
variant={filterRole === 'member' ? 'default' : 'outline'}
|
|
size="sm"
|
|
onClick={() => setFilterRole('member')}
|
|
className={filterRole === 'member' ? 'shadow-sm' : 'border-2'}
|
|
>
|
|
Member
|
|
</Button>
|
|
{(searchQuery || filterRole !== 'all') && (
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={clearFilters}
|
|
className="text-muted-foreground hover:text-destructive"
|
|
>
|
|
<X className="w-4 h-4 mr-1" />
|
|
Reset
|
|
</Button>
|
|
)}
|
|
</div>
|
|
|
|
<p className="text-sm text-muted-foreground">
|
|
Menampilkan {filteredMembers.length} dari {members.length} member
|
|
</p>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{filteredMembers.length === 0 ? (
|
|
<Card className="border-2 border-border">
|
|
<CardContent className="py-12 text-center">
|
|
<p className="text-muted-foreground">
|
|
{searchQuery || filterRole !== 'all'
|
|
? 'Tidak ada member yang cocok dengan filter'
|
|
: 'Belum ada member'}
|
|
</p>
|
|
</CardContent>
|
|
</Card>
|
|
) : (
|
|
<>
|
|
<Card className="border-2 border-border hidden md:block">
|
|
<CardContent className="p-0">
|
|
{/* Desktop Table */}
|
|
<div className="overflow-x-auto">
|
|
<Table>
|
|
<TableHeader>
|
|
<TableRow>
|
|
<TableHead className="whitespace-nowrap">Email</TableHead>
|
|
<TableHead className="whitespace-nowrap">Nama</TableHead>
|
|
<TableHead className="whitespace-nowrap">Role</TableHead>
|
|
<TableHead className="whitespace-nowrap">Bergabung</TableHead>
|
|
<TableHead className="text-right whitespace-nowrap">Aksi</TableHead>
|
|
</TableRow>
|
|
</TableHeader>
|
|
<TableBody>
|
|
{filteredMembers.map((member) => (
|
|
<TableRow key={member.id}>
|
|
<TableCell>{member.email || "-"}</TableCell>
|
|
<TableCell>{member.name || "-"}</TableCell>
|
|
<TableCell>
|
|
{adminIds.has(member.id) ? (
|
|
<Badge className="bg-primary">Admin</Badge>
|
|
) : (
|
|
<Badge className="bg-secondary text-primary">Member</Badge>
|
|
)}
|
|
</TableCell>
|
|
<TableCell>{formatDateTime(member.created_at)}</TableCell>
|
|
<TableCell className="text-right">
|
|
<Button variant="ghost" size="sm" onClick={() => viewMemberDetails(member)}>
|
|
<Eye className="w-4 h-4" />
|
|
</Button>
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => toggleAdminRole(member.id, adminIds.has(member.id))}
|
|
disabled={member.id === user?.id}
|
|
>
|
|
{adminIds.has(member.id) ? <ShieldOff className="w-4 h-4" /> : <Shield className="w-4 h-4" />}
|
|
</Button>
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => confirmDeleteMember(member)}
|
|
disabled={member.id === user?.id}
|
|
className="text-destructive hover:text-destructive hover:bg-destructive/10"
|
|
>
|
|
<Trash2 className="w-4 h-4" />
|
|
</Button>
|
|
</TableCell>
|
|
</TableRow>
|
|
))}
|
|
</TableBody>
|
|
</Table>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* Mobile Card Layout */}
|
|
<div className="md:hidden space-y-3">
|
|
{filteredMembers.map((member) => (
|
|
<div key={member.id} className="border-2 border-border rounded-lg p-4 space-y-3 bg-card shadow-sm">
|
|
<div>
|
|
<div className="flex items-start justify-between gap-2">
|
|
<div className="flex-1 min-w-0">
|
|
<h3 className="font-semibold text-base line-clamp-1">{member.name || "Tanpa Nama"}</h3>
|
|
<p className="text-sm text-muted-foreground truncate">{member.email || "-"}</p>
|
|
</div>
|
|
{adminIds.has(member.id) ? (
|
|
<Badge className="bg-primary shrink-0">Admin</Badge>
|
|
) : (
|
|
<Badge className="bg-secondary text-primary shrink-0">Member</Badge>
|
|
)}
|
|
</div>
|
|
<div className="space-y-1">
|
|
<div className="flex items-center justify-between">
|
|
<span className="text-sm text-muted-foreground">Bergabung:</span>
|
|
<span className="text-sm">{formatDateTime(member.created_at)}</span>
|
|
</div>
|
|
</div>
|
|
<div className="flex gap-2 pt-2 border-t border-border">
|
|
<Button variant="ghost" size="sm" onClick={() => viewMemberDetails(member)} className="flex-1">
|
|
<Eye className="w-4 h-4 mr-1" />
|
|
Detail
|
|
</Button>
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => toggleAdminRole(member.id, adminIds.has(member.id))}
|
|
disabled={member.id === user?.id}
|
|
className="flex-1"
|
|
>
|
|
{adminIds.has(member.id) ? <ShieldOff className="w-4 h-4 mr-1" /> : <Shield className="w-4 h-4 mr-1" />}
|
|
{adminIds.has(member.id) ? "Hapus Admin" : "Jadikan Admin"}
|
|
</Button>
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => confirmDeleteMember(member)}
|
|
disabled={member.id === user?.id}
|
|
className="flex-1 text-destructive hover:text-destructive hover:bg-destructive/10"
|
|
>
|
|
<Trash2 className="w-4 h-4 mr-1" />
|
|
Hapus
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</>
|
|
)}
|
|
|
|
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
|
<DialogContent className="max-w-lg border-2 border-border">
|
|
<DialogHeader>
|
|
<DialogTitle>Detail Member</DialogTitle>
|
|
</DialogHeader>
|
|
{selectedMember && (
|
|
<div className="space-y-4">
|
|
<div className="text-sm space-y-1">
|
|
<p>
|
|
<span className="text-muted-foreground">Email:</span> {selectedMember.email}
|
|
</p>
|
|
<p>
|
|
<span className="text-muted-foreground">Nama:</span> {selectedMember.name || "-"}
|
|
</p>
|
|
<p>
|
|
<span className="text-muted-foreground">ID:</span> {selectedMember.id}
|
|
</p>
|
|
</div>
|
|
<div className="border-t border-border pt-4">
|
|
<p className="font-medium mb-2">Akses Produk:</p>
|
|
{memberAccess.length === 0 ? (
|
|
<p className="text-muted-foreground text-sm">Tidak ada akses</p>
|
|
) : (
|
|
<div className="space-y-1">
|
|
{memberAccess.map((access) => (
|
|
<div key={access.id} className="flex justify-between text-sm">
|
|
<span>{access.product?.title}</span>
|
|
<span className="text-muted-foreground">{formatDateTime(access.granted_at)}</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</DialogContent>
|
|
</Dialog>
|
|
|
|
<AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
|
|
<AlertDialogContent className="border-2 border-border">
|
|
<AlertDialogHeader>
|
|
<AlertDialogTitle>Hapus Member?</AlertDialogTitle>
|
|
<AlertDialogDescription asChild>
|
|
<div className="space-y-2">
|
|
<p>
|
|
Anda akan menghapus member <strong>{memberToDelete?.email || memberToDelete?.name}</strong>.
|
|
</p>
|
|
<p className="text-destructive font-medium">
|
|
Tindakan ini akan menghapus SEMUA data terkait member ini:
|
|
</p>
|
|
<ul className="list-disc list-inside text-sm text-muted-foreground space-y-1">
|
|
<li>Order dan item order</li>
|
|
<li>Akses produk</li>
|
|
<li>Progress video</li>
|
|
<li>Jadwal konsultasi</li>
|
|
<li>Event kalender</li>
|
|
<li>Role admin (jika ada)</li>
|
|
<li>Profil user</li>
|
|
<li>Akun autentikasi</li>
|
|
</ul>
|
|
<p className="text-sm text-muted-foreground">
|
|
Tindakan ini <strong>TIDAK BISA dibatalkan</strong>.
|
|
</p>
|
|
</div>
|
|
</AlertDialogDescription>
|
|
</AlertDialogHeader>
|
|
<AlertDialogFooter>
|
|
<AlertDialogCancel disabled={isDeleting}>Batal</AlertDialogCancel>
|
|
<AlertDialogAction
|
|
onClick={deleteMember}
|
|
disabled={isDeleting}
|
|
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
|
>
|
|
{isDeleting ? (
|
|
<>
|
|
<span className="animate-spin mr-2">⏳</span>
|
|
Menghapus...
|
|
</>
|
|
) : (
|
|
<>
|
|
<Trash2 className="w-4 h-4 mr-2" />
|
|
Ya, Hapus Member
|
|
</>
|
|
)}
|
|
</AlertDialogAction>
|
|
</AlertDialogFooter>
|
|
</AlertDialogContent>
|
|
</AlertDialog>
|
|
</div>
|
|
</AppLayout>
|
|
);
|
|
}
|