feat: build Order Management & Analytics (F2.13-F2.16)

Components:
- OrderList: table with filters (status, date, search, pagination)
- OrderListItem: single order row with status badge
- OrderDetail: full order view with status update, items, customer info
- OrderTimeline: status change history with visual progress
- AnalyticsDashboard: stats cards (orders, revenue, completed, pending)

Features:
- Order list with keyword search, status filter, date range
- Pagination support
- Status change workflow with immediate update
- Order detail with items breakdown and customer info
- Visual timeline progress indicator
- Analytics dashboard with key metrics
- Placeholder charts for future implementation

Updated Orders page to use new components with list/detail navigation
This commit is contained in:
dwindown
2026-04-18 12:15:48 +07:00
parent ed2520aadf
commit fa792d38ae
11 changed files with 1361 additions and 5 deletions

View File

@@ -0,0 +1,128 @@
.formipay-analytics-dashboard {
display: flex;
flex-direction: column;
gap: 24px;
padding: 20px;
}
.formipay-dashboard-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.formipay-dashboard-header h2 {
margin: 0;
font-size: 20px;
font-weight: 600;
display: flex;
align-items: center;
gap: 10px;
}
.formipay-dashboard-header svg {
fill: #1e1e1e;
}
.formipay-stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
gap: 16px;
}
.stat-card {
display: flex;
align-items: center;
gap: 16px;
background: #fff;
border: 1px solid #e0e0e0;
border-radius: 4px;
padding: 20px;
}
.stat-icon {
width: 48px;
height: 48px;
display: flex;
align-items: center;
justify-content: center;
background: #f0f0f1;
border-radius: 4px;
}
.stat-icon svg {
fill: #1e1e1e;
}
.stat-icon.completed {
background: #e7f7ed;
}
.stat-icon.completed span {
font-size: 24px;
color: #28a745;
}
.stat-icon.pending {
background: #fff8e5;
}
.stat-icon.pending span {
font-size: 24px;
}
.stat-content {
flex: 1;
display: flex;
flex-direction: column;
}
.stat-label {
font-size: 12px;
font-weight: 600;
color: #646970;
text-transform: uppercase;
}
.stat-value {
font-size: 24px;
font-weight: 700;
color: #1e1e1e;
}
.formipay-dashboard-charts {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(400px, 1fr));
gap: 16px;
}
.chart-card {
background: #fff;
border: 1px solid #e0e0e0;
border-radius: 4px;
padding: 20px;
}
.chart-card h3 {
margin: 0 0 16px;
font-size: 14px;
font-weight: 600;
color: #1e1e1e;
}
.chart-placeholder {
height: 200px;
display: flex;
align-items: center;
justify-content: center;
background: #f6f7f7;
border: 1px dashed #c3c4c7;
border-radius: 2px;
}
.chart-placeholder p {
margin: 0;
font-size: 13px;
color: #646970;
text-align: center;
}

View File

