docs: Update progress and SOP with CRUD pattern

Updated documentation with latest progress and standardized CRUD pattern.

PROGRESS_NOTE.md Updates:
- Email notification enhancements (variable dropdown, card reorganization)
- Card styling fixes (success = green, not purple)
- List support verification
- Product CRUD backend API complete (600+ lines)
- All endpoints: list, get, create, update, delete
- Full variant support for variable products
- Categories, tags, attributes endpoints

PROJECT_SOP.md Updates:
- Added Section 6.9: CRUD Module Pattern (Standard Template)
- Complete file structure template
- Backend API pattern with code examples
- Frontend index/create/edit page patterns
- Comprehensive checklist for new modules
- Based on Orders module analysis
- Ready to use for Products, Customers, Coupons, etc.

Benefits:
- Consistent pattern across all modules
- Faster development (copy-paste template)
- Standardized UX and code structure
- Clear checklist for implementation
- Reference implementation documented
This commit is contained in:
dwindown
2025-11-19 18:58:59 +07:00
parent 42457e75f1
commit 8b58b2a605
4 changed files with 1337 additions and 1 deletions

View File

@@ -1157,6 +1157,454 @@ Use Orders as the template for building new core modules.
---
## 6.9 CRUD Module Pattern (Standard Template)
**All CRUD modules (Orders, Products, Customers, Coupons, etc.) MUST follow this exact pattern for consistency.**
### 📁 File Structure
```
admin-spa/src/routes/{Module}/
├── index.tsx # List view (table + filters)
├── New.tsx # Create new item
├── Edit.tsx # Edit existing item
├── Detail.tsx # View item details (optional)
├── components/ # Module-specific components
│ ├── {Module}Card.tsx # Mobile card view
│ ├── FilterBottomSheet.tsx # Mobile filters
│ └── SearchBar.tsx # Search component
└── partials/ # Shared form components
└── {Module}Form.tsx # Reusable form for create/edit
```
### 🎯 Backend API Pattern
**File:** `includes/Api/{Module}Controller.php`
```php
<?php
namespace WooNooW\Api;
class {Module}Controller {
public static function register_routes() {
// List
register_rest_route('woonoow/v1', '/{module}', [
'methods' => 'GET',
'callback' => [__CLASS__, 'get_{module}'],
'permission_callback' => [Permissions::class, 'check_admin'],
]);
// Single
register_rest_route('woonoow/v1', '/{module}/(?P<id>\d+)', [
'methods' => 'GET',
'callback' => [__CLASS__, 'get_{item}'],
'permission_callback' => [Permissions::class, 'check_admin'],
]);
// Create
register_rest_route('woonoow/v1', '/{module}', [
'methods' => 'POST',
'callback' => [__CLASS__, 'create_{item}'],
'permission_callback' => [Permissions::class, 'check_admin'],
]);
// Update
register_rest_route('woonoow/v1', '/{module}/(?P<id>\d+)', [
'methods' => 'PUT',
'callback' => [__CLASS__, 'update_{item}'],
'permission_callback' => [Permissions::class, 'check_admin'],
]);
// Delete
register_rest_route('woonoow/v1', '/{module}/(?P<id>\d+)', [
'methods' => 'DELETE',
'callback' => [__CLASS__, 'delete_{item}'],
'permission_callback' => [Permissions::class, 'check_admin'],
]);
}
// List with pagination & filters
public static function get_{module}(WP_REST_Request $request) {
$page = max(1, (int) $request->get_param('page'));
$per_page = min(100, max(1, (int) ($request->get_param('per_page') ?: 20)));
$search = $request->get_param('search');
$status = $request->get_param('status');
$orderby = $request->get_param('orderby') ?: 'date';
$order = $request->get_param('order') ?: 'DESC';
// Query logic here
return new WP_REST_Response([
'rows' => $items,
'total' => $total,
'page' => $page,
'per_page' => $per_page,
'pages' => $max_pages,
], 200);
}
}
```
**Register in Routes.php:**
```php
use WooNooW\Api\{Module}Controller;
// In rest_api_init:
{Module}Controller::register_routes();
```
### 🎨 Frontend Index Page Pattern
**File:** `admin-spa/src/routes/{Module}/index.tsx`
```typescript
import React, { useState, useCallback } from 'react';
import { useQuery, useMutation, keepPreviousData } from '@tanstack/react-query';
import { api } from '@/lib/api';
import { useFABConfig } from '@/hooks/useFABConfig';
import { setQuery, getQuery } from '@/lib/query-params';
import { __ } from '@/lib/i18n';
export default function {Module}Index() {
useFABConfig('{module}'); // Enable FAB for create
const initial = getQuery();
const [page, setPage] = useState(Number(initial.page ?? 1) || 1);
const [status, setStatus] = useState<string | undefined>(initial.status || undefined);
const [searchQuery, setSearchQuery] = useState('');
const [selectedIds, setSelectedIds] = useState<number[]>([]);
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
const perPage = 20;
// Sync URL params
React.useEffect(() => {
setQuery({ page, status });
}, [page, status]);
// Fetch data
const q = useQuery({
queryKey: ['{module}', { page, perPage, status }],
queryFn: () => api.get('/{module}', {
page, per_page: perPage, status
}),
placeholderData: keepPreviousData,
});
const data = q.data as undefined | { rows: any[]; total: number };
// Filter by search
const filteredItems = React.useMemo(() => {
const rows = data?.rows;
if (!rows) return [];
if (!searchQuery.trim()) return rows;
const query = searchQuery.toLowerCase();
return rows.filter((item: any) =>
item.name?.toLowerCase().includes(query) ||
item.id?.toString().includes(query)
);
}, [data, searchQuery]);
// Bulk delete
const deleteMutation = useMutation({
mutationFn: async (ids: number[]) => {
const results = await Promise.allSettled(
ids.map(id => api.del(`/{module}/${id}`))
);
const failed = results.filter(r => r.status === 'rejected').length;
return { total: ids.length, failed };
},
onSuccess: (result) => {
const { total, failed } = result;
if (failed === 0) {
toast.success(__('Items deleted successfully'));
} else if (failed < total) {
toast.warning(__(`${total - failed} deleted, ${failed} failed`));
} else {
toast.error(__('Failed to delete items'));
}
setSelectedIds([]);
setShowDeleteDialog(false);
q.refetch();
},
});
// Checkbox handlers
const allIds = filteredItems.map(r => r.id) || [];
const allSelected = allIds.length > 0 && selectedIds.length === allIds.length;
const toggleAll = () => {
setSelectedIds(allSelected ? [] : allIds);
};
const toggleRow = (id: number) => {
setSelectedIds(prev =>
prev.includes(id) ? prev.filter(x => x !== id) : [...prev, id]
);
};
return (
<div className="space-y-4 w-full pb-4">
{/* Desktop: Filters */}
<div className="hidden md:block rounded-lg border p-4">
{/* Filter controls */}
</div>
{/* Mobile: Search + Filter */}
<div className="md:hidden">
<SearchBar
value={searchQuery}
onChange={setSearchQuery}
onFilterClick={() => setFilterSheetOpen(true)}
/>
</div>
{/* Desktop: Table */}
<div className="hidden md:block">
<table className="w-full">
<thead>
<tr>
<th><Checkbox checked={allSelected} onCheckedChange={toggleAll} /></th>
<th>{__('Name')}</th>
<th>{__('Status')}</th>
<th>{__('Actions')}</th>
</tr>
</thead>
<tbody>
{filteredItems.map(item => (
<tr key={item.id}>
<td><Checkbox checked={selectedIds.includes(item.id)} onCheckedChange={() => toggleRow(item.id)} /></td>
<td>{item.name}</td>
<td><StatusBadge value={item.status} /></td>
<td><Link to={`/{module}/${item.id}`}>{__('View')}</Link></td>
</tr>
))}
</tbody>
</table>
</div>
{/* Mobile: Cards */}
<div className="md:hidden space-y-2">
{filteredItems.map(item => (
<{Module}Card key={item.id} item={item} />
))}
</div>
{/* Delete Dialog */}
<AlertDialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
{/* Dialog content */}
</AlertDialog>
</div>
);
}
```
### 📝 Frontend Create Page Pattern
**File:** `admin-spa/src/routes/{Module}/New.tsx`
```typescript
import React, { useEffect, useRef } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { api } from '@/lib/api';
import { useNavigate } from 'react-router-dom';
import { usePageHeader } from '@/contexts/PageHeaderContext';
import { Button } from '@/components/ui/button';
import { useFABConfig } from '@/hooks/useFABConfig';
import { __ } from '@/lib/i18n';
import {Module}Form from './partials/{Module}Form';
export default function {Module}New() {
const nav = useNavigate();
const qc = useQueryClient();
const { setPageHeader, clearPageHeader } = usePageHeader();
const formRef = useRef<HTMLFormElement>(null);
useFABConfig('none'); // Hide FAB on create page
const mutate = useMutation({
mutationFn: (data: any) => api.post('/{module}', data),
onSuccess: (data) => {
qc.invalidateQueries({ queryKey: ['{module}'] });
showSuccessToast(__('Item created successfully'));
nav('/{module}');
},
onError: (error: any) => {
showErrorToast(error);
},
});
// Set page header
useEffect(() => {
const actions = (
<div className="flex gap-2">
<Button size="sm" variant="ghost" onClick={() => nav('/{module}')}>
{__('Back')}
</Button>
<Button
size="sm"
onClick={() => formRef.current?.requestSubmit()}
disabled={mutate.isPending}
>
{mutate.isPending ? __('Creating...') : __('Create')}
</Button>
</div>
);
setPageHeader(__('New {Item}'), actions);
return () => clearPageHeader();
}, [mutate.isPending, setPageHeader, clearPageHeader, nav]);
return (
<div className="space-y-4">
<{Module}Form
mode="create"
formRef={formRef}
hideSubmitButton={true}
onSubmit={(form) => mutate.mutate(form)}
/>
</div>
);
}
```
### ✏️ Frontend Edit Page Pattern
**File:** `admin-spa/src/routes/{Module}/Edit.tsx`
```typescript
import React, { useEffect, useRef } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { api } from '@/lib/api';
import { usePageHeader } from '@/contexts/PageHeaderContext';
import { Button } from '@/components/ui/button';
import { useFABConfig } from '@/hooks/useFABConfig';
import { __ } from '@/lib/i18n';
import {Module}Form from './partials/{Module}Form';
export default function {Module}Edit() {
const { id } = useParams();
const itemId = Number(id);
const nav = useNavigate();
const qc = useQueryClient();
const { setPageHeader, clearPageHeader } = usePageHeader();
const formRef = useRef<HTMLFormElement>(null);
useFABConfig('none');
const itemQ = useQuery({
queryKey: ['{item}', itemId],
enabled: Number.isFinite(itemId),
queryFn: () => api.get(`/{module}/${itemId}`)
});
const upd = useMutation({
mutationFn: (payload: any) => api.put(`/{module}/${itemId}`, payload),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['{module}'] });
qc.invalidateQueries({ queryKey: ['{item}', itemId] });
showSuccessToast(__('Item updated successfully'));
nav(`/{module}/${itemId}`);
},
onError: (error: any) => {
showErrorToast(error);
}
});
const item = itemQ.data || {};
// Set page header
useEffect(() => {
const actions = (
<div className="flex gap-2">
<Button size="sm" variant="ghost" onClick={() => nav(`/{module}/${itemId}`)}>
{__('Back')}
</Button>
<Button
size="sm"
onClick={() => formRef.current?.requestSubmit()}
disabled={upd.isPending}
>
{upd.isPending ? __('Saving...') : __('Save')}
</Button>
</div>
);
setPageHeader(__('Edit {Item}'), actions);
return () => clearPageHeader();
}, [itemId, upd.isPending, setPageHeader, clearPageHeader, nav]);
if (!Number.isFinite(itemId)) {
return <div className="p-4 text-sm text-red-600">{__('Invalid ID')}</div>;
}
if (itemQ.isLoading) {
return <LoadingState message={__('Loading...')} />;
}
if (itemQ.isError) {
return <ErrorCard
title={__('Failed to load item')}
message={getPageLoadErrorMessage(itemQ.error)}
onRetry={() => itemQ.refetch()}
/>;
}
return (
<div className="space-y-4">
<{Module}Form
mode="edit"
initial={item}
formRef={formRef}
hideSubmitButton={true}
onSubmit={(form) => upd.mutate(form)}
/>
</div>
);
}
```
### 📋 Checklist for New CRUD Module
**Backend:**
- [ ] Create `{Module}Controller.php` with all CRUD endpoints
- [ ] Register routes in `Routes.php`
- [ ] Add permission checks (`Permissions::check_admin`)
- [ ] Implement pagination, filters, search
- [ ] Return consistent response format
- [ ] Add i18n for all error messages
**Frontend:**
- [ ] Create `routes/{Module}/index.tsx` (list view)
- [ ] Create `routes/{Module}/New.tsx` (create)
- [ ] Create `routes/{Module}/Edit.tsx` (edit)
- [ ] Create `routes/{Module}/Detail.tsx` (optional view)
- [ ] Create `components/{Module}Card.tsx` (mobile)
- [ ] Create `partials/{Module}Form.tsx` (reusable form)
- [ ] Add to navigation tree (`nav/tree.ts`)
- [ ] Configure FAB (`useFABConfig`)
- [ ] Add all i18n strings
- [ ] Implement bulk delete
- [ ] Add filters (status, date, search)
- [ ] Add pagination
- [ ] Test mobile responsive
- [ ] Test error states
- [ ] Test loading states
**Testing:**
- [ ] Create item
- [ ] Edit item
- [ ] Delete item
- [ ] Bulk delete
- [ ] Search
- [ ] Filter by status
- [ ] Pagination
- [ ] Mobile view
- [ ] Error handling
- [ ] Permission checks
---
## 7. 🎨 Admin Interface Modes
WooNooW provides **three distinct admin interface modes** to accommodate different workflows and user preferences: