feat(F-094): completed feature

This commit is contained in:
chattie
2026-08-20 22:01:54 +02:00
parent c56037492d
commit 418bdca7f4
14 changed files with 305 additions and 58 deletions

View File

@@ -4230,6 +4230,40 @@
"close": true "close": true
}, },
"completed_at": "2026-08-20T19:57:43Z" "completed_at": "2026-08-20T19:57:43Z"
},
{
"id": "F-094",
"type": "feature",
"title": "Create and explain product variants from Publish tab",
"problem": "Pricing and Inventory show a warning that variants are created from the Publish tab, but Publish only contains state controls and no variant UI. Operators do not know what a variant is or how to create the SKU/EAN records required for stock and pricing.",
"goal": "Add a clear variant explanation and a create-variant form to Publish with SKU and optional EAN, plus a list of existing variants. Use the existing product variant API and refresh the list after creation.",
"scope_in": [
"admin ProductEditor Publish tab and variant management"
],
"scope_out": [
"No database or backend API redesign",
"no changes to variant uniqueness rules"
],
"priority": "high",
"risk": "low",
"description": "Problem: Pricing and Inventory show a warning that variants are created from the Publish tab, but Publish only contains state controls and no variant UI. Operators do not know what a variant is or how to create the SKU/EAN records required for stock and pricing.. Goal: Add a clear variant explanation and a create-variant form to Publish with SKU and optional EAN, plus a list of existing variants. Use the existing product variant API and refresh the list after creation.. Scope IN: admin ProductEditor Publish tab and variant management. Scope OUT: No database or backend API redesign, no changes to variant uniqueness rules. Type: feature. Priority: high. Risk: low.",
"acceptance": [
"- Publish tab explains that a variant is a sellable presentation of a product with its own SKU/EAN",
"- Publish tab lets an operator create a variant with required SKU and optional EAN",
"- Successful creation shows the new variant in the list without a page reload",
"- Duplicate SKU/EAN errors are shown clearly",
"- Pricing and Inventory no longer point to a nonexistent variant workflow",
"- Admin typecheck/lint and verify.sh pass"
],
"status": "done",
"created_at": "2026-08-20",
"gates": {
"reviewer": true,
"security": true,
"qa": true,
"close": true
},
"completed_at": "2026-08-20T20:01:54Z"
} }
] ]
} }

View File

