This commit is contained in:
gpt-engineer-app[bot]
2025-12-19 03:26:31 +00:00
parent 4f16122e25
commit 986c7c6992
14 changed files with 1953 additions and 40 deletions

View File

@@ -0,0 +1,91 @@
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, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Skeleton } from '@/components/ui/skeleton';
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from '@/components/ui/accordion';
import { CurriculumEditor } from '@/components/admin/CurriculumEditor';
import { BookOpen } from 'lucide-react';
interface Product {
id: string;
title: string;
slug: string;
}
export default function AdminBootcamp() {
const { user, isAdmin, loading: authLoading } = useAuth();
const navigate = useNavigate();
const [bootcamps, setBootcamps] = useState<Product[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
if (!authLoading) {
if (!user) navigate('/auth');
else if (!isAdmin) navigate('/dashboard');
else fetchBootcamps();
}
}, [user, isAdmin, authLoading]);
const fetchBootcamps = async () => {
const { data, error } = await supabase
.from('products')
.select('id, title, slug')
.eq('type', 'bootcamp')
.order('created_at', { ascending: false });
if (!error && data) setBootcamps(data);
setLoading(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">
<div className="flex items-center gap-3 mb-8">
<BookOpen className="w-8 h-8" />
<div>
<h1 className="text-4xl font-bold">Manajemen Bootcamp</h1>
<p className="text-muted-foreground">Kelola kurikulum bootcamp</p>
</div>
</div>
{bootcamps.length === 0 ? (
<Card className="border-2 border-border">
<CardContent className="py-12 text-center">
<p className="text-muted-foreground mb-4">Belum ada bootcamp. Buat produk dengan tipe bootcamp terlebih dahulu.</p>
<Button onClick={() => navigate('/admin/products')} variant="outline" className="border-2">
Ke Manajemen Produk
</Button>
</CardContent>
</Card>
) : (
<Accordion type="single" collapsible className="space-y-4">
{bootcamps.map((bootcamp) => (
<AccordionItem key={bootcamp.id} value={bootcamp.id} className="border-2 border-border bg-card">
<AccordionTrigger className="px-4 hover:no-underline">
<span className="font-bold">{bootcamp.title}</span>
</AccordionTrigger>
<AccordionContent className="px-4 pb-4">
<CurriculumEditor productId={bootcamp.id} />
</AccordionContent>
</AccordionItem>
))}
</Accordion>
)}
</div>
</AppLayout>
);
}

View File

