Files
WooNooW/admin-spa/src/routes/Coupons/index.tsx
dwindown b77f63fcaf 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
2025-11-20 13:57:35 +07:00

329 lines
12 KiB
TypeScript

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 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>
);
}