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.
351 lines
16 KiB
TypeScript
351 lines
16 KiB
TypeScript
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;
|
|
}
|
|
|
|
const emptyProduct = {
|
|
title: '',
|
|
slug: '',
|
|
type: 'webinar',
|
|
description: '',
|
|
content: '',
|
|
meeting_link: '',
|
|
recording_url: '',
|
|
price: 0,
|
|
sale_price: null as number | null,
|
|
is_active: true,
|
|
};
|
|
|
|
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,
|
|
});
|
|
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,
|
|
};
|
|
|
|
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">
|
|
{/* Desktop Table */}
|
|
<div className="hidden md:block overflow-x-auto">
|
|
<Table>
|
|
<TableHeader>
|
|
<TableRow>
|
|
<TableHead className="whitespace-nowrap">Judul</TableHead>
|
|
<TableHead className="whitespace-nowrap">Tipe</TableHead>
|
|
<TableHead className="whitespace-nowrap">Harga</TableHead>
|
|
<TableHead className="whitespace-nowrap">Status</TableHead>
|
|
<TableHead className="text-right whitespace-nowrap">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>
|
|
</div>
|
|
|
|
{/* Mobile Card Layout */}
|
|
<div className="md:hidden space-y-3 p-4">
|
|
{products.map((product) => (
|
|
<Card key={product.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">{product.title}</h3>
|
|
<p className="text-sm text-muted-foreground capitalize">{product.type}</p>
|
|
</div>
|
|
<div className="flex gap-1 shrink-0">
|
|
<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>
|
|
</div>
|
|
</div>
|
|
<div className="space-y-1">
|
|
<div className="flex items-center justify-between">
|
|
<span className="text-sm text-muted-foreground">Harga:</span>
|
|
<div className="text-right">
|
|
{product.sale_price ? (
|
|
<div className="flex items-center gap-2">
|
|
<span className="font-bold">{formatIDR(product.sale_price)}</span>
|
|
<span className="text-sm text-muted-foreground line-through">{formatIDR(product.price)}</span>
|
|
</div>
|
|
) : (
|
|
<span className="font-bold">{formatIDR(product.price)}</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
<div className="flex items-center justify-between">
|
|
<span className="text-sm text-muted-foreground">Status:</span>
|
|
<span className={product.is_active ? 'text-foreground' : 'text-muted-foreground'}>
|
|
{product.is_active ? 'Aktif' : 'Nonaktif'}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
))}
|
|
{products.length === 0 && (
|
|
<div className="text-center py-8 text-muted-foreground">
|
|
Belum ada produk
|
|
</div>
|
|
)}
|
|
</div>
|
|
</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-1 md: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="webinar">Webinar</SelectItem>
|
|
<SelectItem value="bootcamp">Bootcamp</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</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-1 md: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>
|
|
);
|
|
}
|