From b77f63fcafb354b4e40d296b97480ec4cfa965b5 Mon Sep 17 00:00:00 2001 From: dwindown Date: Thu, 20 Nov 2025 13:57:35 +0700 Subject: [PATCH] feat: Coupons CRUD - Frontend list page (Phase 2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- admin-spa/src/lib/api/coupons.ts | 95 ++++++++ admin-spa/src/routes/Coupons/index.tsx | 325 ++++++++++++++++++++++++- 2 files changed, 416 insertions(+), 4 deletions(-) create mode 100644 admin-spa/src/lib/api/coupons.ts diff --git a/admin-spa/src/lib/api/coupons.ts b/admin-spa/src/lib/api/coupons.ts new file mode 100644 index 0000000..95ff308 --- /dev/null +++ b/admin-spa/src/lib/api/coupons.ts @@ -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 => { + return api.get('/coupons', { params }); + }, + + /** + * Get single coupon + */ + get: async (id: number): Promise => { + return api.get(`/coupons/${id}`); + }, + + /** + * Create new coupon + */ + create: async (data: CouponFormData): Promise => { + return api.post('/coupons', data); + }, + + /** + * Update coupon + */ + update: async (id: number, data: Partial): Promise => { + 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'}`); + }, +}; diff --git a/admin-spa/src/routes/Coupons/index.tsx b/admin-spa/src/routes/Coupons/index.tsx index 3fa1239..d170091 100644 --- a/admin-spa/src/routes/Coupons/index.tsx +++ b/admin-spa/src/routes/Coupons/index.tsx @@ -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([]); + + // 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 = { + '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 ; + } + + if (isError) { + return ( + refetch()} + /> + ); + } + + const coupons = data?.coupons || []; + const hasActiveFilters = search || discountType; + return ( -
-

{__('Coupons')}

-

{__('Coming soon — SPA coupon list.')}

+
+ {/* Toolbar */} +
+ {/* Left: Bulk Actions */} +
+ {/* Delete - Show only when items selected */} + {selectedIds.length > 0 && ( + + )} + + {/* Refresh - Always visible (REQUIRED per SOP) */} + +
+ + {/* Right: Filters */} +
+ {/* Discount Type Filter */} + + + {/* Search */} + setSearch(e.target.value)} + className="w-[200px]" + /> + + {/* Reset Filters - Text link style per SOP */} + {hasActiveFilters && ( + + )} +
+
+ + {/* Desktop Table */} +
+ + + + + + + + + + + + + + {coupons.length === 0 ? ( + + + + ) : ( + coupons.map((coupon) => ( + + + + + + + + + + )) + )} + +
+ 0} + onCheckedChange={toggleAll} + /> + {__('Code')}{__('Type')}{__('Amount')}{__('Usage')}{__('Expires')}{__('Actions')}
+ + {hasActiveFilters ? __('No coupons found matching your filters') : __('No coupons yet')} + {!hasActiveFilters && ( +

{__('Create your first coupon to get started')}

+ )} +
+ toggleSelection(coupon.id)} + /> + +
{coupon.code}
+ {coupon.description && ( +
+ {coupon.description} +
+ )} +
+ {formatDiscountType(coupon.discount_type)} + {formatAmount(coupon)} +
+ {coupon.usage_count} / {coupon.usage_limit || '∞'} +
+
+ {coupon.date_expires ? ( +
{new Date(coupon.date_expires).toLocaleDateString('id-ID')}
+ ) : ( +
{__('No expiry')}
+ )} +
+ +
+
+ + {/* Mobile Cards */} +
+ {coupons.length === 0 ? ( + + + {hasActiveFilters ? __('No coupons found matching your filters') : __('No coupons yet')} + {!hasActiveFilters && ( +

{__('Create your first coupon to get started')}

+ )} +
+ ) : ( + coupons.map((coupon) => ( + +
+
+ toggleSelection(coupon.id)} + /> +
+
{coupon.code}
+ {formatDiscountType(coupon.discount_type)} +
+
+
+
{formatAmount(coupon)}
+
+
+ {coupon.description && ( +

{coupon.description}

+ )} +
+
{__('Usage')}: {coupon.usage_count} / {coupon.usage_limit || '∞'}
+ {coupon.date_expires && ( +
{__('Expires')}: {new Date(coupon.date_expires).toLocaleDateString('id-ID')}
+ )} +
+ +
+ )) + )} +
+ + {/* Pagination */} + {data && data.total_pages > 1 && ( +
+
+ {__('Page')} {page} {__('of')} {data.total_pages} +
+
+ + +
+
+ )}
); }