Replace dropdown filters with tab buttons across admin pages
Update all admin pages to use tab button filters instead of dropdown selects, following the pattern from MemberAccess.tsx: Changes: - AdminProducts: Tab buttons for product type (only bootcamp/webinar shown) and status (active/inactive) - AdminConsulting: Added status filter with tab buttons (pending payment, confirmed, completed, cancelled) - AdminOrders: Tab buttons for status filter (all, paid, pending, refunded) - AdminMembers: Tab buttons for role filter (all, admin, member) - AdminReviews: Tab buttons for both type (all, consulting, bootcamp, webinar, general) and status (all, pending, approved) Features added: - Clear button (X) on search input when text is present - Reset button appears when any filter is active - Consistent styling with shadow-sm for active tabs and border-2 for outline tabs - All filters in vertical stack layout for better mobile responsiveness - Active state visually distinct with default variant and shadow 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -14,7 +14,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@
|
|||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||||
import { toast } from '@/hooks/use-toast';
|
import { toast } from '@/hooks/use-toast';
|
||||||
import { formatIDR } from '@/lib/format';
|
import { formatIDR } from '@/lib/format';
|
||||||
import { Video, Calendar, Clock, User, Link as LinkIcon, ExternalLink, CheckCircle, XCircle, Loader2, Search } from 'lucide-react';
|
import { Video, Calendar, Clock, User, Link as LinkIcon, ExternalLink, CheckCircle, XCircle, Loader2, Search, X } from 'lucide-react';
|
||||||
import { format, parseISO, isToday, isTomorrow, isPast } from 'date-fns';
|
import { format, parseISO, isToday, isTomorrow, isPast } from 'date-fns';
|
||||||
import { id } from 'date-fns/locale';
|
import { id } from 'date-fns/locale';
|
||||||
|
|
||||||
@@ -73,6 +73,7 @@ export default function AdminConsulting() {
|
|||||||
const [creatingMeet, setCreatingMeet] = useState(false);
|
const [creatingMeet, setCreatingMeet] = useState(false);
|
||||||
const [activeTab, setActiveTab] = useState('upcoming');
|
const [activeTab, setActiveTab] = useState('upcoming');
|
||||||
const [searchQuery, setSearchQuery] = useState('');
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
|
const [filterStatus, setFilterStatus] = useState<string>('all');
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!authLoading) {
|
if (!authLoading) {
|
||||||
@@ -283,18 +284,32 @@ export default function AdminConsulting() {
|
|||||||
})).sort((a, b) => new Date(a.firstDate).getTime() - new Date(b.firstDate).getTime());
|
})).sort((a, b) => new Date(a.firstDate).getTime() - new Date(b.firstDate).getTime());
|
||||||
})();
|
})();
|
||||||
|
|
||||||
// Filter orders based on search query
|
// Filter orders based on search query and status
|
||||||
const filteredGroupedOrders = groupedOrders.filter(order => {
|
const filteredGroupedOrders = groupedOrders.filter(order => {
|
||||||
if (!searchQuery) return true;
|
// Search filter
|
||||||
|
if (searchQuery) {
|
||||||
const query = searchQuery.toLowerCase();
|
const query = searchQuery.toLowerCase();
|
||||||
const firstSlot = order.slots[0];
|
const firstSlot = order.slots[0];
|
||||||
|
|
||||||
return (
|
const matchesSearch =
|
||||||
order.profile?.name?.toLowerCase().includes(query) ||
|
order.profile?.name?.toLowerCase().includes(query) ||
|
||||||
order.profile?.email?.toLowerCase().includes(query) ||
|
order.profile?.email?.toLowerCase().includes(query) ||
|
||||||
firstSlot.topic_category?.toLowerCase().includes(query) ||
|
firstSlot.topic_category?.toLowerCase().includes(query) ||
|
||||||
order.orderId?.toLowerCase().includes(query)
|
order.orderId?.toLowerCase().includes(query);
|
||||||
);
|
|
||||||
|
if (!matchesSearch) return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Status filter
|
||||||
|
if (filterStatus !== 'all') {
|
||||||
|
const firstSlot = order.slots[0];
|
||||||
|
if (filterStatus === 'confirmed' && firstSlot.status !== 'confirmed') return false;
|
||||||
|
if (filterStatus === 'pending_payment' && firstSlot.status !== 'pending_payment') return false;
|
||||||
|
if (filterStatus === 'completed' && firstSlot.status !== 'completed') return false;
|
||||||
|
if (filterStatus === 'cancelled' && firstSlot.status !== 'cancelled') return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
});
|
});
|
||||||
|
|
||||||
const today = new Date().toISOString().split('T')[0];
|
const today = new Date().toISOString().split('T')[0];
|
||||||
@@ -378,6 +393,8 @@ export default function AdminConsulting() {
|
|||||||
{/* Search */}
|
{/* Search */}
|
||||||
<Card className="border-2 border-border mb-6">
|
<Card className="border-2 border-border mb-6">
|
||||||
<CardContent className="pt-6">
|
<CardContent className="pt-6">
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Search */}
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
|
||||||
<Input
|
<Input
|
||||||
@@ -386,9 +403,76 @@ export default function AdminConsulting() {
|
|||||||
onChange={(e) => setSearchQuery(e.target.value)}
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
className="pl-10 border-2"
|
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>
|
||||||
<div className="mt-2 text-sm text-muted-foreground">
|
|
||||||
|
{/* Status Filter */}
|
||||||
|
<div className="flex flex-wrap gap-2 items-center">
|
||||||
|
<span className="text-sm font-medium text-muted-foreground">Status:</span>
|
||||||
|
<Button
|
||||||
|
variant={filterStatus === 'all' ? 'default' : 'outline'}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setFilterStatus('all')}
|
||||||
|
className={filterStatus === 'all' ? 'shadow-sm' : 'border-2'}
|
||||||
|
>
|
||||||
|
Semua
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant={filterStatus === 'pending_payment' ? 'default' : 'outline'}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setFilterStatus('pending_payment')}
|
||||||
|
className={filterStatus === 'pending_payment' ? 'shadow-sm' : 'border-2'}
|
||||||
|
>
|
||||||
|
Menunggu Pembayaran
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant={filterStatus === 'confirmed' ? 'default' : 'outline'}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setFilterStatus('confirmed')}
|
||||||
|
className={filterStatus === 'confirmed' ? 'shadow-sm' : 'border-2'}
|
||||||
|
>
|
||||||
|
Dikonfirmasi
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant={filterStatus === 'completed' ? 'default' : 'outline'}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setFilterStatus('completed')}
|
||||||
|
className={filterStatus === 'completed' ? 'shadow-sm' : 'border-2'}
|
||||||
|
>
|
||||||
|
Selesai
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant={filterStatus === 'cancelled' ? 'default' : 'outline'}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setFilterStatus('cancelled')}
|
||||||
|
className={filterStatus === 'cancelled' ? 'shadow-sm' : 'border-2'}
|
||||||
|
>
|
||||||
|
Dibatalkan
|
||||||
|
</Button>
|
||||||
|
{(searchQuery || filterStatus !== 'all') && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => { setSearchQuery(''); setFilterStatus('all'); }}
|
||||||
|
className="text-muted-foreground hover:text-destructive"
|
||||||
|
>
|
||||||
|
<X className="w-4 h-4 mr-1" />
|
||||||
|
Reset
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Result count */}
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
Menampilkan {filteredGroupedOrders.length} dari {groupedOrders.length} jadwal konsultasi
|
Menampilkan {filteredGroupedOrders.length} dari {groupedOrders.length} jadwal konsultasi
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -10,9 +10,8 @@ import { Button } from "@/components/ui/button";
|
|||||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||||
import { Skeleton } from "@/components/ui/skeleton";
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
|
||||||
import { formatDateTime } from "@/lib/format";
|
import { formatDateTime } from "@/lib/format";
|
||||||
import { Eye, Shield, ShieldOff, Search } from "lucide-react";
|
import { Eye, Shield, ShieldOff, Search, X } from "lucide-react";
|
||||||
import { toast } from "@/hooks/use-toast";
|
import { toast } from "@/hooks/use-toast";
|
||||||
|
|
||||||
interface Member {
|
interface Member {
|
||||||
@@ -78,6 +77,11 @@ export default function AdminMembers() {
|
|||||||
return matchesSearch && matchesRole;
|
return matchesSearch && matchesRole;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const clearFilters = () => {
|
||||||
|
setSearchQuery('');
|
||||||
|
setFilterRole('all');
|
||||||
|
};
|
||||||
|
|
||||||
const viewMemberDetails = async (member: Member) => {
|
const viewMemberDetails = async (member: Member) => {
|
||||||
setSelectedMember(member);
|
setSelectedMember(member);
|
||||||
const { data } = await supabase.from("user_access").select("*, product:products(title)").eq("user_id", member.id);
|
const { data } = await supabase.from("user_access").select("*, product:products(title)").eq("user_id", member.id);
|
||||||
@@ -123,7 +127,7 @@ export default function AdminMembers() {
|
|||||||
{/* Search & Filter */}
|
{/* Search & Filter */}
|
||||||
<Card className="border-2 border-border mb-6">
|
<Card className="border-2 border-border mb-6">
|
||||||
<CardContent className="pt-6">
|
<CardContent className="pt-6">
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<div className="space-y-4">
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
|
||||||
<Input
|
<Input
|
||||||
@@ -132,20 +136,58 @@ export default function AdminMembers() {
|
|||||||
onChange={(e) => setSearchQuery(e.target.value)}
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
className="pl-10 border-2"
|
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>
|
||||||
<Select value={filterRole} onValueChange={setFilterRole}>
|
|
||||||
<SelectTrigger className="border-2">
|
<div className="flex flex-wrap gap-2 items-center">
|
||||||
<SelectValue placeholder="Filter role" />
|
<span className="text-sm font-medium text-muted-foreground">Role:</span>
|
||||||
</SelectTrigger>
|
<Button
|
||||||
<SelectContent>
|
variant={filterRole === 'all' ? 'default' : 'outline'}
|
||||||
<SelectItem value="all">Semua Role</SelectItem>
|
size="sm"
|
||||||
<SelectItem value="admin">Admin</SelectItem>
|
onClick={() => setFilterRole('all')}
|
||||||
<SelectItem value="member">Member</SelectItem>
|
className={filterRole === 'all' ? 'shadow-sm' : 'border-2'}
|
||||||
</SelectContent>
|
>
|
||||||
</Select>
|
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>
|
</div>
|
||||||
<div className="mt-2 text-sm text-muted-foreground">
|
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
Menampilkan {filteredMembers.length} dari {members.length} member
|
Menampilkan {filteredMembers.length} dari {members.length} member
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -13,9 +13,8 @@ import { Input } from "@/components/ui/input";
|
|||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
import { formatIDR, formatDateTime } from "@/lib/format";
|
import { formatIDR, formatDateTime } from "@/lib/format";
|
||||||
import { Eye, CheckCircle, XCircle, Video, ExternalLink, Trash2, AlertTriangle, RefreshCw, Link as LinkIcon, Download, Search } from "lucide-react";
|
import { Eye, CheckCircle, XCircle, Video, ExternalLink, Trash2, AlertTriangle, RefreshCw, Link as LinkIcon, Download, Search, X } from "lucide-react";
|
||||||
import { toast } from "@/hooks/use-toast";
|
import { toast } from "@/hooks/use-toast";
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
|
||||||
import { getPaymentStatusLabel, getPaymentStatusColor, canRefundOrder, canCancelOrder, canMarkAsPaid } from "@/lib/statusHelpers";
|
import { getPaymentStatusLabel, getPaymentStatusColor, canRefundOrder, canCancelOrder, canMarkAsPaid } from "@/lib/statusHelpers";
|
||||||
import { convertToCSV, downloadCSV, formatExportDate, formatExportIDR } from "@/lib/exportCSV";
|
import { convertToCSV, downloadCSV, formatExportDate, formatExportIDR } from "@/lib/exportCSV";
|
||||||
|
|
||||||
@@ -104,6 +103,11 @@ export default function AdminOrders() {
|
|||||||
return matchesSearch && matchesStatus;
|
return matchesSearch && matchesStatus;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const clearFilters = () => {
|
||||||
|
setSearchQuery('');
|
||||||
|
setFilterStatus('all');
|
||||||
|
};
|
||||||
|
|
||||||
const viewOrderDetails = async (order: Order) => {
|
const viewOrderDetails = async (order: Order) => {
|
||||||
setSelectedOrder(order);
|
setSelectedOrder(order);
|
||||||
const { data: itemsData } = await supabase.from("order_items").select("*, product:products(title, type)").eq("order_id", order.id);
|
const { data: itemsData } = await supabase.from("order_items").select("*, product:products(title, type)").eq("order_id", order.id);
|
||||||
@@ -395,7 +399,7 @@ export default function AdminOrders() {
|
|||||||
{/* Search & Filter */}
|
{/* Search & Filter */}
|
||||||
<Card className="border-2 border-border mb-6">
|
<Card className="border-2 border-border mb-6">
|
||||||
<CardContent className="pt-6">
|
<CardContent className="pt-6">
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<div className="space-y-4">
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
|
||||||
<Input
|
<Input
|
||||||
@@ -404,21 +408,66 @@ export default function AdminOrders() {
|
|||||||
onChange={(e) => setSearchQuery(e.target.value)}
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
className="pl-10 border-2"
|
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>
|
||||||
<Select value={filterStatus} onValueChange={setFilterStatus}>
|
|
||||||
<SelectTrigger className="border-2">
|
<div className="flex flex-wrap gap-2 items-center">
|
||||||
<SelectValue placeholder="Filter status" />
|
<span className="text-sm font-medium text-muted-foreground">Status:</span>
|
||||||
</SelectTrigger>
|
<Button
|
||||||
<SelectContent>
|
variant={filterStatus === 'all' ? 'default' : 'outline'}
|
||||||
<SelectItem value="all">Semua Status</SelectItem>
|
size="sm"
|
||||||
<SelectItem value="paid">Lunas</SelectItem>
|
onClick={() => setFilterStatus('all')}
|
||||||
<SelectItem value="pending">Pending</SelectItem>
|
className={filterStatus === 'all' ? 'shadow-sm' : 'border-2'}
|
||||||
<SelectItem value="refunded">Refunded</SelectItem>
|
>
|
||||||
</SelectContent>
|
Semua
|
||||||
</Select>
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant={filterStatus === 'paid' ? 'default' : 'outline'}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setFilterStatus('paid')}
|
||||||
|
className={filterStatus === 'paid' ? 'shadow-sm' : 'border-2'}
|
||||||
|
>
|
||||||
|
Lunas
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant={filterStatus === 'pending' ? 'default' : 'outline'}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setFilterStatus('pending')}
|
||||||
|
className={filterStatus === 'pending' ? 'shadow-sm' : 'border-2'}
|
||||||
|
>
|
||||||
|
Pending
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant={filterStatus === 'refunded' ? 'default' : 'outline'}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setFilterStatus('refunded')}
|
||||||
|
className={filterStatus === 'refunded' ? 'shadow-sm' : 'border-2'}
|
||||||
|
>
|
||||||
|
Refunded
|
||||||
|
</Button>
|
||||||
|
{(searchQuery || filterStatus !== '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>
|
</div>
|
||||||
<div className="mt-2 text-sm text-muted-foreground">
|
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
Menampilkan {filteredOrders.length} dari {orders.length} order
|
Menampilkan {filteredOrders.length} dari {orders.length} order
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@
|
|||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||||
import { toast } from '@/hooks/use-toast';
|
import { toast } from '@/hooks/use-toast';
|
||||||
import { Skeleton } from '@/components/ui/skeleton';
|
import { Skeleton } from '@/components/ui/skeleton';
|
||||||
import { Plus, Pencil, Trash2, Search } from 'lucide-react';
|
import { Plus, Pencil, Trash2, Search, X } from 'lucide-react';
|
||||||
import { CurriculumEditor } from '@/components/admin/CurriculumEditor';
|
import { CurriculumEditor } from '@/components/admin/CurriculumEditor';
|
||||||
import { RichTextEditor } from '@/components/RichTextEditor';
|
import { RichTextEditor } from '@/components/RichTextEditor';
|
||||||
import { formatIDR } from '@/lib/format';
|
import { formatIDR } from '@/lib/format';
|
||||||
@@ -90,6 +90,15 @@ export default function AdminProducts() {
|
|||||||
return matchesSearch && matchesType && matchesStatus;
|
return matchesSearch && matchesType && matchesStatus;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Get unique product types from actual products
|
||||||
|
const productTypes = ['all', ...Array.from(new Set(products.map(p => p.type)))];
|
||||||
|
|
||||||
|
const clearFilters = () => {
|
||||||
|
setSearchQuery('');
|
||||||
|
setFilterType('all');
|
||||||
|
setFilterStatus('all');
|
||||||
|
};
|
||||||
|
|
||||||
const generateSlug = (title: string) => title.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '');
|
const generateSlug = (title: string) => title.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '');
|
||||||
|
|
||||||
const handleEdit = (product: Product) => {
|
const handleEdit = (product: Product) => {
|
||||||
@@ -187,9 +196,8 @@ export default function AdminProducts() {
|
|||||||
{/* Search and Filter */}
|
{/* Search and Filter */}
|
||||||
<Card className="border-2 border-border mb-6">
|
<Card className="border-2 border-border mb-6">
|
||||||
<CardContent className="pt-6">
|
<CardContent className="pt-6">
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
<div className="space-y-4">
|
||||||
{/* Search */}
|
{/* Search */}
|
||||||
<div className="md:col-span-2">
|
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
|
||||||
<Input
|
<Input
|
||||||
@@ -198,40 +206,76 @@ export default function AdminProducts() {
|
|||||||
onChange={(e) => setSearchQuery(e.target.value)}
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
className="pl-10 border-2"
|
className="pl-10 border-2"
|
||||||
/>
|
/>
|
||||||
</div>
|
{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>
|
||||||
|
|
||||||
{/* Type Filter */}
|
{/* Type Filter */}
|
||||||
<Select value={filterType} onValueChange={setFilterType}>
|
<div className="flex flex-wrap gap-2 items-center">
|
||||||
<SelectTrigger className="border-2">
|
<span className="text-sm font-medium text-muted-foreground">Tipe:</span>
|
||||||
<SelectValue placeholder="Filter tipe" />
|
{productTypes.map((type) => (
|
||||||
</SelectTrigger>
|
<Button
|
||||||
<SelectContent>
|
key={type}
|
||||||
<SelectItem value="all">Semua Tipe</SelectItem>
|
variant={filterType === type ? 'default' : 'outline'}
|
||||||
<SelectItem value="webinar">Webinar</SelectItem>
|
size="sm"
|
||||||
<SelectItem value="course">Course</SelectItem>
|
onClick={() => setFilterType(type)}
|
||||||
<SelectItem value="consulting">Consulting</SelectItem>
|
className={filterType === type ? 'shadow-sm' : 'border-2'}
|
||||||
<SelectItem value="ebook">E-book</SelectItem>
|
>
|
||||||
<SelectItem value="bootcamp">Bootcamp</SelectItem>
|
{type === 'all' ? 'Semua' : type === 'webinar' ? 'Webinar' : type === 'bootcamp' ? 'Bootcamp' : type}
|
||||||
</SelectContent>
|
</Button>
|
||||||
</Select>
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Status Filter */}
|
{/* Status Filter */}
|
||||||
<Select value={filterStatus} onValueChange={setFilterStatus}>
|
<div className="flex flex-wrap gap-2 items-center">
|
||||||
<SelectTrigger className="border-2">
|
<span className="text-sm font-medium text-muted-foreground">Status:</span>
|
||||||
<SelectValue placeholder="Filter status" />
|
<Button
|
||||||
</SelectTrigger>
|
variant={filterStatus === 'all' ? 'default' : 'outline'}
|
||||||
<SelectContent>
|
size="sm"
|
||||||
<SelectItem value="all">Semua Status</SelectItem>
|
onClick={() => setFilterStatus('all')}
|
||||||
<SelectItem value="active">Aktif</SelectItem>
|
className={filterStatus === 'all' ? 'shadow-sm' : 'border-2'}
|
||||||
<SelectItem value="inactive">Nonaktif</SelectItem>
|
>
|
||||||
</SelectContent>
|
Semua
|
||||||
</Select>
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant={filterStatus === 'active' ? 'default' : 'outline'}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setFilterStatus('active')}
|
||||||
|
className={filterStatus === 'active' ? 'shadow-sm' : 'border-2'}
|
||||||
|
>
|
||||||
|
Aktif
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant={filterStatus === 'inactive' ? 'default' : 'outline'}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setFilterStatus('inactive')}
|
||||||
|
className={filterStatus === 'inactive' ? 'shadow-sm' : 'border-2'}
|
||||||
|
>
|
||||||
|
Nonaktif
|
||||||
|
</Button>
|
||||||
|
{(searchQuery || filterType !== 'all' || filterStatus !== '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>
|
</div>
|
||||||
|
|
||||||
{/* Result count */}
|
{/* Result count */}
|
||||||
<div className="mt-4 text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
Menampilkan {filteredProducts.length} dari {products.length} produk
|
Menampilkan {filteredProducts.length} dari {products.length} produk
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -6,12 +6,11 @@ import { Button } from "@/components/ui/button";
|
|||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
|
||||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
import { Skeleton } from "@/components/ui/skeleton";
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
import { toast } from "@/hooks/use-toast";
|
import { toast } from "@/hooks/use-toast";
|
||||||
import { Star, Check, X, Edit, Trash2, Search } from "lucide-react";
|
import { Star, Check, X, Edit, Trash2, Search, X as XIcon } from "lucide-react";
|
||||||
|
|
||||||
interface Review {
|
interface Review {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -125,6 +124,10 @@ export default function AdminReviews() {
|
|||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const clearFilters = () => {
|
||||||
|
setFilter({ ...filter, type: 'all', status: 'all', search: '' });
|
||||||
|
};
|
||||||
|
|
||||||
const pendingReviews = reviews.filter((r) => !r.is_approved);
|
const pendingReviews = reviews.filter((r) => !r.is_approved);
|
||||||
|
|
||||||
const renderStars = (rating: number) => (
|
const renderStars = (rating: number) => (
|
||||||
@@ -174,8 +177,8 @@ export default function AdminReviews() {
|
|||||||
|
|
||||||
<Card className="border-2 border-border">
|
<Card className="border-2 border-border">
|
||||||
<CardContent className="pt-6">
|
<CardContent className="pt-6">
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
<div className="space-y-4">
|
||||||
<div className="relative md:col-span-1">
|
<div className="relative">
|
||||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
|
||||||
<Input
|
<Input
|
||||||
placeholder="Cari ulasan..."
|
placeholder="Cari ulasan..."
|
||||||
@@ -183,33 +186,102 @@ export default function AdminReviews() {
|
|||||||
onChange={(e) => setFilter({ ...filter, search: e.target.value })}
|
onChange={(e) => setFilter({ ...filter, search: e.target.value })}
|
||||||
className="pl-10 border-2"
|
className="pl-10 border-2"
|
||||||
/>
|
/>
|
||||||
|
{filter.search && (
|
||||||
|
<button
|
||||||
|
onClick={() => setFilter({ ...filter, search: '' })}
|
||||||
|
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
||||||
|
>
|
||||||
|
<XIcon className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<Select value={filter.type} onValueChange={(v) => setFilter({ ...filter, type: v })}>
|
|
||||||
<SelectTrigger className="border-2">
|
|
||||||
<SelectValue placeholder="Tipe" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="all">Semua Tipe</SelectItem>
|
|
||||||
<SelectItem value="consulting">Konsultasi</SelectItem>
|
|
||||||
<SelectItem value="bootcamp">Bootcamp</SelectItem>
|
|
||||||
<SelectItem value="webinar">Webinar</SelectItem>
|
|
||||||
<SelectItem value="general">Umum</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
|
|
||||||
<Select value={filter.status} onValueChange={(v) => setFilter({ ...filter, status: v })}>
|
<div className="flex flex-wrap gap-2 items-center">
|
||||||
<SelectTrigger className="border-2">
|
<span className="text-sm font-medium text-muted-foreground">Tipe:</span>
|
||||||
<SelectValue placeholder="Status" />
|
<Button
|
||||||
</SelectTrigger>
|
variant={filter.type === 'all' ? 'default' : 'outline'}
|
||||||
<SelectContent>
|
size="sm"
|
||||||
<SelectItem value="all">Semua Status</SelectItem>
|
onClick={() => setFilter({ ...filter, type: 'all' })}
|
||||||
<SelectItem value="pending">Menunggu</SelectItem>
|
className={filter.type === 'all' ? 'shadow-sm' : 'border-2'}
|
||||||
<SelectItem value="approved">Disetujui</SelectItem>
|
>
|
||||||
</SelectContent>
|
Semua
|
||||||
</Select>
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant={filter.type === 'consulting' ? 'default' : 'outline'}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setFilter({ ...filter, type: 'consulting' })}
|
||||||
|
className={filter.type === 'consulting' ? 'shadow-sm' : 'border-2'}
|
||||||
|
>
|
||||||
|
Konsultasi
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant={filter.type === 'bootcamp' ? 'default' : 'outline'}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setFilter({ ...filter, type: 'bootcamp' })}
|
||||||
|
className={filter.type === 'bootcamp' ? 'shadow-sm' : 'border-2'}
|
||||||
|
>
|
||||||
|
Bootcamp
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant={filter.type === 'webinar' ? 'default' : 'outline'}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setFilter({ ...filter, type: 'webinar' })}
|
||||||
|
className={filter.type === 'webinar' ? 'shadow-sm' : 'border-2'}
|
||||||
|
>
|
||||||
|
Webinar
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant={filter.type === 'general' ? 'default' : 'outline'}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setFilter({ ...filter, type: 'general' })}
|
||||||
|
className={filter.type === 'general' ? 'shadow-sm' : 'border-2'}
|
||||||
|
>
|
||||||
|
Umum
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-2 text-sm text-muted-foreground">
|
|
||||||
|
<div className="flex flex-wrap gap-2 items-center">
|
||||||
|
<span className="text-sm font-medium text-muted-foreground">Status:</span>
|
||||||
|
<Button
|
||||||
|
variant={filter.status === 'all' ? 'default' : 'outline'}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setFilter({ ...filter, status: 'all' })}
|
||||||
|
className={filter.status === 'all' ? 'shadow-sm' : 'border-2'}
|
||||||
|
>
|
||||||
|
Semua
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant={filter.status === 'pending' ? 'default' : 'outline'}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setFilter({ ...filter, status: 'pending' })}
|
||||||
|
className={filter.status === 'pending' ? 'shadow-sm' : 'border-2'}
|
||||||
|
>
|
||||||
|
Menunggu
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant={filter.status === 'approved' ? 'default' : 'outline'}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setFilter({ ...filter, status: 'approved' })}
|
||||||
|
className={filter.status === 'approved' ? 'shadow-sm' : 'border-2'}
|
||||||
|
>
|
||||||
|
Disetujui
|
||||||
|
</Button>
|
||||||
|
{(filter.search || filter.type !== 'all' || filter.status !== 'all') && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={clearFilters}
|
||||||
|
className="text-muted-foreground hover:text-destructive"
|
||||||
|
>
|
||||||
|
<XIcon className="w-4 h-4 mr-1" />
|
||||||
|
Reset
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
Menampilkan {filteredReviews.length} dari {reviews.length} ulasan
|
Menampilkan {filteredReviews.length} dari {reviews.length} ulasan
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
Reference in New Issue
Block a user