@@ -6,6 +6,7 @@ import { productsApi, brandsApi, categoriesApi } from '@/lib/api-client';
import { ImagesSection } from './sections/ImagesSection'; import { ImagesSection } from './sections/ImagesSection';
import { InventorySection } from './sections/InventorySection'; import { InventorySection } from './sections/InventorySection';
import { PricingSection } from './sections/PricingSection'; import { PricingSection } from './sections/PricingSection';
import { VariantManager } from './sections/VariantManager';
import LexicalEditor from '@/features/cms/components/LexicalEditor'; import LexicalEditor from '@/features/cms/components/LexicalEditor';
interface ProductEditorProps { interface ProductEditorProps {
@@ -408,6 +409,13 @@ export function ProductEditor({ productId }: ProductEditorProps) {
{/* ── PUBLISH ── */} {/* ── PUBLISH ── */}
{tab === 'publish' && ( {tab === 'publish' && (
<section className="space-y-5"> <section className="space-y-5">
{productId ? (
<VariantManager productId={productId} />
) : (
<div className="p-6 bg-amber-50 border border-amber-200 rounded-xl text-sm text-amber-800">
Guarda primero el producto para crear variantes.
</div>
)}
<div> <div>
<label className="block text-sm font-semibold text-gray-900 mb-3">Estado del producto</label> <label className="block text-sm font-semibold text-gray-900 mb-3">Estado del producto</label>
<div className="grid grid-cols-3 gap-3"> <div className="grid grid-cols-3 gap-3">

View File

@@ -407,7 +407,7 @@ export function InventorySection({ productId }: InventorySectionProps) {
<p className="text-4xl mb-3">📦</p> <p className="text-4xl mb-3">📦</p>
<p className="text-gray-500 text-sm">Este producto no tiene variantes</p> <p className="text-gray-500 text-sm">Este producto no tiene variantes</p>
<p className="text-gray-400 text-xs mt-1"> <p className="text-gray-400 text-xs mt-1">
Las variantes se crean desde la pestaña Publicar Ve a Publicar para crear la primera variante
</p> </p>
</div> </div>
); );

View File

@@ -114,7 +114,7 @@ export function PricingSection({ productId }: { productId: string }) {
if (variants.length === 0) { if (variants.length === 0) {
return ( return (
<div className="p-6 bg-amber-50 border border-amber-200 rounded-xl text-sm text-amber-800"> <div className="p-6 bg-amber-50 border border-amber-200 rounded-xl text-sm text-amber-800">
Este producto no tiene variantes. Las variantes se crean desde la pestaña Publicar. Este producto no tiene variantes. Ve a Publicar para crear la primera variante.
</div> </div>
); );
} }

View File

@@ -0,0 +1,127 @@
'use client';
import { useCallback, useEffect, useState } from 'react';
import { ApiError, type ProductVariant } from '@/types';
import { productsApi } from '@/lib/api-client';
interface VariantManagerProps {
productId: string;
}
export function VariantManager({ productId }: VariantManagerProps) {
const [variants, setVariants] = useState<ProductVariant[]>([]);
const [sku, setSku] = useState('');
const [ean, setEan] = useState('');
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [error, setError] = useState('');
const [message, setMessage] = useState('');
const load = useCallback(async () => {
setLoading(true);
try {
const result = await productsApi.getVariants(productId);
setVariants(result.items ?? []);
} catch {
setError('No se pudieron cargar las variantes.');
} finally {
setLoading(false);
}
}, [productId]);
useEffect(() => { load(); }, [load]);
const createVariant = async () => {
const normalizedSku = sku.trim();
const normalizedEan = ean.trim();
if (!normalizedSku) {
setError('El SKU es obligatorio.');
return;
}
setSaving(true);
setError('');
setMessage('');
try {
const created = await productsApi.createVariant(productId, {
sku: normalizedSku,
ean: normalizedEan || null,
});
setVariants((prev) => [...prev, created]);
setSku('');
setEan('');
setMessage('Variante creada correctamente.');
} catch (cause) {
setError(
cause instanceof ApiError && cause.statusCode === 409
? 'El SKU o EAN ya existe en otra variante.'
: 'No se pudo crear la variante.',
);
} finally {
setSaving(false);
}
};
return (
<div className="space-y-5">
<div className="p-4 bg-blue-50 border border-blue-100 rounded-xl text-sm text-blue-800">
<p className="font-semibold mb-1">¿Qué es una variante?</p>
<p>
Es una presentación vendible del producto. Cada formato, tamaño o referencia tiene su propio
SKU y, opcionalmente, EAN; sobre la variante se gestionan precios, stock e inventario.
</p>
</div>
<div className="p-5 bg-white border border-gray-200 rounded-xl space-y-4">
<div>
<h3 className="font-semibold text-gray-900">Crear variante</h3>
<p className="text-xs text-gray-500 mt-1">Por ejemplo, un formato o tamaño distinto del mismo producto.</p>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<label className="text-sm font-medium text-gray-700">
SKU <span className="text-red-500">*</span>
<input
value={sku}
onChange={(event) => setSku(event.target.value)}
placeholder="Ej: PROTEINA-GUISANTE-500G"
className="mt-1 w-full px-3 py-2.5 border border-gray-300 rounded-lg text-sm font-mono focus:ring-2 focus:ring-[#2D6A4F] outline-none"
/>
</label>
<label className="text-sm font-medium text-gray-700">
EAN <span className="text-gray-400 font-normal">(opcional)</span>
<input
value={ean}
onChange={(event) => setEan(event.target.value)}
placeholder="Código de barras"
className="mt-1 w-full px-3 py-2.5 border border-gray-300 rounded-lg text-sm font-mono focus:ring-2 focus:ring-[#2D6A4F] outline-none"
/>
</label>
</div>
<button
type="button"
onClick={createVariant}
disabled={saving}
className="px-4 py-2 bg-[#2D6A4F] text-white text-sm font-semibold rounded-lg hover:bg-[#1B4332] disabled:opacity-50"
>
{saving ? 'Creando...' : 'Crear variante'}
</button>
{error && <p className="text-sm text-red-600">{error}</p>}
{message && <p className="text-sm text-green-600">{message}</p>}
</div>
<div className="p-5 bg-white border border-gray-200 rounded-xl">
<h3 className="font-semibold text-gray-900 mb-3">Variantes existentes</h3>
{loading ? <p className="text-sm text-gray-400">Cargando...</p> : variants.length === 0 ? (
<p className="text-sm text-gray-500">Todavía no hay variantes. Crea la primera arriba.</p>
) : (
<div className="space-y-2">
{variants.map((variant) => (
<div key={variant.id} className="flex items-center justify-between px-3 py-2 bg-gray-50 rounded-lg text-sm">
<span className="font-mono text-gray-700">{variant.sku}</span>
<span className="font-mono text-gray-500">{variant.ean ?? 'Sin EAN'}</span>
</div>
))}
</div>
)}
</div>
</div>
);
}

View File

@@ -75,6 +75,8 @@ export const productsApi = {
get: (id: string) => api.get<import('@/types').Product>(`/api/products/${id}`), get: (id: string) => api.get<import('@/types').Product>(`/api/products/${id}`),
getVariants: (id: string) => getVariants: (id: string) =>
api.get<{ items: import('@/types').ProductVariant[] }>(`/api/products/${id}/variants`), api.get<{ items: import('@/types').ProductVariant[] }>(`/api/products/${id}/variants`),
createVariant: (productId: string, data: { sku: string; ean?: string | null }) =>
api.post<import('@/types').ProductVariant>(`/api/products/${productId}/variants`, data),
create: (data: unknown) => api.post<import('@/types').Product>('/api/products', data), create: (data: unknown) => api.post<import('@/types').Product>('/api/products', data),
update: (id: string, data: unknown) => update: (id: string, data: unknown) =>
api.patch<import('@/types').Product>(`/api/products/${id}`, data), api.patch<import('@/types').Product>(`/api/products/${id}`, data),

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,14 @@
# F-094 — Implementer evidence
## Changes
- Added `VariantManager` to the Publish tab with a plain-language explanation of variants, SKU, and EAN.
- Added SKU-required/EAN-optional variant creation using the existing `POST /products/:id/variants` API.
- Added existing variant list and inline success/conflict feedback.
- Added `productsApi.createVariant`.
- Updated Pricing and Inventory empty states to point to the now-existing Publish workflow.
## Validation
- Admin `npx tsc --noEmit` → exit 0
- Admin ESLint on touched files → 0 errors (pre-existing warnings only)

View File

@@ -0,0 +1,14 @@
{
"feature_id": "F-094",
"agent": "leader",
"verdict": "APPROVED",
"summary": "F-094 adds an understandable variant creation workflow to Publish and fixes the misleading empty-state guidance.",
"evidence": [
"reviewer.json verdict=APPROVED",
"security.json verdict=APPROVED",
"qa.json verdict=APPROVED",
"Root tests: 133 passed, 56 skipped",
"scripts/verify.sh exit 0"
],
"timestamp": "2026-08-20T20:01:50Z"
}

View File

@@ -0,0 +1,13 @@
{
"feature_id": "F-094",
"agent": "qa",
"verdict": "APPROVED",
"summary": "Publish variant management passes admin checks, the root test suite, and harness verification.",
"evidence": [
"Admin npx tsc --noEmit exit 0",
"Admin ESLint touched files exit 0 with warnings only",
"Root tests: 133 passed, 56 skipped",
"scripts/verify.sh exit 0"
],
"timestamp": "2026-08-20T20:01:40Z"
}

View File

@@ -0,0 +1,14 @@
{
"feature_id": "F-094",
"agent": "reviewer",
"verdict": "APPROVED",
"summary": "Publish now contains a clear variant workflow backed by the existing create/list endpoints, and empty states point to a real action.",
"evidence": [
"VariantManager explains sellable variants, SKU, and EAN",
"Create form uses SKU required and EAN optional",
"Successful creation updates the list without reload",
"Duplicate API conflicts are shown to the operator",
"Pricing and Inventory messages no longer reference a missing UI"
],
"timestamp": "2026-08-20T20:01:10Z"
}

View File

@@ -0,0 +1,13 @@
{
"feature_id": "F-094",
"agent": "security",
"verdict": "APPROVED",
"summary": "The UI uses the existing authenticated admin variant endpoint; server-side SKU/EAN validation and uniqueness remain authoritative.",
"evidence": [
"No new backend route or authorization bypass",
"SKU is required in the UI and backend schema validates all fields",
"Duplicate SKU/EAN remains rejected by database constraints",
"No secrets, unsafe HTML, or dependencies added"
],
"timestamp": "2026-08-20T20:01:20Z"
}

View File

@@ -1,14 +1,22 @@
# Feature actual # Feature actual
## Feature activa: F-093 (in_progress) — Inventory search by product name or EAN ## Feature activa: F-094 (in_progress) — Create and explain product variants from Publish tab
Backlog: 161 features (151 done, 9 pending, 1 in_progress). Backlog: 162 features (152 done, 9 pending, 1 in_progress).
Últimas features cerradas: **F-080**, **F-081**, **F-082**, **F-083**, **F-084**, **F-085**, **F-086**, **F-087**. Últimas features cerradas: **F-080**, **F-081**, **F-082**, **F-083**, **F-084**, **F-085**, **F-086**, **F-087**.
## Incidencia actual (2026-08-20) ## Incidencia actual (2026-08-20)
La búsqueda de Inventario envía `q`, pero el listado admin solo filtra por nombre de producto. F-093 añade coincidencias por EAN de variante y actualiza el placeholder a EAN o nombre. Precios e Inventario indican que las variantes se crean desde Publicar, pero Publicar no ofrece creación ni explicación. F-094 añade la gestión de variantes allí.
## Última incidencia resuelta (2026-08-20)
F-093 cerrada con todos los gates aprobados. Inventario busca ahora por EAN o nombre de producto.
## Incidencia anterior (2026-08-20)
La búsqueda de Inventario enviaba `q`, pero el listado admin solo filtraba por nombre de producto.
## Última incidencia resuelta (2026-08-20) ## Última incidencia resuelta (2026-08-20)

View File

@@ -1,62 +1,13 @@
{ {
"feature_id": "F-093", "feature_id": "F-094",
"stage": "close", "stage": "close",
"agent": "leader", "agent": "leader",
"action": "Validate F-093 gates and close inventory EAN/name search", "action": "Validate F-094 gates and close variant workflow",
"state": "running", "state": "running",
"next_agent": "leader", "next_agent": "leader",
"waiting_for": "verify.sh green", "waiting_for": "verify.sh green",
"updated_at": "2026-08-20T19:57:34Z", "updated_at": "2026-08-20T20:01:42Z",
"timeline": [ "timeline": [
{
"ts": "2026-08-20T19:44:59Z",
"agent": "leader",
"stage": "intake",
"state": "running",
"message": "Triage repeated variant PATCH 409 and opaque SKU/EAN conflict feedback"
},
{
"ts": "2026-08-20T19:45:13Z",
"agent": "implementer",
"stage": "build",
"state": "running",
"message": "Add field-specific SKU/EAN conflict messages and guard duplicate in-flight saves"
},
{
"ts": "2026-08-20T19:46:54Z",
"agent": "reviewer",
"stage": "review_gate",
"state": "running",
"message": "Review duplicate variant conflict mapping and in-flight save guard"
},
{
"ts": "2026-08-20T19:47:03Z",
"agent": "security",
"stage": "security_gate",
"state": "running",
"message": "Check F-091 uniqueness enforcement and error disclosure"
},
{
"ts": "2026-08-20T19:47:11Z",
"agent": "qa",
"stage": "qa_gate",
"state": "running",
"message": "Run F-091 typecheck, tests, lint, and verify"
},
{
"ts": "2026-08-20T19:47:22Z",
"agent": "leader",
"stage": "close",
"state": "running",
"message": "Validate F-091 gates and close SKU/EAN conflict fix"
},
{
"ts": "2026-08-20T19:47:40Z",
"agent": "leader",
"stage": "close",
"state": "done",
"message": "F-091 cerrado: conflictos SKU/EAN identificados y doble envío prevenido"
},
{ {
"ts": "2026-08-20T19:48:12Z", "ts": "2026-08-20T19:48:12Z",
"agent": "leader", "agent": "leader",
@@ -147,6 +98,55 @@
"stage": "close", "stage": "close",
"state": "running", "state": "running",
"message": "Validate F-093 gates and close inventory EAN/name search" "message": "Validate F-093 gates and close inventory EAN/name search"
},
{
"ts": "2026-08-20T19:58:08Z",
"agent": "leader",
"stage": "close",
"state": "done",
"message": "F-093 cerrado: búsqueda de Inventario por EAN o nombre"
},
{
"ts": "2026-08-20T19:58:26Z",
"agent": "leader",
"stage": "intake",
"state": "running",
"message": "Triage missing variant creation UI and explanation in Publish tab"
},
{
"ts": "2026-08-20T19:58:57Z",
"agent": "implementer",
"stage": "build",
"state": "running",
"message": "Add Publish variant manager, API create method, and clear empty-state guidance"
},
{
"ts": "2026-08-20T20:00:59Z",
"agent": "reviewer",
"stage": "review_gate",
"state": "running",
"message": "Review Publish variant creation UX and API integration"
},
{
"ts": "2026-08-20T20:01:10Z",
"agent": "security",
"stage": "security_gate",
"state": "running",
"message": "Check variant creation validation, uniqueness, and authorization"
},
{
"ts": "2026-08-20T20:01:22Z",
"agent": "qa",
"stage": "qa_gate",
"state": "running",
"message": "Run variant manager typecheck, tests, lint, and verify"
},
{
"ts": "2026-08-20T20:01:42Z",
"agent": "leader",
"stage": "close",
"state": "running",
"message": "Validate F-094 gates and close variant workflow"
} }
] ]
} }