diff --git a/backlog/features.json b/backlog/features.json index 1f779f0..9353da3 100644 --- a/backlog/features.json +++ b/backlog/features.json @@ -4656,6 +4656,162 @@ "Shipped state allows tracking number", "verify.sh is green" ], + "status": "done", + "created_at": "2026-08-21", + "gates": { + "reviewer": true, + "security": true, + "qa": true, + "close": true + }, + "completed_at": "2026-08-21T07:27:55Z" + }, + { + "id": "F-107", + "type": "fix", + "title": "Reorder product editor form fields", + "problem": "Product editor form order is confusing and slug is manually editable", + "goal": "Slug auto-generated and disabled, featured star next to name, description right after name", + "scope_in": [ + "admin product editor general tab" + ], + "scope_out": [ + "no schema change", + "no redesign" + ], + "priority": "high", + "risk": "low", + "description": "Problem: Product editor form order is confusing and slug is manually editable. Goal: Slug auto-generated and disabled, featured star next to name, description right after name. Scope IN: admin product editor general tab. Scope OUT: no schema change, no redesign. Type: fix. Priority: high. Risk: low.", + "acceptance": [ + "Slug auto-generated from name and input disabled", + "Featured star checkbox sits next to product name", + "Description follows name in form order", + "verify.sh is green" + ], + "status": "pending", + "created_at": "2026-08-21", + "gates": { + "reviewer": false, + "security": false, + "qa": false + } + }, + { + "id": "F-108", + "type": "fix", + "title": "Remove variants module: price stock EAN per product in General tab", + "problem": "Variants UI is useless because each variant is created as a separate product", + "goal": "Hide variant management, manage price stock EAN per product, show price and stock section in General tab between name and description", + "scope_in": [ + "admin product editor", + "inventory", + "publish tab" + ], + "scope_out": [ + "no schema deletion", + "keep one internal variant per product" + ], + "priority": "high", + "risk": "med", + "description": "Problem: Variants UI is useless because each variant is created as a separate product. Goal: Hide variant management, manage price stock EAN per product, show price and stock section in General tab between name and description. Scope IN: admin product editor, inventory, publish tab. Scope OUT: no schema deletion, keep one internal variant per product. Type: fix. Priority: high. Risk: med.", + "acceptance": [ + "No variant creation UI remains in admin", + "Price stock EAN editable per product in General tab between name and description", + "Inventory lists products not variants", + "verify.sh is green" + ], + "status": "pending", + "created_at": "2026-08-21", + "gates": { + "reviewer": false, + "security": false, + "qa": false + } + }, + { + "id": "F-109", + "type": "fix", + "title": "Auto-generate SKU as SKU-MV-product-id and hide it from editor", + "problem": "SKU is internal only but operators must still see and type it", + "goal": "Generate SKU automatically as SKU-MV-[product_id], keep it internal, remove SKU input from product editor", + "scope_in": [ + "catalog", + "admin product editor", + "inventory display" + ], + "scope_out": [ + "no SKU editability", + "no external service" + ], + "priority": "high", + "risk": "low", + "description": "Problem: SKU is internal only but operators must still see and type it. Goal: Generate SKU automatically as SKU-MV-[product_id], keep it internal, remove SKU input from product editor. Scope IN: catalog, admin product editor, inventory display. Scope OUT: no SKU editability, no external service. Type: fix. Priority: high. Risk: low.", + "acceptance": [ + "New product gets SKU SKU-MV-[product_id] without operator input", + "Product editor shows no SKU field", + "Supersedes F-100 title-based SKU idea", + "verify.sh is green" + ], + "status": "pending", + "created_at": "2026-08-21", + "gates": { + "reviewer": false, + "security": false, + "qa": false + } + }, + { + "id": "F-110", + "type": "feature", + "title": "Inventory: expiration date column, expiry and margin filters", + "problem": "Inventory shows internal SKU column and lacks expiry and profit-margin insight", + "goal": "Replace SKU column with expiration date, add expiring-soon filter under 30 days sorted ASC, add margin column and low-margin filter under 30 percent sorted ASC", + "scope_in": [ + "admin inventory list" + ], + "scope_out": [ + "no pricing redesign" + ], + "priority": "high", + "risk": "med", + "description": "Problem: Inventory shows internal SKU column and lacks expiry and profit-margin insight. Goal: Replace SKU column with expiration date, add expiring-soon filter under 30 days sorted ASC, add margin column and low-margin filter under 30 percent sorted ASC. Scope IN: admin inventory list. Scope OUT: no pricing redesign. Type: feature. Priority: high. Risk: med.", + "acceptance": [ + "SKU column removed and expiration date column shown", + "Filter shows items expiring in under 30 days sorted by date ASC", + "Margin column shows profit margin percent", + "Filter shows items with margin under 30 percent sorted ASC", + "verify.sh is green" + ], + "status": "pending", + "created_at": "2026-08-21", + "gates": { + "reviewer": false, + "security": false, + "qa": false + } + }, + { + "id": "F-111", + "type": "feature", + "title": "Shipping management in order editing with tracking history", + "problem": "Order editing tab has no shipping management and tracking number leaves no history record", + "goal": "Manage shipping in order edit tab, register tracking number changes in order history", + "scope_in": [ + "admin order detail", + "orders module history" + ], + "scope_out": [ + "no carrier integrations" + ], + "priority": "high", + "risk": "med", + "description": "Problem: Order editing tab has no shipping management and tracking number leaves no history record. Goal: Manage shipping in order edit tab, register tracking number changes in order history. Scope IN: admin order detail, orders module history. Scope OUT: no carrier integrations. Type: feature. Priority: high. Risk: med.", + "acceptance": [ + "Shipping section editable in order edit tab", + "Every tracking number change recorded in order history", + "History visible in order detail", + "verify.sh is green" + ], "status": "pending", "created_at": "2026-08-21", "gates": { diff --git a/project/apps/admin/src/app/(dashboard)/orders/[id]/page.tsx b/project/apps/admin/src/app/(dashboard)/orders/[id]/page.tsx index 3ac7ecc..70eea72 100644 --- a/project/apps/admin/src/app/(dashboard)/orders/[id]/page.tsx +++ b/project/apps/admin/src/app/(dashboard)/orders/[id]/page.tsx @@ -1,9 +1,9 @@ 'use client'; import { useState, useEffect, useCallback } from 'react'; -import { useParams, useRouter } from 'next/navigation'; +import { useParams } from 'next/navigation'; import Link from 'next/link'; -import type { Order, OrderState } from '@/types'; -import { ordersApi } from '@/lib/api-client'; +import type { Order, OrderState, Product } from '@/types'; +import { ordersApi, productsApi } from '@/lib/api-client'; const STATE_LABELS: Record = { PENDING: 'Pendiente', @@ -53,19 +53,39 @@ const ACTION_LABELS: Record = { PARTIALLY_REFUNDED: 'Reembolso parcial', }; +interface DraftItem { + variantId: string; + productId: string; + sku: string; + name: string; + quantity: number; +} + function formatPrice(cents: number) { return `€${(cents / 100).toFixed(2)}`; } export default function OrderDetailPage() { const { id } = useParams<{ id: string }>(); - const router = useRouter(); const [order, setOrder] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(''); const [transitioning, setTransitioning] = useState(false); const [showConfirm, setShowConfirm] = useState(null); const [confirmReason, setConfirmReason] = useState(''); + const [trackingInput, setTrackingInput] = useState(''); + const [notification, setNotification] = useState<{ ok: boolean; message: string } | null>(null); + + // Edición de artículos + const [editing, setEditing] = useState(false); + const [draftItems, setDraftItems] = useState([]); + const [savingItems, setSavingItems] = useState(false); + const [itemsError, setItemsError] = useState(''); + const [productQuery, setProductQuery] = useState(''); + const [searchResults, setSearchResults] = useState([]); + const [searching, setSearching] = useState(false); + const [addingProductId, setAddingProductId] = useState(null); + const [addingVariants, setAddingVariants] = useState<{ id: string; sku: string }[]>([]); const load = useCallback(async () => { setLoading(true); @@ -83,12 +103,22 @@ export default function OrderDetailPage() { useEffect(() => { load(); }, [load]); const handleTransition = async (nextState: OrderState) => { + if (nextState === 'SHIPPED' && !trackingInput.trim()) return; setTransitioning(true); try { - const updated = await ordersApi.transition(id, nextState); + const updated = await ordersApi.transition(id, nextState, trackingInput); setOrder(updated); setShowConfirm(null); setConfirmReason(''); + setTrackingInput(''); + if (updated.notified) { + setNotification({ ok: true, message: 'Cliente notificado por email.' }); + } else { + setNotification({ + ok: false, + message: updated.notificationError || 'No se pudo notificar al cliente por email.', + }); + } } catch (err) { alert(err instanceof Error ? err.message : 'Error al cambiar estado'); } finally { @@ -96,6 +126,108 @@ export default function OrderDetailPage() { } }; + const startEditing = () => { + if (!order) return; + setDraftItems( + order.items.map((item) => ({ + variantId: item.variantId, + productId: item.productId, + sku: item.sku, + name: item.name, + quantity: item.quantity, + })), + ); + setItemsError(''); + setEditing(true); + }; + + const updateDraftQuantity = (variantId: string, quantity: number) => { + setDraftItems((prev) => + prev.map((item) => + item.variantId === variantId + ? { ...item, quantity: Math.max(1, Math.min(999, quantity || 1)) } + : item, + ), + ); + }; + + const removeDraftItem = (variantId: string) => { + setDraftItems((prev) => prev.filter((item) => item.variantId !== variantId)); + }; + + const searchProducts = useCallback(async (query: string) => { + setProductQuery(query); + if (query.trim().length < 2) { + setSearchResults([]); + return; + } + setSearching(true); + try { + const result = await productsApi.list({ q: query.trim(), limit: 8 }); + setSearchResults(result.items ?? []); + } catch { + setSearchResults([]); + } finally { + setSearching(false); + } + }, []); + + const pickProductForAdd = async (product: Product) => { + setAddingProductId(product.id); + setAddingVariants([]); + try { + const result = await productsApi.getVariants(product.id); + const variants = (result.items ?? []).map((variant) => ({ id: variant.id, sku: variant.sku })); + if (variants.length === 1) { + addDraftItem(variants[0].id, product.id, variants[0].sku, product.name); + setAddingProductId(null); + } else { + setAddingVariants(variants); + } + } catch (err) { + setItemsError(err instanceof Error ? err.message : 'Error al cargar variantes'); + setAddingProductId(null); + } + }; + + const addDraftItem = (variantId: string, productId: string, sku: string, name: string) => { + setDraftItems((prev) => { + const existing = prev.find((item) => item.variantId === variantId); + if (existing) { + return prev.map((item) => + item.variantId === variantId ? { ...item, quantity: Math.min(999, item.quantity + 1) } : item, + ); + } + return [...prev, { variantId, productId, sku, name, quantity: 1 }]; + }); + setSearchResults([]); + setProductQuery(''); + setAddingProductId(null); + setAddingVariants([]); + setItemsError(''); + }; + + const saveItems = async () => { + if (draftItems.length === 0) { + setItemsError('El pedido debe tener al menos un artículo'); + return; + } + setSavingItems(true); + setItemsError(''); + try { + const updated = await ordersApi.editItems( + id, + draftItems.map((item) => ({ variantId: item.variantId, quantity: item.quantity })), + ); + setOrder(updated); + setEditing(false); + } catch (err) { + setItemsError(err instanceof Error ? err.message : 'Error al guardar artículos'); + } finally { + setSavingItems(false); + } + }; + if (loading) { return (
@@ -117,6 +249,7 @@ export default function OrderDetailPage() { const currentState = order.state as OrderState; const allowed = ALLOWED_TRANSITIONS[currentState] ?? []; + const itemsEditable = currentState !== 'CANCELLED' && currentState !== 'REFUNDED'; return (
@@ -142,6 +275,20 @@ export default function OrderDetailPage() {
+ {/* Notification result */} + {notification && ( +
+ {notification.message} + +
+ )} +
{/* Main content */}
@@ -153,7 +300,7 @@ export default function OrderDetailPage() { {allowed.map((next) => ( + )} +
+ + {!editing ? ( +
+ {order.items.map((item) => ( +
+
+

{item.name}

+

+ {item.quantity} × {formatPrice(item.unitPriceCents)} + {item.discountCents > 0 && ` (-${formatPrice(item.discountCents)})`} +

+
+

+ {formatPrice((item.unitPriceCents - item.discountCents) * item.quantity)}

-

- {formatPrice((item.unitPriceCents - item.discountCents) * item.quantity)} -

+ ))} +
+ ) : ( +
+
+ {draftItems.map((item) => ( +
+
+

{item.name}

+

{item.sku}

+
+ updateDraftQuantity(item.variantId, Number(e.target.value))} + className="w-20 px-2 py-1.5 border border-gray-300 rounded-lg text-sm text-right focus:ring-2 focus:ring-[#2D6A4F] outline-none" + /> + +
+ ))} + {draftItems.length === 0 && ( +

Sin artículos. Añade al menos uno.

+ )}
- ))} -
+ + {/* Add product */} +
+ + { searchProducts(e.target.value); }} + placeholder="Buscar por nombre o SKU (mín. 2 caracteres)…" + className="mt-1 w-full px-3 py-2 border border-gray-300 rounded-xl text-sm focus:ring-2 focus:ring-[#2D6A4F] outline-none" + /> + {searching &&

Buscando…

} + {!searching && searchResults.length > 0 && ( +
    + {searchResults.map((product) => ( +
  • + {addingProductId === product.id && addingVariants.length > 0 ? ( +
    +

    {product.name}

    +
    + {addingVariants.map((variant) => ( + + ))} +
    +
    + ) : ( + + )} +
  • + ))} +
+ )} +
+ + {itemsError &&

{itemsError}

} + +
+ + +
+

+ Los precios e IVA se recalculan con las tarifas vigentes al guardar. +

+
+ )}
@@ -211,6 +470,12 @@ export default function OrderDetailPage() { {formatPrice(order.totalCents)} + {order.trackingNumber && ( +
+

Seguimiento

+

{order.trackingNumber}

+
+ )} {/* Timeline */} @@ -239,8 +504,23 @@ export default function OrderDetailPage() { Confirmar cambio de estado

- ¿{ACTION_LABELS[showConfirm] ?? showConfirm}? + ¿{ACTION_LABELS[showConfirm] ?? showConfirm}? Se notificará al cliente por email.

+ {showConfirm === 'SHIPPED' && ( +
+ + setTrackingInput(e.target.value)} + placeholder="p. ej. PQ123456789ES" + maxLength={120} + className="w-full px-3 py-2 border border-gray-300 rounded-xl text-sm focus:ring-2 focus:ring-[#2D6A4F] outline-none" + /> +
+ )} {(showConfirm === 'CANCELLED' || showConfirm === 'REFUNDED') && (