feat(F-123): completed feature

This commit is contained in:
chattie
2026-08-21 17:36:50 +02:00
parent 7da9cad664
commit 3058c81b31
19 changed files with 378 additions and 63 deletions

View File

@@ -5218,6 +5218,23 @@
"close": true
},
"completed_at": "2026-08-21T13:32:24Z"
},
{
"id": "F-123",
"type": "fix",
"title": "Remove 'Save product first' warning for single-variant products",
"description": "F-121 dejó en ProductEditor.tsx los textos 'Guarda primero el producto para configurar precio, stock y EAN.' (línea 286) y 'Guarda primero el producto para subir imágenes.' (línea 374). El bundle servido en :3004 sigue mostrando la restricción cuando productId es undefined (modo creación). Como todos los productos son single-variant, la restricción no aporta valor. Fix: eliminar los avisos y permitir que PriceStockSection e ImagesSection rendericen siempre, con los botones de guardado deshabilitados y un caption 'Se guardará al crear el producto' hasta que exista productId.",
"priority": "high",
"risk": "low",
"status": "done",
"created_at": "2026-08-21",
"gates": {
"reviewer": true,
"security": true,
"qa": true,
"close": true
},
"completed_at": "2026-08-21T15:36:50Z"
}
]
}

View File

@@ -278,22 +278,9 @@ export function ProductEditor({ productId: initialProductId }: ProductEditorProp
<span className="text-xs text-gray-400 hidden sm:inline">(aparece en la home)</span>
</label>
</div>
{/* Precio y stock por producto (entre nombre y descripción) */}
{productId ? (
<PriceStockSection productId={productId} />
) : (
<div className="p-4 bg-amber-50 border border-amber-200 rounded-xl text-sm text-amber-800">
Guarda primero el producto para configurar precio, stock y EAN.
</div>
)}
<div>
<label className="block text-sm font-semibold text-gray-900 mb-1.5">Descripción</label>
<LexicalEditor
value={desc}
onChange={(html) => setDesc(html)}
placeholder="Descripción detallada del producto…"
/>
</div>
{/* Precio y stock por producto (entre nombre y metadatos) */}
<PriceStockSection productId={productId} />
{/* Marca / Canal / Caducidad — antes de descripción (F-123) */}
<div className="grid grid-cols-1 sm:grid-cols-3 gap-5">
<div>
<label className="block text-sm font-semibold text-gray-900 mb-1.5">Marca</label>
@@ -323,6 +310,14 @@ export function ProductEditor({ productId: initialProductId }: ProductEditorProp
<p className="mt-1 text-xs text-gray-400">Se mostrará en el listado de productos y en la tienda.</p>
</div>
</div>
<div>
<label className="block text-sm font-semibold text-gray-900 mb-1.5">Descripción</label>
<LexicalEditor
value={desc}
onChange={(html) => setDesc(html)}
placeholder="Descripción detallada del producto…"
/>
</div>
<div>
<div className="flex items-center justify-between mb-3">
<label className="text-sm font-semibold text-gray-900">Categorías</label>
@@ -369,13 +364,7 @@ export function ProductEditor({ productId: initialProductId }: ProductEditorProp
{/* ── IMAGES ── */}
{tab === 'images' && (
<section>
{!productId ? (
<div className="p-6 bg-amber-50 border border-amber-200 rounded-xl text-sm text-amber-800">
Guarda primero el producto para subir imágenes.
</div>
) : (
<ImagesSection productId={productId} />
)}
<ImagesSection productId={productId} />
</section>
)}

View File

@@ -4,7 +4,7 @@ import { productsApi } from '@/lib/api-client';
import type { ProductImage } from '@/types';
interface ImagesSectionProps {
productId: string;
productId?: string;
}
export function ImagesSection({ productId }: ImagesSectionProps) {
@@ -18,8 +18,10 @@ export function ImagesSection({ productId }: ImagesSectionProps) {
const fileRef = useRef<HTMLInputElement>(null);
const load = useCallback(async () => {
if (!productId) return;
const pid = productId;
try {
const p = await productsApi.get(productId);
const p = await productsApi.get(pid);
setImages(p.images ?? []);
} catch {
setError('Error al cargar imágenes');
@@ -124,9 +126,43 @@ export function ImagesSection({ productId }: ImagesSectionProps) {
};
if (!productId) {
return <div className="p-4 bg-amber-50 border border-amber-200 rounded-xl text-sm text-amber-800">
Guarda primero el producto para gestionar imágenes.
</div>;
// Pending state: render UI with disabled actions and a caption.
return (
<div className="space-y-5">
<div className="flex items-center justify-between">
<h3 className="text-sm font-bold text-gray-900">Imágenes</h3>
<span className="text-xs text-gray-400">Se guardarán al crear el producto</span>
</div>
<div className="flex flex-col gap-3 sm:flex-row opacity-50 pointer-events-none">
<input
type="text"
value=""
readOnly
placeholder="Pega una URL de imagen..."
className="flex-1 px-4 py-2.5 border border-gray-300 rounded-xl text-sm bg-gray-50"
/>
<button
type="button"
disabled
className="px-5 py-2.5 bg-[#2D6A4F] disabled:opacity-50 text-white text-sm font-semibold rounded-xl transition-colors"
>
Añadir URL
</button>
<button
type="button"
disabled
className="px-5 py-2.5 border border-gray-300 text-gray-700 text-sm font-medium rounded-xl transition-colors"
>
📤 Subir imagen
</button>
</div>
<div className="border-2 border-dashed border-gray-200 rounded-xl p-8 text-center">
<p className="text-gray-400 text-sm">
🖼 Arrastra imágenes aquí para añadirlas al producto
</p>
</div>
</div>
);
}
if (loading) return <div className="p-8 text-gray-400 text-sm">Cargando imágenes...</div>;

View File

@@ -24,7 +24,8 @@ type VatRate = 'general' | 'reduced' | 'super-reduced';
* El modelo interno conserva una única variante por producto; esta sección
* opera siempre sobre la primera variante.
*/
export function PriceStockSection({ productId }: { productId: string }) {
export function PriceStockSection({ productId }: { productId?: string }) {
const pending = !productId;
const [variant, setVariant] = useState<ProductVariant | null>(null);
const [loadError, setLoadError] = useState('');
@@ -49,27 +50,52 @@ export function PriceStockSection({ productId }: { productId: string }) {
const [eanMsg, setEanMsg] = useState('');
// Peso y compra mínima (F-102)
const [unitWeightKg, setUnitWeightKg] = useState('1');
// Peso se gestiona en gramos (UI: dropdown + custom). Se convierte a kg al persistir.
const WEIGHT_PRESETS_GR = [100, 150, 200, 250, 300, 350, 500, 740] as const;
const [unitWeightGr, setUnitWeightGr] = useState('1');
const [customWeightGr, setCustomWeightGr] = useState('');
const [minPurchaseQty, setMinPurchaseQty] = useState('1');
const [savingProductMeta, setSavingProductMeta] = useState(false);
const [metaMsg, setMetaMsg] = useState('');
// Determina si el valor actual coincide con un preset o si requiere custom.
const weightIsCustom = !WEIGHT_PRESETS_GR.map(String).includes(unitWeightGr);
const weightPresetValue = weightIsCustom ? 'custom' : unitWeightGr;
const onWeightPresetChange = (value: string) => {
if (value === 'custom') {
// Mantén el valor actual en customWeightGr para que el usuario no pierda lo escrito.
setCustomWeightGr(unitWeightGr || '');
// Marca custom sin asignar un valor concreto hasta que el usuario edite.
if (!WEIGHT_PRESETS_GR.map(String).includes(unitWeightGr)) {
setUnitWeightGr('');
}
} else {
setUnitWeightGr(value);
setCustomWeightGr('');
}
};
useEffect(() => {
taxApi.list().then(({ items }) => setTaxRates(items.filter((r) => r.active))).catch(() => {});
}, []);
useEffect(() => {
if (!productId) return; // pending: nothing to load yet
const pid = productId;
let cancelled = false;
productsApi
.get(productId)
.get(pid)
.then((product) => {
if (cancelled) return;
setUnitWeightKg(String((product as unknown as { unitWeightKg?: number }).unitWeightKg ?? 1));
const kg = (product as unknown as { unitWeightKg?: number }).unitWeightKg ?? 1;
setUnitWeightGr(String(Math.round(kg * 1000)));
setCustomWeightGr('');
setMinPurchaseQty(String((product as unknown as { minPurchaseQty?: number }).minPurchaseQty ?? 1));
})
.catch(() => {});
productsApi
.getVariants(productId)
.getVariants(pid)
.then(async ({ items }) => {
if (cancelled) return;
const first = items?.[0] ?? null;
@@ -177,7 +203,7 @@ export function PriceStockSection({ productId }: { productId: string }) {
setSavingEan(true);
setEanMsg('');
try {
const updated = await productsApi.updateVariant(productId, variant.id, { ean: next || null });
const updated = await productsApi.updateVariant(productId as string, variant.id, { ean: next || null });
setVariant((prev) => (prev ? { ...prev, ean: updated.ean } : prev));
setEan(updated.ean ?? '');
setEanMsg('✓');
@@ -190,9 +216,11 @@ export function PriceStockSection({ productId }: { productId: string }) {
};
const saveProductMeta = async () => {
const weight = parseFloat(unitWeightKg.replace(',', '.'));
if (pending || !productId) return;
const gr = parseFloat(unitWeightGr.replace(',', '.'));
const weight = gr / 1000;
const minQty = parseInt(minPurchaseQty, 10);
if (isNaN(weight) || weight <= 0 || weight > 1000) {
if (isNaN(gr) || gr <= 0 || gr > 100000) {
setMetaMsg('Peso inválido');
return;
}
@@ -203,7 +231,7 @@ export function PriceStockSection({ productId }: { productId: string }) {
setSavingProductMeta(true);
setMetaMsg('');
try {
await productsApi.update(productId, { unitWeightKg: weight, minPurchaseQty: minQty });
await productsApi.update(productId as string, { unitWeightKg: weight, minPurchaseQty: minQty });
setMetaMsg('✓');
setTimeout(() => setMetaMsg(''), 3000);
} catch {
@@ -217,7 +245,7 @@ export function PriceStockSection({ productId }: { productId: string }) {
return <div className="p-4 bg-red-50 border border-red-200 rounded-xl text-sm text-red-700">{loadError}</div>;
}
if (!variant) {
if (!pending && !variant) {
return (
<div className="p-4 bg-amber-50 border border-amber-200 rounded-xl text-sm text-amber-800">
Este producto aún no tiene datos internos de venta. Recarga la página para generarlos.
@@ -229,9 +257,14 @@ export function PriceStockSection({ productId }: { productId: string }) {
<div className="border border-gray-200 rounded-xl p-5 space-y-4 bg-gray-50/50">
<div className="flex items-center justify-between">
<h3 className="text-sm font-bold text-gray-900">Precio y stock</h3>
{priceMsg && (
<span className={`text-xs ${priceMsg.startsWith('✓') ? 'text-green-600' : 'text-red-600'}`}>{priceMsg}</span>
)}
<div className="flex items-center gap-3">
{pending && (
<span className="text-xs text-gray-400">Se guardará al crear el producto</span>
)}
{priceMsg && (
<span className={`text-xs ${priceMsg.startsWith('✓') ? 'text-green-600' : 'text-red-600'}`}>{priceMsg}</span>
)}
</div>
</div>
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
@@ -292,7 +325,7 @@ export function PriceStockSection({ productId }: { productId: string }) {
onChange={(e) => setStock(e.target.value)}
onBlur={saveStock}
onKeyDown={(e) => { if (e.key === 'Enter') saveStock(); }}
disabled={savingStock}
disabled={savingStock || pending}
className="w-full px-3 py-2 border border-gray-300 rounded-xl text-sm focus:ring-2 focus:ring-[#2D6A4F] outline-none bg-white disabled:opacity-50"
/>
{stockMsg && <span className={`text-xs shrink-0 ${stockMsg.startsWith('✓') ? 'text-green-600' : 'text-red-600'}`}>{stockMsg}</span>}
@@ -306,7 +339,7 @@ export function PriceStockSection({ productId }: { productId: string }) {
onChange={(e) => setEan(e.target.value)}
onBlur={saveEan}
onKeyDown={(e) => { if (e.key === 'Enter') saveEan(); }}
disabled={savingEan}
disabled={savingEan || pending}
placeholder="8412345678901"
className="w-full px-3 py-2 border border-gray-300 rounded-xl text-sm font-mono focus:ring-2 focus:ring-[#2D6A4F] outline-none bg-white disabled:opacity-50"
/>
@@ -325,7 +358,7 @@ export function PriceStockSection({ productId }: { productId: string }) {
<div className="flex items-end">
<button
onClick={savePrice}
disabled={savingPrice}
disabled={savingPrice || pending}
className="w-full px-4 py-2 bg-[#2D6A4F] hover:bg-[#1B4332] disabled:opacity-50 text-white text-sm font-semibold rounded-xl transition-colors"
>
{savingPrice ? 'Guardando…' : 'Guardar precio'}
@@ -335,16 +368,30 @@ export function PriceStockSection({ productId }: { productId: string }) {
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
<div>
<label className="block text-xs font-semibold text-gray-600 mb-1">Peso unitario (kg)</label>
<input
type="text" inputMode="decimal" value={unitWeightKg}
onChange={(e) => setUnitWeightKg(e.target.value)}
<label className="block text-xs font-semibold text-gray-600 mb-1">Peso unitario (Gr)</label>
<select
value={weightPresetValue}
onChange={(e) => onWeightPresetChange(e.target.value)}
onBlur={saveProductMeta}
onKeyDown={(e) => { if (e.key === 'Enter') saveProductMeta(); }}
disabled={savingProductMeta}
placeholder="1"
disabled={savingProductMeta || pending}
className="w-full px-3 py-2 border border-gray-300 rounded-xl text-sm focus:ring-2 focus:ring-[#2D6A4F] outline-none bg-white disabled:opacity-50"
/>
>
{WEIGHT_PRESETS_GR.map(g => (
<option key={g} value={String(g)}>{g} g</option>
))}
<option value="custom">Personalizado</option>
</select>
{weightIsCustom && (
<input
type="text" inputMode="decimal" value={customWeightGr || unitWeightGr}
onChange={(e) => { setCustomWeightGr(e.target.value); setUnitWeightGr(e.target.value); }}
onBlur={saveProductMeta}
onKeyDown={(e) => { if (e.key === 'Enter') saveProductMeta(); }}
disabled={savingProductMeta || pending}
placeholder="Gramos"
className="mt-2 w-full px-3 py-2 border border-gray-300 rounded-xl text-sm focus:ring-2 focus:ring-[#2D6A4F] outline-none bg-white disabled:opacity-50"
/>
)}
</div>
<div>
<label className="block text-xs font-semibold text-gray-600 mb-1">Compra mínima (uds.)</label>
@@ -354,7 +401,7 @@ export function PriceStockSection({ productId }: { productId: string }) {
onChange={(e) => setMinPurchaseQty(e.target.value)}
onBlur={saveProductMeta}
onKeyDown={(e) => { if (e.key === 'Enter') saveProductMeta(); }}
disabled={savingProductMeta}
disabled={savingProductMeta || pending}
className="w-full px-3 py-2 border border-gray-300 rounded-xl text-sm focus:ring-2 focus:ring-[#2D6A4F] outline-none bg-white disabled:opacity-50"
/>
{metaMsg && <span className={`text-xs shrink-0 ${metaMsg.startsWith('✓') ? 'text-green-600' : 'text-red-600'}`}>{metaMsg}</span>}

File diff suppressed because one or more lines are too long

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 840 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 840 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

View File

@@ -86,6 +86,36 @@ def main():
print(f'Started {feature_id}')
return
# CLI flags (non-interactive mode)
cli_args = sys.argv[1:]
if any(a.startswith('--id') or a.startswith('--type') for a in cli_args):
import argparse
p = argparse.ArgumentParser(add_help=False)
p.add_argument('--id')
p.add_argument('--type', choices=TYPE_CHOICES, default='fix')
p.add_argument('--title', default='Fix TODO')
p.add_argument('--description', default='Need change')
p.add_argument('--priority', choices=LEVEL_CHOICES, default='med')
p.add_argument('--risk', choices=LEVEL_CHOICES, default='low')
ns, _ = p.parse_known_args(cli_args)
fid = ns.id or next_id(features)
features = data['features']
features.append({
'id': fid,
'type': ns.type,
'title': ns.title,
'description': ns.description,
'priority': ns.priority,
'risk': ns.risk,
'status': 'pending',
'created_at': str(date.today()),
'gates': {'reviewer': False, 'security': False, 'qa': False},
})
data['features'] = features
BACKLOG.write_text(json.dumps(data, indent=2, ensure_ascii=False) + '\n', encoding='utf-8')
print(f'Created {fid}: {ns.title}')
return
features = data.get('features', [])
print('Create ticket (English caveman style).')

View File

@@ -0,0 +1,61 @@
# F-123 — Editor de producto: quitar gate, reorder y selector de peso en gramos
## Diagnóstico
F-121 (`a40d78d`) y F-107 (reorder previo) fueron fixes parciales:
1. **Gate "Guarda primero" sigue activo**: `ProductEditor.tsx` líneas 286 y 374 mantienen los `div` con el warning. F-121 solo cambió el flujo post-save (`router.push → router.replace` + `setProductId`), no eliminó los textos. Bundle live `15z1dcv3uvf3z.js` aún contiene "Guarda primero" (1 ocurrencia).
2. **Reorder incompleto**: Marca / Canal / Caducidad están en `ProductEditor.tsx` **después** de Descripción. El operador quiere esos campos **antes** de Descripción, justo debajo de Peso unitario.
3. **Peso unitario en kg con input libre**: actualmente `<input type="text" value={unitWeightKg}>` con label "Peso unitario (kg)". El operador quiere:
- Label "Peso unitario (Gr)"
- Select dropdown con valores [100, 150, 200, 250, 300, 350, 500, 740, custom]
- Al elegir "custom" mostrar input libre en gramos
- Convertir a kg al guardar (dividir por 1000)
## Diseño
### Cambios
1. **`ProductEditor.tsx`** — Eliminar los dos bloques condicionales `{productId ? <Section/> : <Warning/>}`. Renderizar siempre:
- `<PriceStockSection productId={productId} />` (productId puede ser undefined)
- `<ImagesSection productId={productId} />` (idem)
2. **`PriceStockSection.tsx`** — Aceptar `productId?: string`:
- Si `productId` es undefined → renderizar el formulario con inputs activos, **pero** desactivar los botones de guardado y mostrar caption "Se guardará al crear el producto".
- Si `productId` definido y la variante aún no carga → mantener el warning actual de "no datos internos de venta".
- Si `productId` definido y variante cargada → comportamiento normal.
3. **`ImagesSection.tsx`** — Aceptar `productId?: string`:
- Si `productId` undefined → mostrar UI con inputs/área de drop deshabilitados y caption.
- Si `productId` definido → comportamiento normal.
### Garantías
- Sin cambios en backend.
- Sin cambios en tipos del API client.
- Los productos ya creados siguen funcionando idéntico (productId siempre truthy → mismo path que antes).
- Para nuevos productos: el operador puede escribir precio, stock, EAN y planificar imágenes desde el inicio; al pulsar "Crear producto" el bundle se persiste (precio/stock/EAN se guardan con `setVariantPrice`/`setStock`/`updateVariant` tras crear el id; las imágenes vía upload + POST /products/:id/images). El caption avisa que los botones de guardado individual están inactivos hasta tener id.
### Riesgos
- Bajo. Cambios puramente UI. Sin migraciones, sin API, sin tipos.
## Plan de implementación
1. Editar `ProductEditor.tsx`:
- Quitar los dos bloques de warning.
- Renderizar siempre `<PriceStockSection>` y `<ImagesSection>`.
- **Mover** el bloque Marca/Canal/Caducidad a estar **después** de `<PriceStockSection>` y **antes** de Descripción.
2. Editar `PriceStockSection.tsx`:
- Prop opcional `productId?: string` + flag `pending`.
- Early return si pending → renderizar UI deshabilitada.
- Reemplazar input libre de peso por `<select>` con opciones [100,150,200,250,300,350,500,740,custom]. Custom muestra input auxiliar.
- Convertir gramos → kg al llamar API.
3. Editar `ImagesSection.tsx`:
- Prop opcional `productId?: string`.
- Estado pending → renderizar UI deshabilitada con caption.
4. `npx tsc --noEmit` en `apps/admin`.
5. `npm run build` en `apps/admin`.
6. Reiniciar admin (`monolith.sh prod restart`).
7. Verificar bundle servido: `curl /products/new | grep "Guarda primero"` → 0 ocurrencias; HTML contiene el dropdown.
8. Cerrar gates.

View File

@@ -0,0 +1,46 @@
# F-123 — Editor de producto: quitar gate, reorder y selector de peso en gramos
## Cambios
### `apps/admin/src/features/products/components/ProductEditor.tsx`
- **Eliminados** los dos bloques `⚠️ Guarda primero el producto para configurar …` (líneas 286 y 374 originales).
- **Renderizado siempre** `<PriceStockSection productId={productId} />` y `<ImagesSection productId={productId} />` (productId puede ser undefined).
- **Reordenado** el bloque Marca / Canal de venta / Fecha de caducidad para que aparezca **después** de `<PriceStockSection>` y **antes** de Descripción (estaba al revés).
### `apps/admin/src/features/products/components/sections/PriceStockSection.tsx`
- Prop `productId: string → productId?: string` con flag `pending = !productId`.
- `useEffect` de carga: si `!productId` no hace fetches.
- Early return cuando `!pending && !variant` para el caso "existe producto pero sin variante" (legacy).
- **Reemplazado** el input libre de peso unitario (kg) por un `<select>` con presets `[100, 150, 200, 250, 300, 350, 500, 740, Personalizado…]`. Al elegir "Personalizado…" aparece un input auxiliar en gramos.
- Estado interno en gramos (`unitWeightGr`, `customWeightGr`); se convierte a kg (÷1000) al persistir.
- **Disabled** los botones / inputs de guardado (`Guardar precio`, stock onBlur, EAN onBlur, peso onBlur, compra mínima onBlur) cuando `pending` es true.
- **Caption** "Se guardará al crear el producto" en la cabecera cuando pending.
- Guard TypeScript: añadido `const pending = !productId` y capturas locales (`pid`, `productId as string`) para que TS estreche bien dentro de `.then`/async.
### `apps/admin/src/features/products/components/sections/ImagesSection.tsx`
- Prop `productId: string → productId?: string`.
- Bloque pending: renderiza la UI de carga de imágenes deshabilitada (URL, "Añadir URL", "Subir imagen", área de drop) con caption "Se guardarán al crear el producto".
- `load()` captura `pid` para evitar warnings de TS.
## UX resultante
### Crear producto nuevo (`/products/new`)
- Pestaña General: Nombre + Slug + Precio/Stock (con caption "Se guardará al crear el producto", inputs deshabilitados) + Marca / Canal / Caducidad (siempre editables) + Descripción + Categorías + Atributos.
- Tras pulsar "Crear producto": `setProductId(saved.id)` desbloquea toda la sección de precio/stock, los inputs se hidratan desde API y la caption desaparece.
### Editar producto existente (`/products/[id]`)
- Comportamiento idéntico al previo (productId siempre truthy → no entra en modo pending).
## Evidencia
- `apps/admin npx tsc --noEmit` → exit 0.
- `apps/admin npm run build` → exit 0, BUILD_ID regenerado.
- `apps/admin npx eslint` sobre los tres archivos → 0 errors, 8 warnings preexistentes (no relacionados).
- `grep "Guarda primero" apps/admin/.next/static/chunks/` → 0 ocurrencias en el bundle compilado.
- Source: `grep "Guarda primero"` en los tres archivos modificados → 0 ocurrencias.
## Notas
- Sin cambios en backend ni en tipos del API client.
- La columna de peso en `/products` y `/inventory` se seguirá mostrando en kg (consistente con API), pero el input del editor ya está en gramos.
- El reinicio del monolith lo hace el operador; el bundle compilado ya está en `apps/admin/.next`.

View File

@@ -0,0 +1,17 @@
{
"verdict": "APPROVED",
"agent": "leader",
"feature_id": "F-123",
"summary": "F-123 listo para commit + push. Build regenerado en apps/admin/.next. Operador reinicia monolith para desplegar.",
"checks": [
"reviewer.json APPROVED",
"security.json APPROVED",
"qa.json APPROVED",
"implementer.md completo con secciones Cambios / UX / Evidencia / Notas",
"verify.sh verde (verificado previamente)",
"Files modificados: 3 (ProductEditor.tsx, PriceStockSection.tsx, ImagesSection.tsx)"
],
"commit_message": "feat(F-123): completed feature",
"next_step": "operador: ./scripts/monolith.sh prod restart",
"closed_at": "2026-08-21T15:34:00Z"
}

View File

@@ -0,0 +1,24 @@
{
"verdict": "APPROVED",
"reviewer": "qa",
"feature_id": "F-123",
"summary": "Verificación end-to-end contra bundle compilado y código fuente.",
"checks": [
"apps/admin npx tsc --noEmit → exit 0",
"apps/admin npm run build → exit 0, BUILD_ID regenerado (17:34)",
"grep 'Guarda primero' apps/admin/src/features/products/components/ProductEditor.tsx → 0",
"grep 'Guarda primero' apps/admin/src/features/products/components/sections/PriceStockSection.tsx → 0",
"grep 'Guarda primero' apps/admin/src/features/products/components/sections/ImagesSection.tsx → 0",
"grep 'Guarda primero' apps/admin/.next/static/chunks/ → 0 ocurrencias",
"Reorden verificado: en ProductEditor.tsx Marca está en línea 286 (antes de Descripción en línea 314)",
"Dropdown de peso verificado: WEIGHT_PRESETS_GR = [100, 150, 200, 250, 300, 350, 500, 740] con opción 'Personalizado…'"
],
"evidence_files": [
"apps/admin/.next/BUILD_ID",
"apps/admin/src/features/products/components/ProductEditor.tsx",
"apps/admin/src/features/products/components/sections/PriceStockSection.tsx",
"apps/admin/src/features/products/components/sections/ImagesSection.tsx"
],
"notes": "El bundle servido seguirá mostrando la versión antigua hasta que el operador ejecute `./scripts/monolith.sh prod restart`. Tras restart, `curl http://192.168.18.93:3004/_next/static/chunks/ | grep 'Guarda primero'` debe devolver 0.",
"reviewed_at": "2026-08-21T15:34:00Z"
}

View File

@@ -0,0 +1,18 @@
{
"verdict": "APPROVED",
"reviewer": "reviewer",
"feature_id": "F-123",
"summary": "Cambios UI quirúrgicos en ProductEditor, PriceStockSection, ImagesSection. Sin tocar backend.",
"checks": [
"Eliminado bloque `⚠️ Guarda primero el producto para configurar precio, stock y EAN.` en ProductEditor.tsx",
"Eliminado bloque `⚠️ Guarda primero el producto para subir imágenes.` en ProductEditor.tsx",
"Reorden: Marca / Canal / Caducidad ahora entre PriceStockSection y Descripción",
"PriceStockSection acepta productId opcional; flag pending deshabilita guardados y muestra caption",
"Peso unitario cambiado a dropdown con presets [100,150,200,250,300,350,500,740,custom] en gramos; convierte a kg al persistir",
"ImagesSection acepta productId opcional; UI deshabilitada con caption en pending",
"Sin cambios en backend, tipos del API client ni migraciones",
"TypeScript exit 0, ESLint 0 errors, build OK, bundle sin 'Guarda primero'"
],
"notes": "El reinicio del monolith es responsabilidad del operador.",
"reviewed_at": "2026-08-21T15:34:00Z"
}

View File

@@ -0,0 +1,15 @@
{
"verdict": "APPROVED",
"reviewer": "security",
"feature_id": "F-123",
"summary": "Cambios puramente UI. Sin superficies de ataque nuevas, sin nuevas rutas ni cambios en autorización.",
"checks": [
"Sin cambios en endpoints backend",
"Sin cambios en validación de inputs del cliente que acepten datos no saneados",
"El dropdown de peso no altera la sanitización existente (parseFloat con fallback)",
"Render condicional deshabilita botones pero NO bypasea gates: las llamadas API siguen pasando por admin auth + role check",
"No se exponen datos sensibles en el bundle compilado"
],
"notes": "Cambio puramente visual. Riesgo de seguridad nulo.",
"reviewed_at": "2026-08-21T15:34:00Z"
}

View File

@@ -1,11 +1,26 @@
{
"feature_id": null,
"stage": "idle",
"agent": "leader",
"action": "Sin ejecución activa",
"state": "waiting",
"next_agent": "leader",
"waiting_for": "Seleccionar una feature pending y actualizar este estado",
"updated_at": "2026-08-21T15:00:39Z",
"timeline": []
"feature_id": "F-123",
"stage": "build",
"agent": "implementer",
"action": "Remove warnings in ProductEditor and adapt PriceStockSection/ImagesSection to optional productId",
"state": "running",
"next_agent": "reviewer",
"waiting_for": "build complete",
"updated_at": "2026-08-21T15:17:05Z",
"timeline": [
{
"ts": "2026-08-21T15:16:30Z",
"agent": "architect",
"stage": "design",
"state": "running",
"message": "Design fix to remove Save First warning"
},
{
"ts": "2026-08-21T15:17:05Z",
"agent": "implementer",
"stage": "build",
"state": "running",
"message": "Remove warnings in ProductEditor and adapt PriceStockSection/ImagesSection to optional productId"
}
]
}