fix: asegurar runtime android y diagnostico pos

This commit is contained in:
mono
2026-10-25 19:00:14 -05:00
parent 4ef79f3f9f
commit fdfcc6e00b
9 changed files with 105 additions and 4 deletions

View File

@@ -1,7 +1,7 @@
<template> <template>
<v-app> <v-app>
<v-app-bar color="primary" elevation="2"> <v-app-bar color="primary" elevation="2">
<v-app-bar-nav-icon data-testid="navigation-toggle" class="d-md-none" aria-label="Abrir navegación" @click="drawer = !drawer" /> <v-btn icon="mdi-menu" data-testid="navigation-toggle" aria-label="Abrir navegación" @click="drawer = !drawer" />
<v-app-bar-title>ToldoConjunto - CESTA</v-app-bar-title> <v-app-bar-title>ToldoConjunto - CESTA</v-app-bar-title>
<v-chip v-if="jornada" color="success" variant="tonal" class="mr-3" prepend-icon="mdi-circle">Jornada activa</v-chip> <v-chip v-if="jornada" color="success" variant="tonal" class="mr-3" prepend-icon="mdi-circle">Jornada activa</v-chip>
</v-app-bar> </v-app-bar>

View File

@@ -3,10 +3,16 @@ import '@mdi/font/css/materialdesignicons.css'
import { createVuetify } from 'vuetify' import { createVuetify } from 'vuetify'
import * as components from 'vuetify/components' import * as components from 'vuetify/components'
import * as directives from 'vuetify/directives' import * as directives from 'vuetify/directives'
import { aliases, mdi } from 'vuetify/iconsets/mdi'
export default createVuetify({ export default createVuetify({
components, components,
directives, directives,
icons: {
defaultSet: 'mdi',
aliases,
sets: { mdi },
},
theme: { theme: {
defaultTheme: 'light', defaultTheme: 'light',
themes: { themes: {

View File

@@ -13,6 +13,7 @@
<v-card-text> <v-card-text>
<div class="text-subtitle-1 font-weight-bold">{{ prod.nombrePresentacion }}</div> <div class="text-subtitle-1 font-weight-bold">{{ prod.nombrePresentacion }}</div>
<div class="text-h6 text-primary">${{ prod.precioUnitario.toLocaleString() }}</div> <div class="text-h6 text-primary">${{ prod.precioUnitario.toLocaleString() }}</div>
<div class="text-caption" :class="prod.stock > 0 ? 'text-medium-emphasis' : 'text-error'">Disponible: {{ prod.stock }}</div>
</v-card-text> </v-card-text>
</v-card> </v-card>
</v-col> </v-col>
@@ -32,7 +33,7 @@
<v-list-item-subtitle> <v-list-item-subtitle>
<v-row align="center" class="mt-1"> <v-row align="center" class="mt-1">
<v-col cols="4"> <v-col cols="4">
<div class="d-flex align-center justify-center ga-1"><v-btn icon="mdi-minus" size="small" variant="tonal" aria-label="Disminuir cantidad" @click="cambiarCantidad(i, -1)" /><span class="quantity-value">{{ l.cantidad }}</span><v-btn icon="mdi-plus" size="small" variant="tonal" aria-label="Aumentar cantidad" @click="cambiarCantidad(i, 1)" /></div> <div class="d-flex align-center justify-center ga-1"><v-btn icon="mdi-minus" size="small" variant="tonal" aria-label="Disminuir cantidad" :disabled="l.cantidad <= 1" @click="cambiarCantidad(i, -1)" /><span class="quantity-value">{{ l.cantidad }}</span><v-btn icon="mdi-plus" size="small" variant="tonal" aria-label="Aumentar cantidad" :disabled="l.cantidad >= getStock(l.productoId)" @click="cambiarCantidad(i, 1)" /></div>
</v-col> </v-col>
<v-col cols="4">${{ l.precioUnitario }}</v-col> <v-col cols="4">${{ l.precioUnitario }}</v-col>
<v-col cols="4">${{ l.subtotal }}</v-col> <v-col cols="4">${{ l.subtotal }}</v-col>
@@ -66,6 +67,7 @@ import { VentasService, JornadaService, EdicionService } from '@toldo/services';
const ventasService = new VentasService(); const ventasService = new VentasService();
const jornadaService = new JornadaService(); const jornadaService = new JornadaService();
const productos = ref<any[]>([]); const productos = ref<any[]>([]);
const stockByProduct = ref<Record<string, number>>({});
const lineas = ref<any[]>([]); const lineas = ref<any[]>([]);
const formaPago = ref('EFECTIVO'); const formaPago = ref('EFECTIVO');
const jornadaAbierta = ref<any>(null); const jornadaAbierta = ref<any>(null);
@@ -88,7 +90,9 @@ const edicionService = new EdicionService();
async function cargar() { async function cargar() {
const productores = await db.productores.toArray(); const productores = await db.productores.toArray();
productos.value = (await db.productos.toArray()).map((producto) => ({ ...producto, productorNombre: productores.find((productor) => productor.id === producto.productorId)?.nombre ?? '' })); const movimientos = await db.movimientosInventario.toArray();
stockByProduct.value = movimientos.reduce<Record<string, number>>((stock, movimiento) => { stock[movimiento.productoId] = (stock[movimiento.productoId] ?? 0) + movimiento.cantidad; return stock; }, {});
productos.value = (await db.productos.toArray()).map((producto) => ({ ...producto, stock: stockByProduct.value[producto.id] ?? 0, productorNombre: productores.find((productor) => productor.id === producto.productorId)?.nombre ?? '' }));
jornadaAbierta.value = await jornadaService.getJornadaAbierta(); jornadaAbierta.value = await jornadaService.getJornadaAbierta();
const ventasDelDia = await ventasService.getVentasDelDia(new Date()); const ventasDelDia = await ventasService.getVentasDelDia(new Date());
ventas.value = await Promise.all(ventasDelDia.map(async (venta) => ({ ...venta, lineas: await ventasService.getLineasPorVenta(venta.id) }))); ventas.value = await Promise.all(ventasDelDia.map(async (venta) => ({ ...venta, lineas: await ventasService.getLineasPorVenta(venta.id) })));
@@ -97,10 +101,13 @@ async function cargar() {
function getProdNombre(id: string) { function getProdNombre(id: string) {
return productos.value.find(p => p.id === id)?.nombrePresentacion || id; return productos.value.find(p => p.id === id)?.nombrePresentacion || id;
} }
function getStock(id: string) { return stockByProduct.value[id] ?? 0; }
function agregarProducto(prod: any) { function agregarProducto(prod: any) {
if (getStock(prod.id) <= 0) { error.value = 'Este producto no tiene inventario disponible. Registra primero el inventario inicial.'; return; }
const existente = lineas.value.findIndex(l => l.productoId === prod.id); const existente = lineas.value.findIndex(l => l.productoId === prod.id);
if (existente >= 0) { if (existente >= 0) {
if (lineas.value[existente].cantidad >= getStock(prod.id)) { error.value = `Stock insuficiente. Disponible: ${getStock(prod.id)}.`; return; }
lineas.value[existente].cantidad++; lineas.value[existente].cantidad++;
lineas.value[existente].subtotal = lineas.value[existente].cantidad * lineas.value[existente].precioUnitario; lineas.value[existente].subtotal = lineas.value[existente].cantidad * lineas.value[existente].precioUnitario;
} else { } else {
@@ -162,7 +169,7 @@ async function registrarVenta() {
lineas.value = []; lineas.value = [];
exito.value = `Venta registrada por $${total.value.toLocaleString()} en ${formaPago.value}.`; exito.value = `Venta registrada por $${total.value.toLocaleString()} en ${formaPago.value}.`;
} catch (e: any) { } catch (e: any) {
error.value = e.message; error.value = e.message.includes('objectStore') ? 'No se pudo abrir el almacenamiento local. Cierra y vuelve a abrir la aplicación para actualizar la base de datos.' : e.message;
} }
} }

View File

@@ -2,6 +2,7 @@ import { expect, test } from '@playwright/test';
test('muestra el panel y permite abrir navegación móvil', async ({ page }) => { test('muestra el panel y permite abrir navegación móvil', async ({ page }) => {
await page.goto('/'); await page.goto('/');
await expect(page.locator('.mdi-menu')).toBeVisible();
await expect(page.getByText('Panel de control')).toBeVisible(); await expect(page.getByText('Panel de control')).toBeVisible();
await page.setViewportSize({ width: 390, height: 844 }); await page.setViewportSize({ width: 390, height: 844 });
await page.getByTestId('navigation-toggle').click(); await page.getByTestId('navigation-toggle').click();
@@ -26,6 +27,7 @@ test('permite volver a inicio desde Jornada e Inventario', async ({ page }) => {
if ((await page.viewportSize())!.width < 960) await page.getByTestId('navigation-toggle').click(); if ((await page.viewportSize())!.width < 960) await page.getByTestId('navigation-toggle').click();
await page.getByRole('link', { name: 'Inventario', exact: true }).click(); await page.getByRole('link', { name: 'Inventario', exact: true }).click();
await expect(page.getByText('Inventario y Productos')).toBeVisible(); await expect(page.getByText('Inventario y Productos')).toBeVisible();
await expect(page.locator('.mdi-home-outline')).toBeVisible();
await page.getByTestId('back-button').click(); await page.getByTestId('back-button').click();
await expect(page.getByText('Panel de control')).toBeVisible(); await expect(page.getByText('Panel de control')).toBeVisible();
}); });

View File

@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-10-25

View File

@@ -0,0 +1,22 @@
# Design
## Context
La app usa nombres de iconos MDI, pero el set no estaba declarado explícitamente. El botón de navegación dependía de `d-md-none`, lo que puede ocultarlo en WebViews con un ancho reportado distinto al esperado.
## Goals / Non-Goals
**Goals:** garantizar iconos MDI empaquetados, menú visible y diagnóstico claro de stock.
**Non-Goals:** cambiar el application id, la base local o el flujo de firma Android.
## Decisions
- Declarar `aliases`, `mdi` y `defaultSet` en `createVuetify` además de importar el CSS de MDI.
- Usar un botón de menú explícito siempre visible; el drawer seguirá siendo permanente en escritorio y temporal en móvil.
- Cargar stock desde movimientos y bloquear el agregado de productos sin existencias con explicación.
## Risks / Trade-offs
- El botón de menú ocupa espacio adicional en escritorio, pero evita una dependencia frágil del breakpoint en WebView.
- El stock se calcula localmente; esto es consistente con el modo offline.

View File

@@ -0,0 +1,27 @@
# Proposal
## Why
La APK mostraba controles sin iconos y ocultaba el menú aunque los assets MDI estuvieran presentes, dificultando la operación en Android. Además, el POS no distinguía visualmente la falta de inventario de un error de persistencia.
## What Changes
- Configurar explícitamente el set de iconos MDI de Vuetify y cargar su fuente.
- Mostrar siempre un botón de menú accesible en el encabezado.
- Mostrar stock disponible en cada producto y mensajes accionables al cobrar.
- Mantener pruebas web de navegación y persistencia de ventas.
## Capabilities
### New Capabilities
- `android-webview-runtime`: controles e iconos operables en APK/WebView.
### Modified Capabilities
- `toldo-poc`: experiencia del POS y diagnóstico de inventario.
## Impact
- `apps/toldo-app/src/plugins/vuetify.ts`, Layout y POS.
- Build Capacitor/Android y pruebas de regresión.

View File

@@ -0,0 +1,23 @@
# Spec Delta
## Purpose
Garantiza que la versión Android muestre los controles de navegación y operación con iconos visibles y que los errores de venta puedan resolverse desde la interfaz.
## ADDED Requirements
### Requirement: Iconos y menú operables
The system SHALL render MDI icons for navigation, quantity controls, deletion, and breadcrumbs in the Android WebView.
#### Scenario: Menú APK
- **WHEN** the app opens on Android
- **THEN** a visible menu button opens the navigation drawer
- **AND** breadcrumb and action icons are visible
### Requirement: Diagnóstico de stock
The system SHALL show available stock and explain when a product cannot be added or sold because inventory is insufficient.
#### Scenario: Producto sin inventario
- **WHEN** user selects a product with zero stock
- **THEN** the product is not added to the cart
- **AND** the UI explains that initial inventory must be registered

View File

@@ -0,0 +1,12 @@
# Tasks
## 1. Runtime Android
- [x] 1.1 Declarar y empaquetar MDI en Vuetify; verificar que el build contiene las fuentes
- [x] 1.2 Hacer visible el menú y mantener breadcrumbs/iconos operables; verificar con Playwright
- [x] 1.3 Mostrar stock y errores accionables en POS; verificar tests de venta y stock
## 2. Entrega
- [x] 2.1 Ejecutar tests unitarios, `vue-tsc` y Playwright
- [x] 2.2 Generar APK final y comprobar assets/metadatos Android