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:
95
admin-spa/src/lib/api/coupons.ts
Normal file
95
admin-spa/src/lib/api/coupons.ts
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
import { api } from '../api';
|
||||||
|
|
||||||
|
export interface Coupon {
|
||||||
|
id: number;
|
||||||
|
code: string;
|
||||||
|
amount: number;
|
||||||
|
discount_type: 'percent' | 'fixed_cart' | 'fixed_product';
|
||||||
|
description: string;
|
||||||
|
usage_count: number;
|
||||||
|
usage_limit: number | null;
|
||||||
|
date_expires: string | null;
|
||||||
|
individual_use?: boolean;
|
||||||
|
product_ids?: number[];
|
||||||
|
excluded_product_ids?: number[];
|
||||||
|
usage_limit_per_user?: number | null;
|
||||||
|
limit_usage_to_x_items?: number | null;
|
||||||
|
free_shipping?: boolean;
|
||||||
|
product_categories?: number[];
|
||||||
|
excluded_product_categories?: number[];
|
||||||
|
exclude_sale_items?: boolean;
|
||||||
|
minimum_amount?: number | null;
|
||||||
|
maximum_amount?: number | null;
|
||||||
|
email_restrictions?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CouponListResponse {
|
||||||
|
coupons: Coupon[];
|
||||||
|
total: number;
|
||||||
|
page: number;
|
||||||
|
per_page: number;
|
||||||
|
total_pages: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CouponFormData {
|
||||||
|
code: string;
|
||||||
|
amount: number;
|
||||||
|
discount_type: 'percent' | 'fixed_cart' | 'fixed_product';
|
||||||
|
description?: string;
|
||||||
|
date_expires?: string | null;
|
||||||
|
individual_use?: boolean;
|
||||||
|
product_ids?: number[];
|
||||||
|
excluded_product_ids?: number[];
|
||||||
|
usage_limit?: number | null;
|
||||||
|
usage_limit_per_user?: number | null;
|
||||||
|
limit_usage_to_x_items?: number | null;
|
||||||
|
free_shipping?: boolean;
|
||||||
|
product_categories?: number[];
|
||||||
|
excluded_product_categories?: number[];
|
||||||
|
exclude_sale_items?: boolean;
|
||||||
|
minimum_amount?: number | null;
|
||||||
|
maximum_amount?: number | null;
|
||||||
|
email_restrictions?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CouponsApi = {
|
||||||
|
/**
|
||||||
|
* List coupons with pagination and filtering
|
||||||
|
*/
|
||||||
|
list: async (params?: {
|
||||||
|
page?: number;
|
||||||
|
per_page?: number;
|
||||||
|
search?: string;
|
||||||
|
discount_type?: string;
|
||||||
|
}): Promise<CouponListResponse> => {
|
||||||
|
return api.get('/coupons', { params });
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get single coupon
|
||||||
|
*/
|
||||||
|
get: async (id: number): Promise<Coupon> => {
|
||||||
|
return api.get(`/coupons/${id}`);
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create new coupon
|
||||||
|
*/
|
||||||
|
create: async (data: CouponFormData): Promise<Coupon> => {
|
||||||
|
return api.post('/coupons', data);
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update coupon
|
||||||
|
*/
|
||||||
|
update: async (id: number, data: Partial<CouponFormData>): Promise<Coupon> => {
|
||||||
|
return api.put(`/coupons/${id}`, data);
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete coupon
|
||||||
|
*/
|
||||||
|
delete: async (id: number, force: boolean = false): Promise<{ success: boolean; id: number }> => {
|
||||||
|
return api.del(`/coupons/${id}?force=${force ? 'true' : 'false'}`);
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -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 { __ } 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() {
|
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 (
|
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>
|
||||||
<h1 className="text-xl font-semibold mb-3">{__('Coupons')}</h1>
|
<div className="font-medium">{coupon.code}</div>
|
||||||
<p className="opacity-70">{__('Coming soon — SPA coupon list.')}</p>
|
<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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user