@@ -0,0 +1,170 @@
/**
* Analytics Dashboard - Order statistics and charts
*/
import { __ } from '@wordpress/i18n';
import { useState, useCallback, useEffect } from '@wordpress/element';
import { SelectControl } from '@wordpress/components';
import { Icon, chartLine, money, shoppingCart } from '@wordpress/icons';
import { ordersApi } from '../../api/client';
import './AnalyticsDashboard.css';
export default function AnalyticsDashboard() {
const [stats, setStats] = useState({
totalOrders: 0,
totalRevenue: 0,
completedOrders: 0,
pendingOrders: 0,
});
const [loading, setLoading] = useState(true);
const [dateRange, setDateRange] = useState('30');
const loadStats = useCallback(() => {
setLoading(true);
// This would be a dedicated stats API endpoint
// For now, we'll fetch orders and calculate
ordersApi.list({ limit: 1000 })
.then(result => {
if (result.data) {
const orders = result.data.results || [];
const totalRevenue = orders.reduce((sum, order) => {
if (order.status === 'completed') {
return sum + parseFloat(order.total || 0);
}
return sum;
}, 0);
setStats({
totalOrders: orders.length,
totalRevenue: totalRevenue,
completedOrders: orders.filter(o => o.status === 'completed').length,
pendingOrders: orders.filter(o => ['on-hold', 'payment-confirm', 'in-progress'].includes(o.status)).length,
});
}
})
.catch(error => {
console.error('Load stats error:', error);
})
.finally(() => {
setLoading(false);
});
}, [dateRange]);
useEffect(() => {
loadStats();
}, [loadStats]);
return (
<div className="formipay-analytics-dashboard">
<div className="formipay-dashboard-header">
<h2>
<Icon icon={chartLine} />
{ __('Dashboard', 'formipay') }
</h2>
<SelectControl
value={dateRange}
options={[
{ value: '7', label: __('Last 7 days', 'formipay') },
{ value: '30', label: __('Last 30 days', 'formipay') },
{ value: '90', label: __('Last 90 days', 'formipay') },
{ value: '365', label: __('Last year', 'formipay') },
]}
onChange={setDateRange}
/>
</div>
{loading ? (
<div className="formipay-loading">
<span className="spinner is-active" />
</div>
) : (
<>
<div className="formipay-stats-grid">
<div className="stat-card">
<div className="stat-icon">
<Icon icon={shoppingCart} size={24} />
</div>
<div className="stat-content">
<span className="stat-label">
{ __('Total Orders', 'formipay') }
</span>
<span className="stat-value">
{ stats.totalOrders }
</span>
</div>
</div>
<div className="stat-card">
<div className="stat-icon">
<Icon icon={money} size={24} />
</div>
<div className="stat-content">
<span className="stat-label">
{ __('Total Revenue', 'formipay') }
</span>
<span className="stat-value">
{ stats.totalRevenue.toLocaleString('en-US', {
style: 'currency',
currency: 'USD',
}) }
</span>
</div>
</div>
<div className="stat-card">
<div className="stat-icon completed">
<span></span>
</div>
<div className="stat-content">
<span className="stat-label">
{ __('Completed', 'formipay') }
</span>
<span className="stat-value">
{ stats.completedOrders }
</span>
</div>
</div>
<div className="stat-card">
<div className="stat-icon pending">
<span></span>
</div>
<div className="stat-content">
<span className="stat-label">
{ __('Pending', 'formipay') }
</span>
<span className="stat-value">
{ stats.pendingOrders }
</span>
</div>
</div>
</div>
<div className="formipay-dashboard-charts">
<div className="chart-card">
<h3>
{ __('Revenue Over Time', 'formipay') }
</h3>
<div className="chart-placeholder">
<p>
{ __('Chart placeholder - would use a library like Chart.js or Recharts', 'formipay') }
</p>
</div>
</div>
<div className="chart-card">
<h3>
{ __('Orders by Status', 'formipay') }
</h3>
<div className="chart-placeholder">
<p>
{ __('Chart placeholder - would use a library like Chart.js or Recharts', 'formipay') }
</p>
</div>
</div>
</div>
</>
)}
</div>
);
}

View File