@@ -0,0 +1,134 @@
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, CardHeader, CardTitle } from '@/components/ui/card';
import { Skeleton } from '@/components/ui/skeleton';
import { formatIDR } from '@/lib/format';
import { Package, Users, Receipt, TrendingUp, BookOpen, Calendar } from 'lucide-react';
interface Stats {
totalProducts: number;
totalMembers: number;
totalOrders: number;
totalRevenue: number;
pendingOrders: number;
activeBootcamps: number;
}
export default function AdminDashboard() {
const { user, isAdmin, loading: authLoading } = useAuth();
const navigate = useNavigate();
const [stats, setStats] = useState<Stats | null>(null);
const [recentOrders, setRecentOrders] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
if (!authLoading) {
if (!user) navigate('/auth');
else if (!isAdmin) navigate('/dashboard');
else fetchData();
}
}, [user, isAdmin, authLoading]);
const fetchData = async () => {
const [productsRes, profilesRes, ordersRes, paidOrdersRes, bootcampRes] = await Promise.all([
supabase.from('products').select('id', { count: 'exact' }),
supabase.from('profiles').select('id', { count: 'exact' }),
supabase.from('orders').select('*').order('created_at', { ascending: false }).limit(5),
supabase.from('orders').select('total_amount').eq('payment_status', 'paid'),
supabase.from('products').select('id', { count: 'exact' }).eq('type', 'bootcamp').eq('is_active', true),
]);
const pendingRes = await supabase.from('orders').select('id', { count: 'exact' }).eq('payment_status', 'pending');
const totalRevenue = paidOrdersRes.data?.reduce((sum, o) => sum + (o.total_amount || 0), 0) || 0;
setStats({
totalProducts: productsRes.count || 0,
totalMembers: profilesRes.count || 0,
totalOrders: ordersRes.data?.length || 0,
totalRevenue,
pendingOrders: pendingRes.count || 0,
activeBootcamps: bootcampRes.count || 0,
});
setRecentOrders(ordersRes.data || []);
setLoading(false);
};
if (authLoading || loading) {
return (
<AppLayout>
<div className="container mx-auto px-4 py-8">
<Skeleton className="h-10 w-1/3 mb-8" />
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
{[...Array(4)].map((_, i) => <Skeleton key={i} className="h-32" />)}
</div>
</div>
</AppLayout>
);
}
const statCards = [
{ label: 'Total Produk', value: stats?.totalProducts || 0, icon: Package, color: 'text-primary' },
{ label: 'Total Member', value: stats?.totalMembers || 0, icon: Users, color: 'text-accent' },
{ label: 'Order Pending', value: stats?.pendingOrders || 0, icon: Receipt, color: 'text-secondary-foreground' },
{ label: 'Total Pendapatan', value: formatIDR(stats?.totalRevenue || 0), icon: TrendingUp, color: 'text-primary' },
{ label: 'Bootcamp Aktif', value: stats?.activeBootcamps || 0, icon: BookOpen, color: 'text-accent' },
];
return (
<AppLayout>
<div className="container mx-auto px-4 py-8">
<h1 className="text-4xl font-bold mb-2">Admin Dashboard</h1>
<p className="text-muted-foreground mb-8">Ringkasan statistik platform</p>
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-5 gap-4 mb-8">
{statCards.map((stat) => (
<Card key={stat.label} className="border-2 border-border">
<CardContent className="pt-6">
<div className="flex items-center gap-3">
<stat.icon className={`w-8 h-8 ${stat.color}`} />
<div>
<p className="text-2xl font-bold">{stat.value}</p>
<p className="text-xs text-muted-foreground">{stat.label}</p>
</div>
</div>
</CardContent>
</Card>
))}
</div>
<Card className="border-2 border-border">
<CardHeader>
<CardTitle>Order Terbaru</CardTitle>
</CardHeader>
<CardContent>
{recentOrders.length === 0 ? (
<p className="text-muted-foreground text-center py-8">Belum ada order</p>
) : (
<div className="space-y-4">
{recentOrders.map((order) => (
<div key={order.id} className="flex items-center justify-between py-2 border-b border-border last:border-0">
<div>
<p className="font-mono text-sm">{order.id.slice(0, 8)}</p>
<p className="text-xs text-muted-foreground">{new Date(order.created_at).toLocaleDateString('id-ID')}</p>
</div>
<div className="text-right">
<p className="font-bold">{formatIDR(order.total_amount)}</p>
<span className={`text-xs px-2 py-0.5 ${order.payment_status === 'paid' ? 'bg-accent text-accent-foreground' : 'bg-muted text-muted-foreground'}`}>
{order.payment_status === 'paid' ? 'Lunas' : 'Pending'}
</span>
</div>
</div>
))}
</div>
)}
</CardContent>
</Card>
</div>
</AppLayout>
);
}

View File

