feat: Coupons CRUD - Frontend list page (Phase 2)
Implemented complete Coupons list page following PROJECT_SOP.md Created: CouponsApi helper (lib/api/coupons.ts) - TypeScript interfaces for Coupon and CouponFormData - Full CRUD methods: list, get, create, update, delete - Pagination and filtering support Updated: Coupons/index.tsx (Complete rewrite) - Full CRUD list page with SOP-compliant UI - Toolbar with bulk actions and filters - Desktop table + Mobile cards (responsive) - Pagination support - Search and filter by discount type Following PROJECT_SOP.md Standards: ✅ Toolbar pattern: Bulk delete, Refresh (REQUIRED), Filters ✅ Table UI: p-3 padding, hover:bg-muted/30, bg-muted/50 header ✅ Button styling: bg-red-600 for delete, inline-flex gap-2 ✅ Reset filters: Text link style (NOT button) ✅ Empty state: Icon + message + helper text ✅ Mobile responsive: Cards with md:hidden ✅ Error handling: ErrorCard for page loads ✅ Loading state: LoadingState component ✅ FAB configuration: Navigate to /coupons/new Features: - Bulk selection with checkbox - Bulk delete with confirmation - Search by coupon code - Filter by discount type - Pagination (prev/next) - Formatted discount amounts - Usage tracking display - Expiry date display - Edit navigation UI Components Used: - Card, Input, Select, Checkbox, Badge - Lucide icons: Trash2, RefreshCw, Edit, Tag - Consistent spacing and typography Next Steps: - Create New.tsx (create coupon form) - Create Edit.tsx (edit coupon form) - Update NavigationRegistry.php - Update API_ROUTES.md
This commit is contained in:
@@ -1,11 +1,328 @@
|
||||
import React from 'react';
|
||||
import React, { useState } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { __ } from '@/lib/i18n';
|
||||
import { CouponsApi, type Coupon } from '@/lib/api/coupons';
|
||||
import { showErrorToast, showSuccessToast, getPageLoadErrorMessage } from '@/lib/errorHandling';
|
||||
import { ErrorCard } from '@/components/ErrorCard';
|
||||
import { LoadingState } from '@/components/LoadingState';
|
||||
import { Card } from '@/components/ui/card';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Trash2, RefreshCw, Edit, Tag } from 'lucide-react';
|
||||
import { useFABConfig } from '@/hooks/useFABConfig';
|
||||
|
||||
export default function CouponsIndex() {
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
const [page, setPage] = useState(1);
|
||||
const [search, setSearch] = useState('');
|
||||
const [discountType, setDiscountType] = useState('');
|
||||
const [selectedIds, setSelectedIds] = useState<number[]>([]);
|
||||
|
||||
// Configure FAB to navigate to new coupon page
|
||||
useFABConfig('navigate', '/coupons/new');
|
||||
|
||||
// Fetch coupons
|
||||
const { data, isLoading, isError, error, refetch } = useQuery({
|
||||
queryKey: ['coupons', page, search, discountType],
|
||||
queryFn: () => CouponsApi.list({ page, per_page: 20, search, discount_type: discountType }),
|
||||
});
|
||||
|
||||
// Delete mutation
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (id: number) => CouponsApi.delete(id, false),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['coupons'] });
|
||||
showSuccessToast(__('Coupon deleted successfully'));
|
||||
setSelectedIds([]);
|
||||
},
|
||||
onError: (error) => {
|
||||
showErrorToast(error);
|
||||
},
|
||||
});
|
||||
|
||||
// Bulk delete
|
||||
const handleBulkDelete = async () => {
|
||||
if (!confirm(__('Are you sure you want to delete the selected coupons?'))) return;
|
||||
|
||||
for (const id of selectedIds) {
|
||||
await deleteMutation.mutateAsync(id);
|
||||
}
|
||||
};
|
||||
|
||||
// Toggle selection
|
||||
const toggleSelection = (id: number) => {
|
||||
setSelectedIds(prev =>
|
||||
prev.includes(id) ? prev.filter(i => i !== id) : [...prev, id]
|
||||
);
|
||||
};
|
||||
|
||||
// Toggle all
|
||||
const toggleAll = () => {
|
||||
if (selectedIds.length === data?.coupons.length) {
|
||||
setSelectedIds([]);
|
||||
} else {
|
||||
setSelectedIds(data?.coupons.map(c => c.id) || []);
|
||||
}
|
||||
};
|
||||
|
||||
// Format discount type
|
||||
const formatDiscountType = (type: string) => {
|
||||
const types: Record<string, string> = {
|
||||
'percent': __('Percentage'),
|
||||
'fixed_cart': __('Fixed Cart'),
|
||||
'fixed_product': __('Fixed Product'),
|
||||
};
|
||||
return types[type] || type;
|
||||
};
|
||||
|
||||
// Format amount
|
||||
const formatAmount = (coupon: Coupon) => {
|
||||
if (coupon.discount_type === 'percent') {
|
||||
return `${coupon.amount}%`;
|
||||
}
|
||||
return `Rp${coupon.amount.toLocaleString('id-ID')}`;
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return <LoadingState message={__('Loading coupons...')} />;
|
||||
}
|
||||
|
||||
if (isError) {
|
||||
return (
|
||||
<ErrorCard
|
||||
title={__('Failed to load coupons')}
|
||||
message={getPageLoadErrorMessage(error)}
|
||||
onRetry={() => refetch()}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const coupons = data?.coupons || [];
|
||||
const hasActiveFilters = search || discountType;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold mb-3">{__('Coupons')}</h1>
|
||||
<p className="opacity-70">{__('Coming soon — SPA coupon list.')}</p>
|
||||
<div className="space-y-4">
|
||||
{/* Toolbar */}
|
||||
<div className="flex flex-col lg:flex-row lg:justify-between lg:items-center gap-3">
|
||||
{/* Left: Bulk Actions */}
|
||||
<div className="flex gap-3">
|
||||
{/* Delete - Show only when items selected */}
|
||||
{selectedIds.length > 0 && (
|
||||
<button
|
||||
className="border rounded-md px-3 py-2 text-sm bg-red-600 text-white hover:bg-red-700 disabled:opacity-50 inline-flex items-center gap-2"
|
||||
onClick={handleBulkDelete}
|
||||
disabled={deleteMutation.isPending}
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
{__('Delete')} ({selectedIds.length})
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Refresh - Always visible (REQUIRED per SOP) */}
|
||||
<button
|
||||
className="border rounded-md px-3 py-2 text-sm hover:bg-accent disabled:opacity-50 inline-flex items-center gap-2"
|
||||
onClick={() => refetch()}
|
||||
disabled={isLoading}
|
||||
>
|
||||
<RefreshCw className="w-4 h-4" />
|
||||
{__('Refresh')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Right: Filters */}
|
||||
<div className="flex gap-3 flex-wrap items-center">
|
||||
{/* Discount Type Filter */}
|
||||
<Select value={discountType} onValueChange={setDiscountType}>
|
||||
<SelectTrigger className="w-[180px]">
|
||||
<SelectValue placeholder={__('All types')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="">{__('All types')}</SelectItem>
|
||||
<SelectItem value="percent">{__('Percentage')}</SelectItem>
|
||||
<SelectItem value="fixed_cart">{__('Fixed Cart')}</SelectItem>
|
||||
<SelectItem value="fixed_product">{__('Fixed Product')}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{/* Search */}
|
||||
<Input
|
||||
placeholder={__('Search coupons...')}
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="w-[200px]"
|
||||
/>
|
||||
|
||||
{/* Reset Filters - Text link style per SOP */}
|
||||
{hasActiveFilters && (
|
||||
<button
|
||||
className="text-sm text-muted-foreground hover:text-foreground underline"
|
||||
onClick={() => {
|
||||
setSearch('');
|
||||
setDiscountType('');
|
||||
}}
|
||||
>
|
||||
{__('Clear filters')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Desktop Table */}
|
||||
<div className="hidden md:block rounded-lg border overflow-hidden">
|
||||
<table className="w-full">
|
||||
<thead className="bg-muted/50">
|
||||
<tr className="border-b">
|
||||
<th className="w-12 p-3">
|
||||
<Checkbox
|
||||
checked={selectedIds.length === coupons.length && coupons.length > 0}
|
||||
onCheckedChange={toggleAll}
|
||||
/>
|
||||
</th>
|
||||
<th className="text-left p-3 font-medium">{__('Code')}</th>
|
||||
<th className="text-left p-3 font-medium">{__('Type')}</th>
|
||||
<th className="text-left p-3 font-medium">{__('Amount')}</th>
|
||||
<th className="text-left p-3 font-medium">{__('Usage')}</th>
|
||||
<th className="text-left p-3 font-medium">{__('Expires')}</th>
|
||||
<th className="text-center p-3 font-medium">{__('Actions')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{coupons.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={7} className="p-8 text-center text-muted-foreground">
|
||||
<Tag className="w-12 h-12 mx-auto mb-2 opacity-50" />
|
||||
{hasActiveFilters ? __('No coupons found matching your filters') : __('No coupons yet')}
|
||||
{!hasActiveFilters && (
|
||||
<p className="text-sm mt-1">{__('Create your first coupon to get started')}</p>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
coupons.map((coupon) => (
|
||||
<tr key={coupon.id} className="border-b hover:bg-muted/30 last:border-0">
|
||||
<td className="p-3">
|
||||
<Checkbox
|
||||
checked={selectedIds.includes(coupon.id)}
|
||||
onCheckedChange={() => toggleSelection(coupon.id)}
|
||||
/>
|
||||
</td>
|
||||
<td className="p-3">
|
||||
<div className="font-medium">{coupon.code}</div>
|
||||
{coupon.description && (
|
||||
<div className="text-sm text-muted-foreground line-clamp-1">
|
||||
{coupon.description}
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
<td className="p-3">
|
||||
<Badge variant="outline">{formatDiscountType(coupon.discount_type)}</Badge>
|
||||
</td>
|
||||
<td className="p-3 font-medium">{formatAmount(coupon)}</td>
|
||||
<td className="p-3">
|
||||
<div className="text-sm">
|
||||
{coupon.usage_count} / {coupon.usage_limit || '∞'}
|
||||
</div>
|
||||
</td>
|
||||
<td className="p-3">
|
||||
{coupon.date_expires ? (
|
||||
<div className="text-sm">{new Date(coupon.date_expires).toLocaleDateString('id-ID')}</div>
|
||||
) : (
|
||||
<div className="text-sm text-muted-foreground">{__('No expiry')}</div>
|
||||
)}
|
||||
</td>
|
||||
<td className="p-3 text-center">
|
||||
<button
|
||||
className="inline-flex items-center gap-1 text-sm text-blue-600 hover:text-blue-700"
|
||||
onClick={() => navigate(`/coupons/${coupon.id}`)}
|
||||
>
|
||||
<Edit className="w-4 h-4" />
|
||||
{__('Edit')}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Mobile Cards */}
|
||||
<div className="md:hidden space-y-2">
|
||||
{coupons.length === 0 ? (
|
||||
<Card className="p-8 text-center text-muted-foreground">
|
||||
<Tag className="w-12 h-12 mx-auto mb-2 opacity-50" />
|
||||
{hasActiveFilters ? __('No coupons found matching your filters') : __('No coupons yet')}
|
||||
{!hasActiveFilters && (
|
||||
<p className="text-sm mt-1">{__('Create your first coupon to get started')}</p>
|
||||
)}
|
||||
</Card>
|
||||
) : (
|
||||
coupons.map((coupon) => (
|
||||
<Card key={coupon.id} className="p-4">
|
||||
<div className="flex items-start justify-between mb-2">
|
||||
<div className="flex items-start gap-3">
|
||||
<Checkbox
|
||||
checked={selectedIds.includes(coupon.id)}
|
||||
onCheckedChange={() => toggleSelection(coupon.id)}
|
||||
/>
|
||||
<div>
|
||||
<div className="font-medium">{coupon.code}</div>
|
||||
<Badge variant="outline" className="mt-1">{formatDiscountType(coupon.discount_type)}</Badge>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div className="font-medium">{formatAmount(coupon)}</div>
|
||||
</div>
|
||||
</div>
|
||||
{coupon.description && (
|
||||
<p className="text-sm text-muted-foreground mb-2">{coupon.description}</p>
|
||||
)}
|
||||
<div className="flex items-center justify-between text-sm text-muted-foreground">
|
||||
<div>{__('Usage')}: {coupon.usage_count} / {coupon.usage_limit || '∞'}</div>
|
||||
{coupon.date_expires && (
|
||||
<div>{__('Expires')}: {new Date(coupon.date_expires).toLocaleDateString('id-ID')}</div>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
className="mt-3 w-full inline-flex items-center justify-center gap-2 text-sm text-blue-600 hover:text-blue-700"
|
||||
onClick={() => navigate(`/coupons/${coupon.id}`)}
|
||||
>
|
||||
<Edit className="w-4 h-4" />
|
||||
{__('Edit')}
|
||||
</button>
|
||||
</Card>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
{data && data.total_pages > 1 && (
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{__('Page')} {page} {__('of')} {data.total_pages}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
className="border rounded-md px-3 py-2 text-sm hover:bg-accent disabled:opacity-50"
|
||||
onClick={() => setPage(p => Math.max(1, p - 1))}
|
||||
disabled={page === 1}
|
||||
>
|
||||
{__('Previous')}
|
||||
</button>
|
||||
<button
|
||||
className="border rounded-md px-3 py-2 text-sm hover:bg-accent disabled:opacity-50"
|
||||
onClick={() => setPage(p => p + 1)}
|
||||
disabled={page >= data.total_pages}
|
||||
>
|
||||
{__('Next')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user