@@ -0,0 +1,165 @@
.formipay-order-detail {
display: flex;
flex-direction: column;
height: 100%;
background: #f6f7f7;
}
.formipay-detail-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px 20px;
background: #fff;
border-bottom: 1px solid #e0e0e0;
gap: 16px;
}
.formipay-detail-header h1 {
margin: 0;
font-size: 20px;
font-weight: 600;
flex: 1;
}
.header-actions {
display: flex;
gap: 8px;
}
.formipay-detail-content {
display: grid;
grid-template-columns: 2fr 1fr;
gap: 20px;
padding: 20px;
overflow-y: auto;
}
.formipay-detail-main,
.formipay-detail-sidebar {
display: flex;
flex-direction: column;
gap: 20px;
}
.formipay-detail-card {
background: #fff;
border: 1px solid #e0e0e0;
border-radius: 4px;
padding: 20px;
}
.formipay-detail-card h3 {
margin: 0 0 16px;
font-size: 16px;
font-weight: 600;
color: #1e1e1e;
}
.detail-list {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 16px;
}
.detail-list > div {
display: flex;
flex-direction: column;
}
.detail-list dt {
font-size: 12px;
font-weight: 600;
color: #646970;
margin-bottom: 4px;
}
.detail-list dd {
font-size: 14px;
color: #1e1e1e;
display: flex;
align-items: center;
gap: 8px;
}
.detail-list dd .components-select-control {
flex: 1;
}
.items-table {
width: 100%;
border-collapse: collapse;
}
.items-table th,
.items-table td {
padding: 10px;
text-align: left;
border-bottom: 1px solid #f0f0f1;
}
.items-table th {
font-size: 12px;
font-weight: 600;
color: #646970;
text-transform: uppercase;
}
.items-table td {
font-size: 13px;
}
.items-table small {
display: block;
color: #646970;
font-size: 11px;
}
.items-table tfoot td {
border-top: 2px solid #1e1e1e;
border-bottom: none;
padding-top: 16px;
}
.customer-info {
display: grid;
grid-template-columns: 1fr;
gap: 12px;
}
.customer-info > div {
display: flex;
flex-direction: column;
}
.customer-info dt {
font-size: 11px;
font-weight: 600;
color: #646970;
margin-bottom: 2px;
}
.customer-info dd {
font-size: 13px;
color: #1e1e1e;
}
.no-data {
color: #646970;
font-size: 13px;
}
.formipay-error,
.formipay-loading {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 60px 20px;
gap: 16px;
}
.formipay-error p {
color: #646970;
margin: 0;
}

View File