@@ -0,0 +1,188 @@
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;
full_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">
<Table>
<TableHeader>
<TableRow>
<TableHead>Email</TableHead>
<TableHead>Nama</TableHead>
<TableHead>Role</TableHead>
<TableHead>Bergabung</TableHead>
<TableHead className="text-right">Aksi</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{members.map((member) => (
<TableRow key={member.id}>
<TableCell>{member.email || '-'}</TableCell>
<TableCell>{member.full_name || '-'}</TableCell>
<TableCell>
{adminIds.has(member.id) ? (
<Badge className="bg-primary">Admin</Badge>
) : (
<Badge className="bg-secondary">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>
</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.full_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>
);
}

View File

@@ -0,0 +1,211 @@
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, CardHeader, CardTitle } 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 { formatIDR, formatDateTime } from '@/lib/format';
import { Eye, CheckCircle, XCircle } from 'lucide-react';
import { toast } from '@/hooks/use-toast';
interface Order {
id: string;
user_id: string;
total_amount: number;
status: string;
payment_status: string | null;
payment_method: string | null;
payment_reference: string | null;
created_at: string;
profile?: { email: string } | null;
}
interface OrderItem {
id: string;
product: { title: string };
unit_price: number;
quantity: number;
}
export default function AdminOrders() {
const { user, isAdmin, loading: authLoading } = useAuth();
const navigate = useNavigate();
const [orders, setOrders] = useState<Order[]>([]);
const [loading, setLoading] = useState(true);
const [selectedOrder, setSelectedOrder] = useState<Order | null>(null);
const [orderItems, setOrderItems] = useState<OrderItem[]>([]);
const [dialogOpen, setDialogOpen] = useState(false);
useEffect(() => {
if (!authLoading) {
if (!user) navigate('/auth');
else if (!isAdmin) navigate('/dashboard');
else fetchOrders();
}
}, [user, isAdmin, authLoading]);
const fetchOrders = async () => {
const { data, error } = await supabase
.from('orders')
.select('*, profile:profiles(email)')
.order('created_at', { ascending: false });
if (!error && data) setOrders(data as unknown as Order[]);
setLoading(false);
};
const viewOrderDetails = async (order: Order) => {
setSelectedOrder(order);
const { data } = await supabase
.from('order_items')
.select('*, product:products(title)')
.eq('order_id', order.id);
setOrderItems(data as unknown as OrderItem[] || []);
setDialogOpen(true);
};
const updateOrderStatus = async (orderId: string, status: 'paid' | 'cancelled') => {
const { error } = await supabase.from('orders').update({ payment_status: status }).eq('id', orderId);
if (error) {
toast({ title: 'Error', description: 'Gagal update status', variant: 'destructive' });
} else {
if (status === 'paid') {
// Grant access for each order item
const { data: items } = await supabase.from('order_items').select('product_id').eq('order_id', orderId);
const order = orders.find(o => o.id === orderId);
if (items && order) {
for (const item of items) {
await supabase.from('user_access').upsert({
user_id: order.user_id,
product_id: item.product_id,
granted_at: new Date().toISOString(),
}, { onConflict: 'user_id,product_id' });
}
}
}
toast({ title: 'Berhasil', description: `Status diubah ke ${status}` });
fetchOrders();
setDialogOpen(false);
}
};
const getStatusBadge = (status: string | null) => {
switch (status) {
case 'paid': return <Badge className="bg-accent">Lunas</Badge>;
case 'pending': return <Badge className="bg-secondary">Pending</Badge>;
case 'cancelled': return <Badge className="bg-destructive">Dibatalkan</Badge>;
default: return <Badge className="bg-muted">{status}</Badge>;
}
};
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 Order</h1>
<p className="text-muted-foreground mb-8">Kelola semua pesanan</p>
<Card className="border-2 border-border">
<CardContent className="p-0">
<Table>
<TableHeader>
<TableRow>
<TableHead>ID Order</TableHead>
<TableHead>Email</TableHead>
<TableHead>Total</TableHead>
<TableHead>Metode</TableHead>
<TableHead>Status</TableHead>
<TableHead>Tanggal</TableHead>
<TableHead className="text-right">Aksi</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{orders.map((order) => (
<TableRow key={order.id}>
<TableCell className="font-mono text-sm">{order.id.slice(0, 8)}</TableCell>
<TableCell>{order.profile?.email || '-'}</TableCell>
<TableCell className="font-bold">{formatIDR(order.total_amount)}</TableCell>
<TableCell className="uppercase text-sm">{order.payment_method || '-'}</TableCell>
<TableCell>{getStatusBadge(order.payment_status)}</TableCell>
<TableCell>{formatDateTime(order.created_at)}</TableCell>
<TableCell className="text-right">
<Button variant="ghost" size="sm" onClick={() => viewOrderDetails(order)}>
<Eye className="w-4 h-4" />
</Button>
</TableCell>
</TableRow>
))}
{orders.length === 0 && (
<TableRow>
<TableCell colSpan={7} className="text-center py-8 text-muted-foreground">
Belum ada order
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</CardContent>
</Card>
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
<DialogContent className="max-w-lg border-2 border-border">
<DialogHeader>
<DialogTitle>Detail Order</DialogTitle>
</DialogHeader>
{selectedOrder && (
<div className="space-y-4">
<div className="grid grid-cols-2 gap-2 text-sm">
<div><span className="text-muted-foreground">ID:</span> {selectedOrder.id.slice(0, 8)}</div>
<div><span className="text-muted-foreground">Referensi:</span> {selectedOrder.payment_reference || '-'}</div>
<div><span className="text-muted-foreground">Email:</span> {selectedOrder.profile?.email}</div>
<div><span className="text-muted-foreground">Metode:</span> {selectedOrder.payment_method || '-'}</div>
</div>
<div className="border-t border-border pt-4">
<p className="font-medium mb-2">Item:</p>
{orderItems.map((item) => (
<div key={item.id} className="flex justify-between py-1">
<span>{item.product?.title}</span>
<span className="font-bold">{formatIDR(item.unit_price)}</span>
</div>
))}
<div className="flex justify-between pt-2 border-t border-border mt-2">
<span className="font-bold">Total</span>
<span className="font-bold">{formatIDR(selectedOrder.total_amount)}</span>
</div>
</div>
<div className="flex gap-2 pt-4">
{selectedOrder.payment_status !== 'paid' && (
<Button onClick={() => updateOrderStatus(selectedOrder.id, 'paid')} className="flex-1">
<CheckCircle className="w-4 h-4 mr-2" />
Tandai Lunas
</Button>
)}
{selectedOrder.payment_status !== 'cancelled' && (
<Button variant="outline" onClick={() => updateOrderStatus(selectedOrder.id, 'cancelled')} className="flex-1 border-2">
<XCircle className="w-4 h-4 mr-2" />
Batalkan
</Button>
)}
</div>
</div>
)}
</DialogContent>
</Dialog>
</div>
</AppLayout>
);
}

View File

@@ -0,0 +1,313 @@
import { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useAuth } from '@/hooks/useAuth';
import { supabase } from '@/integrations/supabase/client';
import { AppLayout } from '@/components/AppLayout';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import { Card, CardContent } from '@/components/ui/card';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Switch } from '@/components/ui/switch';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { toast } from '@/hooks/use-toast';
import { Skeleton } from '@/components/ui/skeleton';
import { Plus, Pencil, Trash2 } from 'lucide-react';
import { CurriculumEditor } from '@/components/admin/CurriculumEditor';
import { RichTextEditor } from '@/components/RichTextEditor';
import { formatIDR } from '@/lib/format';
interface Product {
id: string;
title: string;
slug: string;
type: string;
description: string;
content: string;
meeting_link: string | null;
recording_url: string | null;
price: number;
sale_price: number | null;
is_active: boolean;
consulting_duration_minutes: number | null;
}
const emptyProduct = {
title: '',
slug: '',
type: 'consulting',
description: '',
content: '',
meeting_link: '',
recording_url: '',
price: 0,
sale_price: null as number | null,
is_active: true,
consulting_duration_minutes: 60,
};
export default function AdminProducts() {
const { user, isAdmin, loading: authLoading } = useAuth();
const navigate = useNavigate();
const [products, setProducts] = useState<Product[]>([]);
const [loading, setLoading] = useState(true);
const [dialogOpen, setDialogOpen] = useState(false);
const [editingProduct, setEditingProduct] = useState<Product | null>(null);
const [form, setForm] = useState(emptyProduct);
const [saving, setSaving] = useState(false);
const [activeTab, setActiveTab] = useState('details');
useEffect(() => {
if (!authLoading) {
if (!user) navigate('/auth');
else if (!isAdmin) navigate('/dashboard');
else fetchProducts();
}
}, [user, isAdmin, authLoading]);
const fetchProducts = async () => {
const { data, error } = await supabase.from('products').select('*').order('created_at', { ascending: false });
if (!error && data) setProducts(data);
setLoading(false);
};
const generateSlug = (title: string) => title.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '');
const handleEdit = (product: Product) => {
setEditingProduct(product);
setForm({
title: product.title,
slug: product.slug,
type: product.type,
description: product.description,
content: product.content || '',
meeting_link: product.meeting_link || '',
recording_url: product.recording_url || '',
price: product.price,
sale_price: product.sale_price,
is_active: product.is_active,
consulting_duration_minutes: product.consulting_duration_minutes || 60,
});
setActiveTab('details');
setDialogOpen(true);
};
const handleNew = () => {
setEditingProduct(null);
setForm(emptyProduct);
setActiveTab('details');
setDialogOpen(true);
};
const handleSave = async () => {
if (!form.title || !form.slug || form.price <= 0) {
toast({ title: 'Validasi error', description: 'Lengkapi semua field wajib', variant: 'destructive' });
return;
}
setSaving(true);
const productData = {
title: form.title,
slug: form.slug,
type: form.type,
description: form.description,
content: form.content,
meeting_link: form.meeting_link || null,
recording_url: form.recording_url || null,
price: form.price,
sale_price: form.sale_price || null,
is_active: form.is_active,
consulting_duration_minutes: form.type === 'consulting' ? form.consulting_duration_minutes : null,
};
if (editingProduct) {
const { error } = await supabase.from('products').update(productData).eq('id', editingProduct.id);
if (error) toast({ title: 'Error', description: 'Gagal mengupdate produk', variant: 'destructive' });
else { toast({ title: 'Berhasil', description: 'Produk diupdate' }); setDialogOpen(false); fetchProducts(); }
} else {
const { error } = await supabase.from('products').insert(productData);
if (error) toast({ title: 'Error', description: error.message, variant: 'destructive' });
else { toast({ title: 'Berhasil', description: 'Produk dibuat' }); setDialogOpen(false); fetchProducts(); }
}
setSaving(false);
};
const handleDelete = async (id: string) => {
if (!confirm('Hapus produk ini?')) return;
const { error } = await supabase.from('products').delete().eq('id', id);
if (error) toast({ title: 'Error', description: 'Gagal menghapus produk', variant: 'destructive' });
else { toast({ title: 'Berhasil', description: 'Produk dihapus' }); fetchProducts(); }
};
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">
<div className="flex items-center justify-between mb-8">
<div>
<h1 className="text-4xl font-bold">Manajemen Produk</h1>
<p className="text-muted-foreground">Kelola semua produk</p>
</div>
<Button onClick={handleNew} className="shadow-sm">
<Plus className="w-4 h-4 mr-2" />
Tambah Produk
</Button>
</div>
<Card className="border-2 border-border">
<CardContent className="p-0">
<Table>
<TableHeader>
<TableRow>
<TableHead>Judul</TableHead>
<TableHead>Tipe</TableHead>
<TableHead>Harga</TableHead>
<TableHead>Status</TableHead>
<TableHead className="text-right">Aksi</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{products.map((product) => (
<TableRow key={product.id}>
<TableCell className="font-medium">{product.title}</TableCell>
<TableCell className="capitalize">{product.type}</TableCell>
<TableCell>
{product.sale_price ? (
<span>
<span className="font-bold">{formatIDR(product.sale_price)}</span>
<span className="text-muted-foreground line-through ml-2">{formatIDR(product.price)}</span>
</span>
) : (
<span className="font-bold">{formatIDR(product.price)}</span>
)}
</TableCell>
<TableCell>
<span className={product.is_active ? 'text-foreground' : 'text-muted-foreground'}>
{product.is_active ? 'Aktif' : 'Nonaktif'}
</span>
</TableCell>
<TableCell className="text-right">
<Button variant="ghost" size="sm" onClick={() => handleEdit(product)}>
<Pencil className="w-4 h-4" />
</Button>
<Button variant="ghost" size="sm" onClick={() => handleDelete(product.id)}>
<Trash2 className="w-4 h-4" />
</Button>
</TableCell>
</TableRow>
))}
{products.length === 0 && (
<TableRow>
<TableCell colSpan={5} className="text-center py-8 text-muted-foreground">
Belum ada produk
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</CardContent>
</Card>
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
<DialogContent className="max-w-3xl max-h-[90vh] overflow-y-auto border-2 border-border">
<DialogHeader>
<DialogTitle>{editingProduct ? 'Edit Produk' : 'Produk Baru'}</DialogTitle>
</DialogHeader>
<Tabs value={activeTab} onValueChange={setActiveTab} className="mt-4">
<TabsList className="border-2 border-border">
<TabsTrigger value="details">Detail</TabsTrigger>
{editingProduct && form.type === 'bootcamp' && <TabsTrigger value="curriculum">Kurikulum</TabsTrigger>}
</TabsList>
<TabsContent value="details" className="space-y-4 py-4">
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label>Judul *</Label>
<Input value={form.title} onChange={(e) => setForm({ ...form, title: e.target.value, slug: generateSlug(e.target.value) })} className="border-2" />
</div>
<div className="space-y-2">
<Label>Slug *</Label>
<Input value={form.slug} onChange={(e) => setForm({ ...form, slug: e.target.value })} className="border-2" />
</div>
</div>
<div className="space-y-2">
<Label>Tipe</Label>
<Select value={form.type} onValueChange={(v) => setForm({ ...form, type: v })}>
<SelectTrigger className="border-2"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="consulting">Consulting</SelectItem>
<SelectItem value="webinar">Webinar</SelectItem>
<SelectItem value="bootcamp">Bootcamp</SelectItem>
</SelectContent>
</Select>
</div>
{form.type === 'consulting' && (
<div className="space-y-2">
<Label>Durasi Konsultasi (menit)</Label>
<Input
type="number"
value={form.consulting_duration_minutes || 60}
onChange={(e) => setForm({ ...form, consulting_duration_minutes: parseInt(e.target.value) || 60 })}
className="border-2"
/>
</div>
)}
<div className="space-y-2">
<Label>Deskripsi</Label>
<RichTextEditor content={form.description} onChange={(v) => setForm({ ...form, description: v })} />
</div>
<div className="space-y-2">
<Label>Konten</Label>
<RichTextEditor content={form.content} onChange={(v) => setForm({ ...form, content: v })} />
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label>Meeting Link</Label>
<Input value={form.meeting_link} onChange={(e) => setForm({ ...form, meeting_link: e.target.value })} placeholder="https://meet.google.com/..." className="border-2" />
</div>
<div className="space-y-2">
<Label>Recording URL</Label>
<Input value={form.recording_url} onChange={(e) => setForm({ ...form, recording_url: e.target.value })} placeholder="https://youtube.com/..." className="border-2" />
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label>Harga *</Label>
<Input type="number" value={form.price} onChange={(e) => setForm({ ...form, price: parseFloat(e.target.value) || 0 })} className="border-2" />
</div>
<div className="space-y-2">
<Label>Harga Promo</Label>
<Input type="number" value={form.sale_price || ''} onChange={(e) => setForm({ ...form, sale_price: e.target.value ? parseFloat(e.target.value) : null })} placeholder="Kosongkan jika tidak promo" className="border-2" />
</div>
</div>
<div className="flex items-center gap-2">
<Switch checked={form.is_active} onCheckedChange={(checked) => setForm({ ...form, is_active: checked })} />
<Label>Aktif</Label>
</div>
<Button onClick={handleSave} className="w-full shadow-sm" disabled={saving}>
{saving ? 'Menyimpan...' : 'Simpan Produk'}
</Button>
</TabsContent>
{editingProduct && form.type === 'bootcamp' && (
<TabsContent value="curriculum" className="py-4">
<CurriculumEditor productId={editingProduct.id} />
</TabsContent>
)}
</Tabs>
</DialogContent>
</Dialog>
</div>
</AppLayout>
);
}

View File

@@ -0,0 +1,193 @@
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, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Skeleton } from '@/components/ui/skeleton';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { toast } from '@/hooks/use-toast';
import { Plus, Pencil, Trash2, Clock } from 'lucide-react';
interface Workhour {
id: string;
weekday: number;
start_time: string;
end_time: string;
}
const WEEKDAYS = ['Minggu', 'Senin', 'Selasa', 'Rabu', 'Kamis', 'Jumat', 'Sabtu'];
const emptyWorkhour = { weekday: 1, start_time: '09:00', end_time: '17:00' };
export default function AdminSettings() {
const { user, isAdmin, loading: authLoading } = useAuth();
const navigate = useNavigate();
const [loading, setLoading] = useState(true);
const [workhours, setWorkhours] = useState<Workhour[]>([]);
const [dialogOpen, setDialogOpen] = useState(false);
const [editingWorkhour, setEditingWorkhour] = useState<Workhour | null>(null);
const [form, setForm] = useState(emptyWorkhour);
useEffect(() => {
if (!authLoading) {
if (!user) navigate('/auth');
else if (!isAdmin) navigate('/dashboard');
else fetchWorkhours();
}
}, [user, isAdmin, authLoading]);
const fetchWorkhours = async () => {
const { data, error } = await supabase.from('workhours').select('*').order('weekday');
if (!error && data) setWorkhours(data);
setLoading(false);
};
const handleNew = () => {
setEditingWorkhour(null);
setForm(emptyWorkhour);
setDialogOpen(true);
};
const handleEdit = (wh: Workhour) => {
setEditingWorkhour(wh);
setForm({ weekday: wh.weekday, start_time: wh.start_time, end_time: wh.end_time });
setDialogOpen(true);
};
const handleSave = async () => {
const workhourData = {
weekday: form.weekday,
start_time: form.start_time,
end_time: form.end_time,
};
if (editingWorkhour) {
const { error } = await supabase.from('workhours').update(workhourData).eq('id', editingWorkhour.id);
if (error) toast({ title: 'Error', description: 'Gagal mengupdate', variant: 'destructive' });
else { toast({ title: 'Berhasil', description: 'Jam kerja diupdate' }); setDialogOpen(false); fetchWorkhours(); }
} else {
const { error } = await supabase.from('workhours').insert(workhourData);
if (error) toast({ title: 'Error', description: error.message, variant: 'destructive' });
else { toast({ title: 'Berhasil', description: 'Jam kerja ditambahkan' }); setDialogOpen(false); fetchWorkhours(); }
}
};
const handleDelete = async (id: string) => {
if (!confirm('Hapus jam kerja ini?')) return;
const { error } = await supabase.from('workhours').delete().eq('id', id);
if (error) toast({ title: 'Error', description: 'Gagal menghapus', variant: 'destructive' });
else { toast({ title: 'Berhasil', description: 'Jam kerja dihapus' }); fetchWorkhours(); }
};
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">Pengaturan</h1>
<p className="text-muted-foreground mb-8">Konfigurasi platform</p>
<div className="space-y-8">
<Card className="border-2 border-border">
<CardHeader className="flex flex-row items-center justify-between">
<div>
<CardTitle className="flex items-center gap-2">
<Clock className="w-5 h-5" />
Jam Kerja
</CardTitle>
<CardDescription>Atur jadwal ketersediaan untuk konsultasi</CardDescription>
</div>
<Button onClick={handleNew} className="shadow-sm">
<Plus className="w-4 h-4 mr-2" />
Tambah Jam Kerja
</Button>
</CardHeader>
<CardContent className="p-0">
<Table>
<TableHeader>
<TableRow>
<TableHead>Hari</TableHead>
<TableHead>Mulai</TableHead>
<TableHead>Selesai</TableHead>
<TableHead className="text-right">Aksi</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{workhours.map((wh) => (
<TableRow key={wh.id}>
<TableCell className="font-medium">{WEEKDAYS[wh.weekday]}</TableCell>
<TableCell>{wh.start_time}</TableCell>
<TableCell>{wh.end_time}</TableCell>
<TableCell className="text-right">
<Button variant="ghost" size="sm" onClick={() => handleEdit(wh)}>
<Pencil className="w-4 h-4" />
</Button>
<Button variant="ghost" size="sm" onClick={() => handleDelete(wh.id)}>
<Trash2 className="w-4 h-4" />
</Button>
</TableCell>
</TableRow>
))}
{workhours.length === 0 && (
<TableRow>
<TableCell colSpan={4} className="text-center py-8 text-muted-foreground">
Belum ada jam kerja
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</CardContent>
</Card>
</div>
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
<DialogContent className="max-w-md border-2 border-border">
<DialogHeader>
<DialogTitle>{editingWorkhour ? 'Edit Jam Kerja' : 'Tambah Jam Kerja'}</DialogTitle>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<Label>Hari</Label>
<Select value={form.weekday.toString()} onValueChange={(v) => setForm({ ...form, weekday: parseInt(v) })}>
<SelectTrigger className="border-2"><SelectValue /></SelectTrigger>
<SelectContent>
{WEEKDAYS.map((day, i) => (
<SelectItem key={i} value={i.toString()}>{day}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label>Mulai</Label>
<Input type="time" value={form.start_time} onChange={(e) => setForm({ ...form, start_time: e.target.value })} className="border-2" />
</div>
<div className="space-y-2">
<Label>Selesai</Label>
<Input type="time" value={form.end_time} onChange={(e) => setForm({ ...form, end_time: e.target.value })} className="border-2" />
</div>
</div>
<Button onClick={handleSave} className="w-full shadow-sm">Simpan</Button>
</div>
</DialogContent>
</Dialog>
</div>
</AppLayout>
);
}