diff --git a/admin-spa/src/routes/Orders/partials/OrderForm.tsx b/admin-spa/src/routes/Orders/partials/OrderForm.tsx index 20b8456..95315ce 100644 --- a/admin-spa/src/routes/Orders/partials/OrderForm.tsx +++ b/admin-spa/src/routes/Orders/partials/OrderForm.tsx @@ -2,6 +2,7 @@ type ProductSearchItem = { id: number; name: string; + type: string; price?: number | string | null; regular_price?: number | string | null; sale_price?: number | string | null; @@ -9,6 +10,16 @@ type ProductSearchItem = { stock?: number | null; virtual?: boolean; downloadable?: boolean; + variations?: { + id: number; + attributes: Record; + price: number; + regular_price: number; + sale_price: number | null; + sku: string; + stock: number | null; + in_stock: boolean; + }[]; }; import * as React from 'react'; import { makeMoneyFormatter, getStoreCurrency } from '@/lib/currency'; @@ -18,10 +29,12 @@ import { cn } from '@/lib/utils'; import { __ } from '@/lib/i18n'; import { toast } from 'sonner'; import { Input } from '@/components/ui/input'; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; +import { Drawer, DrawerContent, DrawerHeader, DrawerTitle, DrawerTrigger } from '@/components/ui/drawer'; +import { Badge } from '@/components/ui/badge'; import { Label } from '@/components/ui/label'; import { Textarea } from '@/components/ui/textarea'; import { Button } from '@/components/ui/button'; -import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; import { Checkbox } from '@/components/ui/checkbox'; import { SearchableSelect } from '@/components/ui/searchable-select'; @@ -40,8 +53,10 @@ export type ShippingMethod = { id: string; title: string; cost: number }; export type LineItem = { line_item_id?: number; // present in edit mode to update existing line product_id: number; + variation_id?: number; // for variable products qty: number; name?: string; + variation_name?: string; // e.g., "Color: Red" price?: number; virtual?: boolean; downloadable?: boolean; @@ -260,6 +275,9 @@ export default function OrderForm({ // --- Product search for Add Item --- const [searchQ, setSearchQ] = React.useState(''); const [customerSearchQ, setCustomerSearchQ] = React.useState(''); + const [selectedProduct, setSelectedProduct] = React.useState(null); + const [selectedVariationId, setSelectedVariationId] = React.useState(null); + const [showVariationDrawer, setShowVariationDrawer] = React.useState(false); const productsQ = useQuery({ queryKey: ['products', searchQ], queryFn: () => ProductsApi.search(searchQ), @@ -476,7 +494,16 @@ export default function OrderForm({ onChange={(val: string) => { const p = products.find((prod: ProductSearchItem) => String(prod.id) === val); if (!p) return; - if (items.find(x => x.product_id === p.id)) return; + + // If variable product, show variation selector + if (p.type === 'variable' && p.variations && p.variations.length > 0) { + setSelectedProduct(p); + setSelectedVariationId(null); + setShowVariationDrawer(true); + return; + } + + // Simple product - add directly (but allow duplicates for different quantities) setItems(prev => [ ...prev, { @@ -514,10 +541,13 @@ export default function OrderForm({ {items.map((it, idx) => ( - +
{it.name || `Product #${it.product_id}`}
+ {it.variation_name && ( +
{it.variation_name}
+ )} {typeof it.price === 'number' && (
{/* Show strike-through regular price if on sale */} @@ -568,7 +598,7 @@ export default function OrderForm({ @@ -589,10 +619,13 @@ export default function OrderForm({
{items.length ? ( items.map((it, idx) => ( -
+
{it.name || `Product #${it.product_id}`}
+ {it.variation_name && ( +
{it.variation_name}
+ )} {typeof it.price === 'number' && (
{money(Number(it.price))}
)} @@ -602,7 +635,7 @@ export default function OrderForm({ @@ -678,6 +711,89 @@ export default function OrderForm({
+ {/* Variation Selector Drawer (Mobile + Desktop) */} + {selectedProduct && selectedProduct.type === 'variable' && ( + + + + {selectedProduct.name} +

{__('Select a variation')}

+
+
+ {selectedProduct.variations?.map((variation) => { + const variationLabel = Object.entries(variation.attributes) + .map(([key, value]) => `${key}: ${value}`) + .join(', '); + + return ( + + ); + })} +
+
+
+ )} + {/* Coupons */} {showCoupons && (
diff --git a/includes/Api/OrdersController.php b/includes/Api/OrdersController.php index 262bdba..97d3765 100644 --- a/includes/Api/OrdersController.php +++ b/includes/Api/OrdersController.php @@ -1138,9 +1138,10 @@ class OrdersController { $prods = wc_get_products( $args ); $rows = array_map( function( $p ) { - return [ + $data = [ 'id' => $p->get_id(), 'name' => $p->get_name(), + 'type' => $p->get_type(), 'price' => (float) $p->get_price(), 'regular_price' => (float) $p->get_regular_price(), 'sale_price' => $p->get_sale_price() ? (float) $p->get_sale_price() : null, @@ -1148,7 +1149,41 @@ class OrdersController { 'stock' => $p->get_stock_quantity(), 'virtual' => $p->is_virtual(), 'downloadable' => $p->is_downloadable(), + 'variations' => [], ]; + + // If variable product, include variations + if ( $p->get_type() === 'variable' ) { + $variation_ids = $p->get_children(); + foreach ( $variation_ids as $variation_id ) { + $variation = wc_get_product( $variation_id ); + if ( ! $variation ) continue; + + // Get variation attributes + $attributes = []; + foreach ( $variation->get_variation_attributes() as $attr_name => $attr_value ) { + // Remove 'attribute_' prefix and format name + $clean_name = str_replace( 'attribute_', '', $attr_name ); + $clean_name = str_replace( 'pa_', '', $clean_name ); // Remove taxonomy prefix if present + $clean_name = ucfirst( str_replace( [ '-', '_' ], ' ', $clean_name ) ); + + $attributes[ $clean_name ] = $attr_value; + } + + $data['variations'][] = [ + 'id' => $variation->get_id(), + 'attributes' => $attributes, + 'price' => (float) $variation->get_price(), + 'regular_price' => (float) $variation->get_regular_price(), + 'sale_price' => $variation->get_sale_price() ? (float) $variation->get_sale_price() : null, + 'sku' => $variation->get_sku(), + 'stock' => $variation->get_stock_quantity(), + 'in_stock' => $variation->is_in_stock(), + ]; + } + } + + return $data; }, $prods ); return new WP_REST_Response( [ 'rows' => $rows ], 200 );