@@ -0,0 +1,251 @@
/**
* Order Detail - Complete order information view
*/
import { __ } from '@wordpress/i18n';
import { useState, useCallback, useEffect } from '@wordpress/element';
import { Button, SelectControl } from '@wordpress/components';
import { Icon, arrowLeft, trash } from '@wordpress/icons';
import { ordersApi } from '../../api/client';
import OrderTimeline from './OrderTimeline';
import './OrderDetail.css';
const STATUS_OPTIONS = [
{ value: 'on-hold', label: __('On Hold', 'formipay') },
{ value: 'payment-confirm', label: __('Payment Confirmed', 'formipay') },
{ value: 'in-progress', label: __('In Progress', 'formipay') },
{ value: 'shipping', label: __('Shipping', 'formipay') },
{ value: 'completed', label: __('Completed', 'formipay') },
{ value: 'failed', label: __('Failed', 'formipay') },
{ value: 'refunded', label: __('Refunded', 'formipay') },
{ value: 'cancelled', label: __('Cancelled', 'formipay') },
];
export default function OrderDetail({ orderId, onBack }) {
const [order, setOrder] = useState(null);
const [loading, setLoading] = useState(true);
const [updating, setUpdating] = useState(false);
const [newStatus, setNewStatus] = useState('');
const loadOrder = useCallback(() => {
setLoading(true);
ordersApi.get(orderId)
.then(result => {
if (result.data) {
setOrder(result.data);
setNewStatus(result.data.status);
}
})
.catch(error => {
console.error('Load order error:', error);
})
.finally(() => {
setLoading(false);
});
}, [orderId]);
useEffect(() => {
loadOrder();
}, [loadOrder]);
const handleStatusChange = () => {
if (!newStatus || newStatus === order.status) return;
setUpdating(true);
ordersApi.updateStatus(orderId, newStatus)
.then(result => {
if (result.success || result.data?.valid) {
loadOrder();
}
})
.catch(error => {
console.error('Update status error:', error);
})
.finally(() => {
setUpdating(false);
});
};
const handleDelete = () => {
if (!confirm(__('Are you sure you want to delete this order?', 'formipay'))) {
return;
}
ordersApi.delete([orderId])
.then(result => {
if (result.success) {
onBack?.();
}
})
.catch(error => {
console.error('Delete order error:', error);
});
};
if (loading) {
return (
<div className="formipay-order-detail">
<div className="formipay-loading">
<span className="spinner is-active" />
</div>
</div>
);
}
if (!order) {
return (
<div className="formipay-order-detail">
<div className="formipay-error">
<p>{ __('Order not found', 'formipay') }</p>
<Button
variant="secondary"
onClick={onBack}
>
<Icon icon={arrowLeft} size={16} />
{ __('Back to Orders', 'formipay') }
</Button>
</div>
</div>
);
}
const formatDate = (dateString) => {
if (!dateString) return '-';
return new Date(dateString).toLocaleString();
};
return (
<div className="formipay-order-detail">
<div className="formipay-detail-header">
<Button
variant="secondary"
onClick={onBack}
>
<Icon icon={arrowLeft} size={16} />
{ __('Back', 'formipay') }
</Button>
<h1>
{ __('Order', 'formipay') } #{ order.id }
</h1>
<div className="header-actions">
<Button
variant="secondary"
isDestructive
onClick={handleDelete}
>
<Icon icon={trash} size={16} />
{ __('Delete', 'formipay') }
</Button>
</div>
</div>
<div className="formipay-detail-content">
<div className="formipay-detail-main">
<div className="formipay-detail-card">
<h3>{ __('Order Details', 'formipay') }</h3>
<dl className="detail-list">
<div>
<dt>{ __('Status', 'formipay') }</dt>
<dd>
<SelectControl
value={newStatus}
options={STATUS_OPTIONS}
onChange={setNewStatus}
disabled={updating}
/>
{newStatus !== order.status && (
<Button
variant="primary"
size="small"
onClick={handleStatusChange}
disabled={updating}
isBusy={updating}
>
{ updating ? __('Updating...', 'formipay') : __('Update Status', 'formipay') }
</Button>
)}
</dd>
</div>
<div>
<dt>{ __('Date Created', 'formipay') }</dt>
<dd>{ formatDate(order.created_date) }</dd>
</div>
<div>
<dt>{ __('Form ID', 'formipay') }</dt>
<dd>{ order.form_id }</dd>
</div>
<div>
<dt>{ __('Payment Gateway', 'formipay') }</dt>
<dd>{ order.payment_gateway || '-' }</dd>
</div>
</dl>
</div>
<div className="formipay-detail-card">
<h3>{ __('Items', 'formipay') }</h3>
<table className="items-table">
<thead>
<tr>
<th>{ __('Item', 'formipay') }</th>
<th>{ __('Qty', 'formipay') }</th>
<th>{ __('Subtotal', 'formipay') }</th>
</tr>
</thead>
<tbody>
{order.items?.map((item, index) => (
<tr key={index}>
<td>
<strong>{ item.item }</strong>
{item.description && (
<small>{ item.description }</small>
)}
</td>
<td>{ item.qty || 1 }</td>
<td>{ item.subtotal_formatted || item.subtotal }</td>
</tr>
)) || (
<tr>
<td colSpan="3" className="text-center">
{ __('No items', 'formipay') }
</td>
</tr>
)}
</tbody>
<tfoot>
<tr>
<td colSpan="2"><strong>{ __('Total', 'formipay') }</strong></td>
<td><strong>{ order.total_formatted || order.total }</strong></td>
</tr>
</tfoot>
</table>
</div>
</div>
<div className="formipay-detail-sidebar">
<div className="formipay-detail-card">
<h3>{ __('Customer Information', 'formipay') }</h3>
{order.form_data ? (
<dl className="customer-info">
{Object.entries(order.form_data).map(([key, value]) => {
if (['payment', 'payment_gateway', 'coupon_code', 'qty'].includes(key)) {
return null;
}
return (
<div key={key}>
<dt>{ key.replace(/_/g, ' ') }</dt>
<dd>{ value?.value || value || '-' }</dd>
</div>
);
})}
</dl>
) : (
<p className="no-data">{ __('No customer data available', 'formipay') }</p>
)}
</div>
<OrderTimeline orderId={orderId} />
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,111 @@
.formipay-order-list {
display: flex;
flex-direction: column;
height: 100%;
}
.formipay-orders-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px 20px;
background: #fff;
border-bottom: 1px solid #e0e0e0;
}
.formipay-orders-header h2 {
margin: 0;
font-size: 18px;
font-weight: 600;
display: flex;
align-items: center;
gap: 10px;
}
.formipay-orders-header svg {
fill: #1e1e1e;
}
.order-count {
font-size: 13px;
color: #646970;
}
.formipay-orders-filters {
display: flex;
gap: 12px;
padding: 16px 20px;
background: #f6f7f7;
border-bottom: 1px solid #e0e0e0;
flex-wrap: wrap;
align-items: flex-end;
}
.formipay-orders-filters .components-base-control {
flex: 1;
min-width: 200px;
margin: 0;
}
.formipay-date-input {
padding: 6px 8px;
font-size: 13px;
border: 1px solid #8c8f94;
border-radius: 2px;
height: 30px;
}
.formipay-orders-table-wrapper {
flex: 1;
overflow-y: auto;
background: #fff;
}
.formipay-loading {
display: flex;
align-items: center;
justify-content: center;
padding: 60px 20px;
}
.formipay-no-results {
display: flex;
align-items: center;
justify-content: center;
padding: 60px 20px;
color: #646970;
}
.formipay-orders-table {
width: 100%;
border-collapse: collapse;
}
.formipay-orders-table thead th {
padding: 12px 16px;
text-align: left;
font-size: 13px;
font-weight: 600;
color: #1e1e1e;
border-bottom: 1px solid #e0e0e0;
}
.formipay-orders-table tbody td {
padding: 12px 16px;
font-size: 13px;
border-bottom: 1px solid #f0f0f1;
}
.formipay-pagination {
display: flex;
justify-content: center;
align-items: center;
gap: 16px;
padding: 16px;
border-top: 1px solid #e0e0e0;
}
.pagination-info {
font-size: 13px;
color: #646970;
}

View File

@@ -0,0 +1,196 @@
/**
* Order List - Main orders table with filters
*/
import { __ } from '@wordpress/i18n';
import { useState, useCallback, useEffect } from '@wordpress/element';
import { SearchControl, SelectControl, Button } from '@wordpress/components';
import { Icon, list } from '@wordpress/icons';
import { ordersApi } from '../../api/client';
import OrderListItem from './OrderListItem';
import './OrderList.css';
export default function OrderList({ onSelectOrder }) {
const [orders, setOrders] = useState([]);
const [loading, setLoading] = useState(true);
const [total, setTotal] = useState(0);
const [filters, setFilters] = useState({
keyword: '',
status: '',
date_from: '',
date_to: '',
});
const [pagination, setPagination] = useState({
limit: 20,
offset: 0,
});
const loadOrders = useCallback(() => {
setLoading(true);
ordersApi.list({
keyword: filters.keyword,
status: filters.status,
date_from: filters.date_from,
date_to: filters.date_to,
limit: pagination.limit,
offset: pagination.offset,
})
.then(result => {
if (result.data) {
setOrders(result.data.results || []);
setTotal(result.data.total || 0);
}
})
.catch(error => {
console.error('Load orders error:', error);
})
.finally(() => {
setLoading(false);
});
}, [filters, pagination]);
useEffect(() => {
loadOrders();
}, [loadOrders]);
const handleFilterChange = (key, value) => {
setFilters({ ...filters, [key]: value });
setPagination({ ...pagination, offset: 0 });
};
const handleSearch = (value) => {
handleFilterChange('keyword', value);
};
const handlePageChange = (newOffset) => {
setPagination({ ...pagination, offset: newOffset });
};
const statusOptions = {
'': __('All Statuses', 'formipay'),
'on-hold': __('On Hold', 'formipay'),
'payment-confirm': __('Payment Confirmed', 'formipay'),
'in-progress': __('In Progress', 'formipay'),
'shipping': __('Shipping', 'formipay'),
'completed': __('Completed', 'formipay'),
'failed': __('Failed', 'formipay'),
'refunded': __('Refunded', 'formipay'),
'cancelled': __('Cancelled', 'formipay'),
};
const totalPages = Math.ceil(total / pagination.limit);
const currentPage = Math.floor(pagination.offset / pagination.limit) + 1;
return (
<div className="formipay-order-list">
<div className="formipay-orders-header">
<h2>
<Icon icon={list} />
{ __('Orders', 'formipay') }
</h2>
<span className="order-count">
{ total } { __('orders', 'formipay') }
</span>
</div>
<div className="formipay-orders-filters">
<SearchControl
value={filters.keyword}
onChange={handleSearch}
placeholder={ __('Search by order ID, customer name, email...', 'formipay')}
/>
<SelectControl
value={filters.status}
options={Object.entries(statusOptions).map(([value, label]) => ({ value, label }))}
onChange={(value) => handleFilterChange('status', value)}
label={ __('Status', 'formipay')}
/>
<input
type="date"
value={filters.date_from}
onChange={(e) => handleFilterChange('date_from', e.target.value)}
className="formipay-date-input"
/>
<input
type="date"
value={filters.date_to}
onChange={(e) => handleFilterChange('date_to', e.target.value)}
className="formipay-date-input"
/>
{(filters.keyword || filters.status || filters.date_from || filters.date_to) && (
<Button
variant="secondary"
onClick={() => {
setFilters({ keyword: '', status: '', date_from: '', date_to: '' });
setPagination({ limit: 20, offset: 0 });
}}
>
{ __('Clear Filters', 'formipay') }
</Button>
)}
</div>
<div className="formipay-orders-table-wrapper">
{loading ? (
<div className="formipay-loading">
<span className="spinner is-active" />
</div>
) : orders.length === 0 ? (
<div className="formipay-no-results">
<p>{ __('No orders found', 'formipay') }</p>
</div>
) : (
<>
<table className="formipay-orders-table wp-list-table widefat fixed striped">
<thead>
<tr>
<th>{ __('ID', 'formipay') }</th>
<th>{ __('Date', 'formipay') }</th>
<th>{ __('Customer', 'formipay') }</th>
<th>{ __('Total', 'formipay') }</th>
<th>{ __('Status', 'formipay') }</th>
<th>{ __('Actions', 'formipay') }</th>
</tr>
</thead>
<tbody>
{orders.map(order => (
<OrderListItem
key={order.id}
order={order}
onSelect={() => onSelectOrder?.(order.id)}
/>
))}
</tbody>
</table>
{totalPages > 1 && (
<div className="formipay-pagination">
<Button
variant="secondary"
disabled={currentPage === 1}
onClick={() => handlePageChange(pagination.offset - pagination.limit)}
>
{ __('Previous', 'formipay') }
</Button>
<span className="pagination-info">
{ __('Page', 'formipay') } { currentPage } { __('of', 'formipay') } { totalPages }
</span>
<Button
variant="secondary"
disabled={currentPage === totalPages}
onClick={() => handlePageChange(pagination.offset + pagination.limit)}
>
{ __('Next', 'formipay') }
</Button>
</div>
)}
</>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,27 @@
.formipay-order-item {
transition: background 0.2s;
}
.formipay-order-item:hover {
background: #f6f7f7;
}
.status-badge {
display: inline-block;
padding: 4px 10px;
font-size: 11px;
font-weight: 600;
color: #fff;
border-radius: 12px;
text-transform: uppercase;
}
.formipay-order-item .button {
display: inline-flex;
align-items: center;
gap: 6px;
}
.formipay-order-item .button svg {
fill: currentColor;
}

View File

@@ -0,0 +1,87 @@
/**
* Order List Item - Single row in orders table
*/
import { __ } from '@wordpress/i18n';
import { Icon, seen } from '@wordpress/icons';
import './OrderListItem.css';
const STATUS_COLORS = {
'on-hold': '#f0ad4e',
'payment-confirm': '#17a2b8',
'in-progress': '#17a2b8',
'shipping': '#6c757d',
'completed': '#28a745',
'failed': '#dc3545',
'refunded': '#6c757d',
'cancelled': '#dc3545',
};
const STATUS_LABELS = {
'on-hold': __('On Hold', 'formipay'),
'payment-confirm': __('Payment Confirmed', 'formipay'),
'in-progress': __('In Progress', 'formipay'),
'shipping': __('Shipping', 'formipay'),
'completed': __('Completed', 'formipay'),
'failed': __('Failed', 'formipay'),
'refunded': __('Refunded', 'formipay'),
'cancelled': __('Cancelled', 'formipay'),
};
export default function OrderListItem({ order, onSelect }) {
const statusColor = STATUS_COLORS[order.status] || '#6c757d';
const statusLabel = STATUS_LABELS[order.status] || order.status;
const formatDate = (dateString) => {
if (!dateString) return '-';
const date = new Date(dateString);
return date.toLocaleDateString();
};
const getCustomerName = (order) => {
if (order.form_data) {
const nameField = Object.values(order.form_data).find(
field => field.name && field.name.includes('name')
);
return nameField?.value || '-';
}
return '-';
};
const customerName = getCustomerName(order);
return (
<tr className="formipay-order-item">
<td>
<strong>#{ order.id }</strong>
</td>
<td>
{ formatDate(order.created_date) }
</td>
<td>
{ customerName !== '-' ? customerName : <em>Unknown</em> }
</td>
<td>
<strong>{ order.total_formatted || order.total }</strong>
</td>
<td>
<span
className="status-badge"
style={{ backgroundColor: statusColor }}
>
{ statusLabel }
</span>
</td>
<td>
<button
type="button"
className="button button-small"
onClick={onSelect}
>
<Icon icon={seen} size={16} />
{ __('View', 'formipay') }
</button>
</td>
</tr>
);
}

View File

@@ -0,0 +1,116 @@
.formipay-order-timeline h3 {
margin: 0 0 16px;
font-size: 16px;
font-weight: 600;
color: #1e1e1e;
}
.timeline-progress {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 24px;
position: relative;
}
.timeline-step {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
position: relative;
z-index: 1;
flex: 1;
}
.timeline-dot {
width: 24px;
height: 24px;
background: #e0e0e0;
border: 2px solid #c3c4c7;
border-radius: 50%;
position: relative;
}
.timeline-step.completed .timeline-dot {
background: #2271b1;
border-color: #2271b1;
}
.timeline-step.completed .timeline-dot::after {
content: '';
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 8px;
height: 8px;
background: #fff;
border-radius: 50%;
}
.timeline-line {
position: absolute;
top: 12px;
left: 50%;
width: 100%;
height: 2px;
background: #e0e0e0;
z-index: -1;
}
.timeline-step.completed .timeline-line {
background: #2271b1;
}
.timeline-label {
font-size: 10px;
font-weight: 600;
color: #646970;
text-align: center;
text-transform: uppercase;
}
.timeline-events ul {
list-style: none;
margin: 0;
padding: 0;
}
.timeline-events li {
display: grid;
grid-template-columns: 1fr auto;
gap: 8px;
padding: 12px 0;
border-bottom: 1px solid #f0f0f1;
}
.timeline-events li:last-child {
border-bottom: none;
}
.event-status {
font-size: 13px;
font-weight: 600;
color: #1e1e1e;
}
.event-date {
font-size: 11px;
color: #646970;
text-align: right;
}
.event-note {
grid-column: 1 / -1;
font-size: 12px;
color: #646970;
margin-top: 4px;
}
.no-events {
color: #646970;
font-size: 13px;
text-align: center;
padding: 20px 0;
}

View File

@@ -0,0 +1,92 @@
/**
* Order Timeline - Status change history
*/
import { __ } from '@wordpress/i18n';
import { useState, useEffect } from '@wordpress/element';
import './OrderTimeline.css';
const STATUS_FLOW = [
'on-hold',
'payment-confirm',
'in-progress',
'shipping',
'completed',
];
const STATUS_LABELS = {
'on-hold': __('On Hold', 'formipay'),
'payment-confirm': __('Payment Confirmed', 'formipay'),
'in-progress': __('In Progress', 'formipay'),
'shipping': __('Shipping', 'formipay'),
'completed': __('Completed', 'formipay'),
'failed': __('Failed', 'formipay'),
'refunded': __('Refunded', 'formipay'),
'cancelled': __('Cancelled', 'formipay'),
};
export default function OrderTimeline({ orderId }) {
const [timeline, setTimeline] = useState([]);
useEffect(() => {
// Load timeline data - this would be from an API
// For now, we'll create a mock timeline based on status
const mockTimeline = [
{
status: 'on-hold',
date: new Date().toISOString(),
note: __('Order placed', 'formipay'),
},
];
setTimeline(mockTimeline);
}, [orderId]);
return (
<div className="formipay-order-timeline">
<h3>{ __('Order Timeline', 'formipay') }</h3>
<div className="timeline-progress">
{STATUS_FLOW.map((status, index) => (
<div
key={status}
className={`timeline-step ${index === 0 ? 'first' : ''} ${index === STATUS_FLOW.length - 1 ? 'last' : ''}`}
>
<div className="timeline-dot" />
{index < STATUS_FLOW.length - 1 && (
<div className="timeline-line" />
)}
<span className="timeline-label">
{ STATUS_LABELS[status] }
</span>
</div>
))}
</div>
<div className="timeline-events">
{timeline.length === 0 ? (
<p className="no-events">
{ __('No timeline events yet', 'formipay') }
</p>
) : (
<ul>
{timeline.map((event, index) => (
<li key={index}>
<span className="event-status">
{ STATUS_LABELS[event.status] || event.status }
</span>
<span className="event-date">
{ new Date(event.date).toLocaleString() }
</span>
{event.note && (
<span className="event-note">
{ event.note }
</span>
)}
</li>
))}
</ul>
)}
</div>
</div>
);
}

View File

@@ -1,14 +1,27 @@
/** /**
* Orders Page - Placeholder * Orders Page - List and detail view
*/ */
import { __ } from '@wordpress/i18n'; import { __ } from '@wordpress/i18n';
import { useState } from '@wordpress/element';
import OrderList from '../components/orders/OrderList';
import OrderDetail from '../components/orders/OrderDetail';
export default function OrdersPage({ initialData }) { export default function OrdersPage({ initialData }) {
const [selectedOrderId, setSelectedOrderId] = useState(null);
if (selectedOrderId) {
return (
<OrderDetail
orderId={selectedOrderId}
onBack={() => setSelectedOrderId(null)}
/>
);
}
return ( return (
<div className="formipay-page-formipay-page"> <OrderList
<h1>{ __('Orders', 'formipay') }</h1> onSelectOrder={(orderId) => setSelectedOrderId(orderId)}
<p>{ __('Page content coming soon...', 'formipay') }</p> />
</div>
); );
} }