Implemented responsive card layout for mobile devices across all admin pages: - Desktop (md+): Shows traditional table layout - Mobile (<md): Shows stacked card layout with better readability AdminProducts.tsx: - Mobile cards display title, type, price (with sale badge), status - Action buttons (edit/delete) in header AdminOrders.tsx: - Mobile cards display order ID, email, status badge, total, payment method, date - View detail button in header AdminMembers.tsx: - Mobile cards display name, email, role badge, join date - Action buttons (detail/toggle admin) at bottom with full width AdminConsulting.tsx (upcoming & past tabs): - Mobile cards display date, time, client, category, status, meet link - Action buttons (link/complete/cancel) stacked at bottom AdminEvents.tsx (events & availability tabs): - Mobile cards display title/event type or block type, dates, status, notes - Action buttons (edit/delete) at bottom This approach provides much better UX on mobile compared to horizontal scrolling, especially for complex cells like sale prices with badges and multiple action buttons.
249 lines
10 KiB
TypeScript
249 lines
10 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 { formatDateTime } from "@/lib/format";
|
|
import { Eye, Shield, ShieldOff } from "lucide-react";
|
|
import { toast } from "@/hooks/use-toast";
|
|
|
|
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);
|
|
|
|
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);
|
|
};
|
|
|
|
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();
|
|
}
|
|
}
|
|
};
|
|
|
|
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>
|
|
|
|
<Card className="border-2 border-border">
|
|
<CardContent className="p-0">
|
|
{/* Desktop Table */}
|
|
<div className="hidden md:block 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>
|
|
{members.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>
|
|
</TableCell>
|
|
</TableRow>
|
|
))}
|
|
{members.length === 0 && (
|
|
<TableRow>
|
|
<TableCell colSpan={5} className="text-center py-8 text-muted-foreground">
|
|
Belum ada member
|
|
</TableCell>
|
|
</TableRow>
|
|
)}
|
|
</TableBody>
|
|
</Table>
|
|
</div>
|
|
|
|
{/* Mobile Card Layout */}
|
|
<div className="md:hidden space-y-3 p-4">
|
|
{members.map((member) => (
|
|
<Card key={member.id} className="border-2 border-border">
|
|
<CardContent className="p-4 space-y-3">
|
|
<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>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
))}
|
|
{members.length === 0 && (
|
|
<div className="text-center py-8 text-muted-foreground">
|
|
Belum ada member
|
|
</div>
|
|
)}
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<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>
|
|
</div>
|
|
</AppLayout>
|
|
);
|
|
}
|