feat: mejorar pos movil y cierre de jornada

This commit is contained in:
mono
2026-10-25 20:03:36 -05:00
parent 3bffc7fdff
commit ba18b73ed9
20 changed files with 262 additions and 73 deletions

View File

@@ -0,0 +1,8 @@
import { describe, expect, it } from 'vitest';
import { calcularDevuelta, validarPagoEfectivo } from '@toldo/core';
describe('pago en efectivo', () => {
it('calcula la devuelta', () => expect(calcularDevuelta(12000, 20000)).toBe(8000));
it('rechaza efectivo insuficiente', () => expect(validarPagoEfectivo(12000, 10000)).toContain('no alcanza'));
it('acepta efectivo exacto', () => expect(validarPagoEfectivo(12000, 12000)).toBeNull());
});

View File

@@ -29,10 +29,21 @@ describe('casos de uso operativos offline', () => {
const service = new JornadaService();
const jornadaId = await service.abrirJornada(['Ana'], 50000);
await expect(service.abrirJornada(['Luis'], 10000)).rejects.toThrow('Ya existe una jornada abierta');
await service.cerrarJornada(jornadaId);
await service.cerrarJornada(jornadaId, 0);
await expect(service.registrarGastoEnJornada(jornadaId, { id: 'gasto-cerrado', fecha, concepto: 'Bolsas', monto: 1000, formaPago: 'EFECTIVO', createdAt: fecha } as any)).rejects.toThrow('jornada cerrada');
});
it('cierra caja y jornada juntos con banco QR opcional', async () => {
const service = new JornadaService();
const jornadaId = await service.abrirJornada(['Ana'], 50000);
const cuadreId = await service.cerrarJornada(jornadaId, 48000, 12000);
const cuadre = await db.cuadres.get(cuadreId);
const jornada = await db.jornadas.get(jornadaId);
expect(cuadre?.saldoRealEfectivo).toBe(48000);
expect(cuadre?.totalQRBanco).toBe(12000);
expect(jornada?.estado).toBe('CERRADA');
});
it('calcula cuadre mixto de efectivo y QR y registra ambos descuadres', async () => {
await crearProducto();
const jornada = new JornadaService();

View File

@@ -16,15 +16,10 @@
<v-main>
<v-container fluid class="pa-4 pa-md-6">
<div class="d-flex flex-wrap align-center justify-space-between mb-3 ga-2">
<v-breadcrumbs :items="breadcrumbs" class="pa-0" aria-label="Migas de pan">
<template #prepend><v-icon icon="mdi-home-outline" size="small" /></template>
</v-breadcrumbs>
<div class="d-flex ga-2">
<div class="d-flex justify-end mb-3 ga-2">
<v-btn variant="text" size="small" prepend-icon="mdi-arrow-left" data-testid="back-button" @click="goBack">Atrás</v-btn>
<v-btn to="/" variant="text" size="small" prepend-icon="mdi-home">Inicio</v-btn>
</div>
</div>
<router-view />
</v-container>
</v-main>
@@ -32,30 +27,26 @@
</template>
<script setup lang="ts">
import { computed, onMounted, onUnmounted, ref } from 'vue';
import { onMounted, onUnmounted, ref } from 'vue';
import { JornadaService } from '@toldo/services';
import { useRoute, useRouter } from 'vue-router';
import { useRouter } from 'vue-router';
const drawer = ref(true);
const isMobile = ref(false);
const jornada = ref<any>(null);
const jornadaService = new JornadaService();
const route = useRoute();
const router = useRouter();
const navItems = [
{ to: '/jornada', icon: 'mdi-calendar-check', title: 'Jornada' },
{ to: '/inventario', icon: 'mdi-package-variant', title: 'Inventario' },
{ to: '/ventas', icon: 'mdi-cart', title: 'Ventas POS' },
{ to: '/gastos', icon: 'mdi-cash-minus', title: 'Gastos' },
{ to: '/cuadre', icon: 'mdi-cash-register', title: 'Cierre de caja' },
{ to: '/ajustes', icon: 'mdi-pencil', title: 'Ajustes' },
{ to: '/reportes', icon: 'mdi-chart-bar', title: 'Reportes' },
{ to: '/exportar', icon: 'mdi-export', title: 'Exportar / importar' },
];
const breadcrumbs = computed(() => [{ title: 'Inicio', disabled: false, to: '/' }, ...(route.path === '/' ? [] : [{ title: String(route.meta.title ?? 'Sección'), disabled: true }])]);
function goBack() { if (window.history.state?.back) router.back(); else router.push('/'); }
function updateViewport() { isMobile.value = window.innerWidth < 960; if (isMobile.value) drawer.value = false; }
function closeOnMobile() { if (isMobile.value) drawer.value = false; }
function goBack() { if (window.history.state?.back) router.back(); else router.push('/'); }
onMounted(async () => { updateViewport(); window.addEventListener('resize', updateViewport); jornada.value = await jornadaService.getJornadaAbierta(); });
onUnmounted(() => window.removeEventListener('resize', updateViewport));
</script>

View File

@@ -4,9 +4,7 @@ import Home from '../views/Home.vue';
import Inventario from '../views/Inventario.vue';
import Ventas from '../views/Ventas.vue';
import Gastos from '../views/Gastos.vue';
import Cuadre from '../views/Cuadre.vue';
import Reportes from '../views/Reportes.vue';
import Ajustes from '../views/Ajustes.vue';
import Exportar from '../views/Exportar.vue';
import Jornada from '../views/Jornada.vue';
@@ -22,9 +20,7 @@ const router = createRouter({
{ path: 'inventario', component: Inventario, meta: { title: 'Inventario' } },
{ path: 'ventas', component: Ventas, meta: { title: 'Ventas POS' } },
{ path: 'gastos', component: Gastos, meta: { title: 'Gastos' } },
{ path: 'cuadre', component: Cuadre, meta: { title: 'Cierre de caja' } },
{ path: 'reportes', component: Reportes, meta: { title: 'Reportes' } },
{ path: 'ajustes', component: Ajustes, meta: { title: 'Ajustes' } },
{ path: 'exportar', component: Exportar, meta: { title: 'Exportar / importar' } },
]
}

View File

@@ -1,6 +1,6 @@
<template>
<div>
<h2>Ajustes</h2>
<div class="mb-5"><div class="text-overline text-primary">INVENTARIO</div><h1 class="text-h4 mb-1">Historial de ajustes</h1><div class="text-body-2 text-medium-emphasis">Los ajustes se realizan desde Inventario.</div></div>
<v-alert v-if="mensaje" :type="tipoMensaje" variant="tonal" class="mb-3">{{ mensaje }}</v-alert>
<p><strong>Requiere motivo obligatorio</strong></p>
<section>

View File

@@ -1,24 +1,10 @@
<template>
<div>
<h2>Gastos</h2>
<div class="mb-5"><div class="text-overline text-primary">OPERACION</div><h1 class="text-h4 mb-1">Gastos</h1><div class="text-body-2 text-medium-emphasis">Registra compras y pagos de la jornada.</div></div>
<v-alert v-if="mensaje" :type="tipoMensaje" variant="tonal" class="mb-3">{{ mensaje }}</v-alert>
<p v-if="!jornadaAbierta" style="color: orange">Sin jornada abierta - debe abrir jornada para registrar gastos</p>
<p v-else style="color: green">Jornada abierta</p>
<section>
<input v-model="concepto" placeholder="Concepto (cinta, lapicero, bolsas...)" />
<input v-model.number="monto" type="number" placeholder="Monto" />
<select v-model="formaPago">
<option value="EFECTIVO">Efectivo</option>
<option value="QR">QR</option>
</select>
<button @click="registrar" :disabled="!jornadaAbierta">Registrar Gasto</button>
</section>
<section>
<h3>Gastos del día</h3>
<ul>
<li v-for="g in gastos" :key="g.id">{{ g.concepto }} - ${{ g.monto }} ({{ g.formaPago }})</li>
</ul>
</section>
<v-alert :type="jornadaAbierta ? 'success' : 'warning'" variant="tonal" class="mb-4">{{ jornadaAbierta ? 'Jornada abierta' : 'Abre una jornada para registrar gastos' }}</v-alert>
<v-card class="mb-4"><v-card-title>Registrar gasto</v-card-title><v-card-text><v-form @submit.prevent="registrar"><v-text-field v-model="concepto" label="Concepto" placeholder="Cinta, lapicero, bolsas..." prepend-inner-icon="mdi-receipt-text" /><v-row><v-col cols="12" sm="6"><v-text-field v-model.number="monto" type="number" label="Monto" prefix="$" /></v-col><v-col cols="12" sm="6"><v-select v-model="formaPago" :items="['EFECTIVO', 'QR']" label="Forma de pago" /></v-col></v-row><v-btn type="submit" color="primary" block size="large" :disabled="!jornadaAbierta" prepend-icon="mdi-plus-circle">Registrar gasto</v-btn></v-form></v-card-text></v-card>
<v-card><v-card-title>Gastos del día</v-card-title><v-list><v-list-item v-for="g in gastos" :key="g.id" :title="g.concepto" :subtitle="g.formaPago"><template #append><span class="font-weight-bold">${{ g.monto.toLocaleString() }}</span></template></v-list-item><v-list-item v-if="!gastos.length" title="Sin gastos registrados" /></v-list></v-card>
</div>
</template>

View File

@@ -6,8 +6,9 @@
<v-card-title><v-icon icon="mdi-file-delimited" class="mr-2" />Importar inventario inicial</v-card-title>
<v-card-text><div class="text-body-2 mb-3">CSV con: Nombre de producto, Nombre Productor, precio unitario y cantidad.</div><v-file-input v-model="csvFile" accept=".csv,text/csv" label="Seleccionar CSV" density="compact" prepend-icon="mdi-paperclip" @update:model-value="previsualizarCsv" /><v-alert v-if="previewRows.length" variant="outlined" density="compact" class="mb-3"><div class="text-caption font-weight-bold mb-1">Previsualización</div><div v-for="(row, index) in previewRows" :key="index" class="text-caption text-truncate">{{ row }}</div></v-alert><v-btn :disabled="!csvFile || !previewRows.length" color="info" @click="importarCsv" prepend-icon="mdi-upload">Confirmar importación</v-btn></v-card-text>
</v-card>
<v-tabs v-model="inventoryTab" class="d-md-none mb-4" color="primary" grow><v-tab value="catalogo">Catálogo</v-tab><v-tab value="ingresos">Ingresos</v-tab><v-tab value="ajustes">Ajustes</v-tab></v-tabs>
<v-row>
<v-row v-show="!isMobile || inventoryTab === 'catalogo'">
<v-col cols="12" md="6">
<v-card class="mb-4">
<v-card-title>Productores</v-card-title>
@@ -45,7 +46,7 @@
</v-col>
</v-row>
<v-row>
<v-row v-show="!isMobile || inventoryTab === 'ingresos'">
<v-col cols="12" md="6">
<v-card>
<v-card-title>Ingreso Inicial del Día</v-card-title>
@@ -69,16 +70,19 @@
</v-card>
</v-col>
</v-row>
<v-card v-show="!isMobile || inventoryTab === 'ajustes'" class="mt-4"><v-card-title>Ajustar inventario</v-card-title><v-card-text><v-select v-model="productoAjuste" :items="productos" item-title="nombrePresentacion" item-value="id" label="Producto" /><v-text-field v-model.number="cantidadAjuste" type="number" label="Nueva cantidad" /><v-textarea v-model="motivoAjuste" label="Motivo obligatorio" rows="2" /><v-btn color="warning" block prepend-icon="mdi-scale-balance" @click="ajustarInventario">Guardar ajuste</v-btn></v-card-text></v-card>
<v-dialog v-model="dialogoEdicion" max-width="520"><v-card><v-card-title>Editar {{ entidadEdicion === 'PRODUCTOR' ? 'productor' : 'producto' }}</v-card-title><v-card-text><v-text-field v-if="entidadEdicion === 'PRODUCTOR'" v-model="edicionProductor.nombre" label="Nombre" /><template v-else><v-text-field v-model="edicionProducto.nombrePresentacion" label="Nombre / presentación" /><v-text-field v-model.number="edicionProducto.precioUnitario" type="number" label="Precio unitario" /></template><v-divider class="my-3" /><v-text-field v-model="actor" label="Quién realiza el cambio" /><v-textarea v-model="motivo" label="Motivo del cambio" rows="2" /></v-card-text><v-card-actions><v-spacer /><v-btn variant="text" @click="dialogoEdicion = false">Cancelar</v-btn><v-btn color="primary" @click="guardarEdicion">Guardar cambio</v-btn></v-card-actions></v-card></v-dialog>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue';
import { ref, computed, onMounted, onUnmounted } from 'vue';
import { db } from '@toldo/data';
import { InventarioService, ImportInventarioCsvService, EdicionService } from '@toldo/services';
import { InventarioService, ImportInventarioCsvService, EdicionService, AjustesService } from '@toldo/services';
import { validateAjusteMotivo } from '@toldo/core';
const invService = new InventarioService();
const ajustesService = new AjustesService();
const productores = ref<any[]>([]);
const productos = ref<any[]>([]);
const nuevoProductor = ref('');
@@ -103,11 +107,26 @@ const edicionProducto = ref<any>({});
const originalEdicion = ref<any>({});
const actor = ref('');
const motivo = ref('');
const productoAjuste = ref('');
const cantidadAjuste = ref(0);
const motivoAjuste = ref('');
const inventoryTab = ref('catalogo');
const isMobile = ref(false);
const busquedaProductores = ref('');
const busquedaProductos = ref('');
const productoresFiltrados = computed(() => productores.value.filter((productor) => !busquedaProductores.value.trim() || productor.nombre.toLocaleLowerCase().includes(busquedaProductores.value.trim().toLocaleLowerCase())));
const productosFiltrados = computed(() => productos.value.filter((producto) => !busquedaProductos.value.trim() || producto.nombrePresentacion.toLocaleLowerCase().includes(busquedaProductos.value.trim().toLocaleLowerCase())));
function avisar(texto: string, tipo: 'success' | 'error' = 'error') { mensaje.value = texto; tipoMensaje.value = tipo; }
function updateViewport() { isMobile.value = window.innerWidth < 960; }
async function ajustarInventario() {
try {
validateAjusteMotivo(motivoAjuste.value);
if (!productoAjuste.value || cantidadAjuste.value < 0) return avisar('Selecciona un producto y una cantidad válida.');
const anterior = await invService.calcularSaldoProducto(productoAjuste.value);
await ajustesService.crearAjuste({ id: crypto.randomUUID(), fecha: new Date(), productoId: productoAjuste.value, cantidadAnterior: anterior, cantidadNueva: cantidadAjuste.value, diferencia: cantidadAjuste.value - anterior, motivo: motivoAjuste.value, createdAt: new Date() } as any);
cantidadAjuste.value = 0; motivoAjuste.value = ''; await cargar(); avisar('Ajuste registrado.', 'success');
} catch (error: any) { avisar(error.message); }
}
function editarProductor(productor: any) { entidadEdicion.value = 'PRODUCTOR'; originalEdicion.value = { ...productor }; edicionProductor.value = { ...productor }; actor.value = ''; motivo.value = ''; dialogoEdicion.value = true; }
function editarProducto(producto: any) { entidadEdicion.value = 'PRODUCTO'; originalEdicion.value = { ...producto }; edicionProducto.value = { ...producto }; actor.value = ''; motivo.value = ''; dialogoEdicion.value = true; }
async function guardarEdicion() {
@@ -165,5 +184,6 @@ async function ingresoSemana() {
avisar('Ingreso entre semana registrado.', 'success');
}
onMounted(cargar);
onMounted(() => { updateViewport(); window.addEventListener('resize', updateViewport); cargar(); });
onUnmounted(() => window.removeEventListener('resize', updateViewport));
</script>

View File

@@ -26,9 +26,13 @@
<div v-if="resumen">
<div class="mb-2">Ventas: ${{ resumen.totalVentas }}</div>
<div class="mb-2">Gastos: ${{ resumen.totalGastos }}</div>
<div class="mb-2 font-weight-bold">Saldo esperado: ${{ jornadaAbierta ? jornadaAbierta.baseInicial + resumen.totalVentas - resumen.totalGastos : 0 }}</div>
<div class="mb-2 font-weight-bold">Saldo esperado en efectivo: ${{ saldoEsperadoEfectivo.toLocaleString() }}</div>
</div>
<v-btn @click="cerrar" color="error" block class="mt-2" :disabled="!jornadaAbierta">Cerrar Jornada (generar cuadre)</v-btn>
<v-text-field v-model.number="saldoRealEfectivo" type="number" label="Efectivo contado (obligatorio)" prefix="$" class="mt-4" />
<v-text-field v-model.number="totalQRBanco" type="number" label="Dinero en banco por QR (opcional)" prefix="$" />
<v-alert v-if="saldoRealEfectivo !== null && resumen" :type="diferenciaEfectivo === 0 ? 'success' : 'warning'" variant="tonal" class="mb-2">Diferencia de efectivo: ${{ diferenciaEfectivo.toLocaleString() }}</v-alert>
<v-alert v-if="cierreError" :type="cierreCompletado ? 'success' : 'error'" variant="tonal" class="mb-2">{{ cierreError }}</v-alert>
<v-btn @click="cerrar" color="error" block class="mt-2" :disabled="!jornadaAbierta || saldoRealEfectivo < 0" prepend-icon="mdi-lock-check">Cerrar caja y jornada</v-btn>
</v-card-text>
</v-card>
</v-col>
@@ -37,14 +41,21 @@
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue';
import { JornadaService } from '@toldo/services';
import { ref, computed, onMounted } from 'vue';
import { JornadaService, CuadreService } from '@toldo/services';
const service = new JornadaService();
const cuadreService = new CuadreService();
const jornadaAbierta = ref<any>(null);
const responsablesStr = ref('');
const baseInicial = ref(0);
const resumen = ref<any>(null);
const saldoRealEfectivo = ref<number | null>(null);
const totalQRBanco = ref<number | null>(null);
const cierreError = ref('');
const cierreCompletado = ref(false);
const saldoEsperadoEfectivo = ref(0);
const diferenciaEfectivo = computed(() => saldoRealEfectivo.value === null ? 0 : saldoRealEfectivo.value - saldoEsperadoEfectivo.value);
function formatFecha(d: any) {
try { return new Date(d).toLocaleString(); } catch { return d; }
@@ -54,8 +65,11 @@ async function cargar() {
jornadaAbierta.value = await service.getJornadaAbierta();
if (jornadaAbierta.value) {
resumen.value = await service.getResumenJornada(jornadaAbierta.value.id);
const cuadre = await cuadreService.calcularCuadreDelDia(jornadaAbierta.value.fecha, jornadaAbierta.value.baseInicial);
saldoEsperadoEfectivo.value = cuadre.saldoEsperadoEfectivo ?? 0;
} else {
resumen.value = null;
saldoEsperadoEfectivo.value = 0;
}
}
@@ -75,10 +89,14 @@ async function abrir() {
async function cerrar() {
if (!jornadaAbierta.value) return;
cierreError.value = '';
cierreCompletado.value = false;
if (saldoRealEfectivo.value === null || saldoRealEfectivo.value === undefined || saldoRealEfectivo.value < 0) { cierreError.value = 'El efectivo contado es obligatorio.'; return; }
try {
const cid = await service.cerrarJornada(jornadaAbierta.value.id);
const cid = await service.cerrarJornada(jornadaAbierta.value.id, Number(saldoRealEfectivo.value), totalQRBanco.value === null ? undefined : Number(totalQRBanco.value));
await cargar();
alert(`Jornada cerrada. Cuadre: ${cid}`);
cierreCompletado.value = true;
cierreError.value = `Cierre de caja y jornada completado: ${cid}`;
} catch (e: any) {
alert(e.message);
}

View File

@@ -1,8 +1,8 @@
<template>
<div>
<div class="d-flex flex-wrap align-center justify-space-between mb-4 ga-2"><div><h1 class="text-h4 mb-1">Punto de venta</h1><div class="text-body-2 text-medium-emphasis">Toca un producto para agregarlo al carrito</div></div><v-chip :color="jornadaAbierta ? 'success' : 'warning'" variant="tonal" :prepend-icon="jornadaAbierta ? 'mdi-check-circle' : 'mdi-alert'">{{ jornadaAbierta ? 'Jornada abierta' : 'Abre una jornada' }}</v-chip></div>
<div class="d-flex flex-wrap align-center justify-space-between mb-4 ga-2"><div><h1 class="text-h4 mb-1">Punto de venta</h1><div class="text-body-2 text-medium-emphasis">Busca un producto y agrégalo al carrito</div></div><div class="d-flex align-center ga-2"><v-btn v-if="isMobile" color="primary" prepend-icon="mdi-store-search" @click="catalogoDialog = true">Buscar productos</v-btn><v-chip :color="jornadaAbierta ? 'success' : 'warning'" variant="tonal" :prepend-icon="jornadaAbierta ? 'mdi-check-circle' : 'mdi-alert'">{{ jornadaAbierta ? 'Jornada abierta' : 'Abre una jornada' }}</v-chip></div></div>
<v-row>
<v-col cols="12" md="8">
<v-col v-if="!isMobile" cols="12" md="8">
<v-card>
<v-card-title>Productos disponibles</v-card-title>
<v-card-text>
@@ -47,22 +47,26 @@
<v-divider class="my-3"></v-divider>
<div class="text-h5 font-weight-bold">Total: ${{ total.toLocaleString() }}</div>
<v-select v-model="formaPago" :items="['EFECTIVO', 'QR']" label="Forma de Pago" class="mt-3"></v-select>
<v-text-field v-if="formaPago === 'EFECTIVO'" v-model.number="efectivoRecibido" type="number" label="Efectivo recibido" prefix="$" prepend-inner-icon="mdi-cash" />
<v-alert v-if="formaPago === 'EFECTIVO' && efectivoRecibido > 0" :type="devuelta >= 0 ? 'success' : 'warning'" variant="tonal" class="mb-2">{{ devuelta >= 0 ? `Devuelta: $${devuelta.toLocaleString()}` : `Faltan $${Math.abs(devuelta).toLocaleString()}` }}</v-alert>
<v-alert v-if="error" type="error" variant="tonal" density="compact" class="mb-2">{{ error }}</v-alert>
<v-alert v-if="exito" type="success" variant="tonal" density="compact" class="mb-2">{{ exito }}</v-alert>
<v-btn @click="registrarVenta" color="success" block size="large" :disabled="lineas.length === 0 || !jornadaAbierta" class="mt-2" prepend-icon="mdi-cash-register">Cobrar ${{ total.toLocaleString() }}</v-btn>
<v-btn @click="registrarVenta" color="success" block size="large" :disabled="lineas.length === 0 || !jornadaAbierta || (formaPago === 'EFECTIVO' && devuelta < 0)" class="mt-2" prepend-icon="mdi-cash-register">Cobrar ${{ total.toLocaleString() }}</v-btn>
</v-card-text>
</v-card>
</v-col>
</v-row>
<v-card class="mt-4"><v-card-title>Ventas recientes</v-card-title><v-card-text><v-list v-if="ventas.length"><v-list-item v-for="venta in ventas" :key="venta.id" :title="`Venta $${venta.total.toLocaleString()}`" :subtitle="`${new Date(venta.fecha).toLocaleString()} · ${venta.lineas.length} productos`"><template #append><v-btn variant="tonal" size="small" prepend-icon="mdi-pencil" @click="editarVenta(venta)">Editar</v-btn></template></v-list-item></v-list><v-alert v-else type="info" variant="tonal">Las ventas registradas durante el día aparecerán aquí.</v-alert></v-card-text></v-card>
<v-dialog v-model="dialogoVenta" max-width="560"><v-card><v-card-title>Editar venta</v-card-title><v-card-text><v-list><v-list-item v-for="linea in ventaEditada.lineas" :key="linea.id" :title="getProdNombre(linea.productoId)"><template #append><v-text-field v-model.number="linea.cantidad" type="number" label="Cantidad" density="compact" hide-details style="max-width: 130px" /></template></v-list-item></v-list><v-divider class="my-3" /><v-text-field v-model="actorVenta" label="Quién realiza el cambio" /><v-textarea v-model="motivoVenta" label="Motivo del cambio" rows="2" /></v-card-text><v-card-actions><v-spacer /><v-btn variant="text" @click="dialogoVenta = false">Cancelar</v-btn><v-btn color="primary" @click="guardarVenta">Guardar cambio</v-btn></v-card-actions></v-card></v-dialog>
<v-dialog v-model="catalogoDialog" fullscreen><v-card><v-toolbar color="primary"><v-btn icon="mdi-close" aria-label="Cerrar catálogo" @click="catalogoDialog = false" /><v-toolbar-title>Seleccionar producto</v-toolbar-title></v-toolbar><v-card-text><v-text-field v-model="busqueda" label="Buscar producto o productor" prepend-inner-icon="mdi-magnify" clearable autofocus /><v-list><v-list-item v-for="prod in productosFiltrados" :key="prod.id" :title="prod.nombrePresentacion" :subtitle="`Disponible: ${prod.stock} · $${prod.precioUnitario.toLocaleString()}`" :disabled="prod.stock <= 0" @click="agregarDesdeDialogo(prod)"><template #append><v-icon icon="mdi-plus-circle" /></template></v-list-item></v-list></v-card-text></v-card></v-dialog>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue';
import { ref, computed, onMounted, onUnmounted } from 'vue';
import { db } from '@toldo/data';
import { VentasService, JornadaService, EdicionService } from '@toldo/services';
import { calcularDevuelta, validarPagoEfectivo } from '@toldo/core';
const ventasService = new VentasService();
const jornadaService = new JornadaService();
@@ -71,6 +75,10 @@ const stockByProduct = ref<Record<string, number>>({});
const lineas = ref<any[]>([]);
const formaPago = ref('EFECTIVO');
const jornadaAbierta = ref<any>(null);
const isMobile = ref(false);
const catalogoDialog = ref(false);
const efectivoRecibido = ref(0);
const devuelta = computed(() => calcularDevuelta(total.value, efectivoRecibido.value));
const busqueda = ref('');
const productosFiltrados = computed(() => {
const termino = busqueda.value.trim().toLocaleLowerCase();
@@ -102,6 +110,8 @@ function getProdNombre(id: string) {
return productos.value.find(p => p.id === id)?.nombrePresentacion || id;
}
function getStock(id: string) { return stockByProduct.value[id] ?? 0; }
function updateViewport() { isMobile.value = window.innerWidth < 960; }
function agregarDesdeDialogo(prod: any) { agregarProducto(prod); if (!error.value) catalogoDialog.value = false; }
function agregarProducto(prod: any) {
if (getStock(prod.id) <= 0) { error.value = 'Este producto no tiene inventario disponible. Registra primero el inventario inicial.'; return; }
@@ -154,6 +164,7 @@ async function registrarVenta() {
error.value = 'Debe abrir una jornada antes de registrar ventas.';
return;
}
if (formaPago.value === 'EFECTIVO') { const errorPago = validarPagoEfectivo(total.value, efectivoRecibido.value); if (errorPago) { error.value = errorPago; return; } }
const ventaId = crypto.randomUUID();
const lineasVenta = lineas.value.map(l => ({ ...l, ventaId, formaPago: formaPago.value }));
const venta = {
@@ -167,11 +178,13 @@ async function registrarVenta() {
try {
await jornadaService.registrarVentaEnJornada(jornadaAbierta.value.id, venta, lineasVenta);
lineas.value = [];
efectivoRecibido.value = 0;
exito.value = `Venta registrada por $${total.value.toLocaleString()} en ${formaPago.value}.`;
} catch (e: any) {
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;
}
}
onMounted(cargar);
onMounted(() => { updateViewport(); window.addEventListener('resize', updateViewport); cargar(); });
onUnmounted(() => window.removeEventListener('resize', updateViewport));
</script>

View File

@@ -21,13 +21,11 @@ test('permite volver a inicio desde Jornada e Inventario', async ({ page }) => {
if ((await page.viewportSize())!.width < 960) await page.getByTestId('navigation-toggle').click();
await page.getByRole('link', { name: 'Jornada', exact: true }).click();
await expect(page.getByText('Jornada del Toldo')).toBeVisible();
await expect(page.getByLabel('Migas de pan')).toContainText('Inicio');
await page.getByRole('link', { name: 'Inicio' }).last().click();
await expect(page.getByText('Panel de control')).toBeVisible();
if ((await page.viewportSize())!.width < 960) await page.getByTestId('navigation-toggle').click();
await page.getByRole('link', { name: 'Inventario', exact: true }).click();
await expect(page.getByText('Inventario y Productos')).toBeVisible();
await expect(page.locator('.mdi-home-outline')).toBeVisible();
await page.getByTestId('back-button').click();
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,25 @@
# Design
## Context
El Layout ya tiene menú y navegación suficiente; breadcrumbs duplican acciones. `JornadaService.cerrarJornada` crea un cuadre sin saldo real, mientras `Cuadre.vue` obliga a trabajar en otra ruta. POS muestra el catálogo completo antes del carrito.
## Goals / Non-Goals
**Goals:** priorizar carrito y cobro en móvil, mantener operaciones densas separadas por tabs, y hacer que el cierre sea una operación única con datos completos.
**Non-Goals:** eliminar la capacidad de consultar cuadres históricos ni cambiar el esquema de pagos.
## Decisions
- **POS:** mostrar búsqueda y botón de selección; el catálogo completo vive en un `v-dialog` con lista filtrable. El carrito permanece visible arriba en móvil y en columna fija en pantallas grandes.
- **Devuelta:** sólo se solicita para efectivo; se calcula como recibido menos total y no permite confirmar si es negativo.
- **Cierre:** `JornadaService.cerrarJornada` recibe efectivo contado obligatorio y banco QR opcional, calcula diferencias, guarda `CuadreCaja` y marca la jornada cerrada en una transacción.
- **Inventario:** tabs `Catálogo`, `Ingresos` y `Ajustes` en móvil; el menú deja de exponer Ajustes como módulo aislado.
- **Vuetify:** reemplazar inputs/select/button HTML de Gastos y Ajustes por `v-form`, `v-text-field`, `v-select`, `v-btn`, `v-list` y alertas.
## Risks / Trade-offs
- [Cierre incompleto] El usuario puede no conocer el banco -> QR es opcional y se muestra como pendiente, sin bloquear el cierre.
- [Cambio de API] Cerrar jornadas existentes sin saldo real requiere migración de comportamiento -> los registros históricos permanecen intactos.
- [Modal POS] Un modal añade un paso -> evita que el carrito desaparezca y permite buscar entre catálogos grandes.

View File

@@ -0,0 +1,31 @@
# Proposal
## Why
En celulares el POS ocupa toda la pantalla con el catálogo y el carrito queda fuera de vista. Además, breadcrumbs ya no son necesarios, Gastos y Ajustes conservan HTML nativo, y el cierre de caja está separado de la jornada aunque ambos deben ser una única operación de cierre.
## What Changes
- Retirar breadcrumbs y usar tabs responsive en Inventario y otras secciones densas.
- Convertir el selector de productos del POS en una búsqueda compacta con modal/lista de productos y carrito siempre accesible.
- Rediseñar Gastos y Ajustes con componentes Vuetify.
- Mover el ajuste de inventario al flujo de Inventario y retirar Ajustes como operación principal del menú.
- Fusionar cierre de caja y cierre de jornada con efectivo contado obligatorio y banco QR opcional.
- Calcular devuelta para pagos en efectivo antes de confirmar la venta.
## Capabilities
### New Capabilities
- `mobile-pos-flow`: selección de productos, carrito y devuelta optimizados para pantalla pequeña.
- `unified-day-closing`: cierre atómico de caja y jornada.
### Modified Capabilities
- `toldo-poc`: navegación, inventario, gastos, ajustes y reportes operativos.
## Impact
- Vistas Vue/Vuetify de POS, Inventario, Gastos, Ajustes y Jornada.
- `JornadaService` y modelos de cuadre.
- Tests de casos de uso, Playwright y APK.

View File

@@ -0,0 +1,28 @@
# Spec Delta
## Purpose
Permite vender cómodamente desde celulares mostrando el carrito, la búsqueda de productos y la devuelta sin obligar a recorrer una lista larga.
## ADDED Requirements
### Requirement: Catálogo compacto en POS
The system SHALL keep the cart visible and allow selecting products through a searchable compact list or dialog.
#### Scenario: Selección en móvil
- **WHEN** user opens Ventas on a narrow viewport
- **THEN** the cart and total remain visible
- **AND** user can search and add a product from the catalog dialog
### Requirement: Calcular devuelta
The system SHALL calculate and display change for cash payments before saving a sale.
#### Scenario: Pago en efectivo
- **GIVEN** sale total is 12000
- **WHEN** user enters 20000 as received cash
- **THEN** the UI displays change of 8000
- **AND** the sale can be confirmed
#### Scenario: Efectivo insuficiente
- **WHEN** received cash is below the sale total
- **THEN** confirmation is disabled and an actionable message is shown

View File

@@ -0,0 +1,28 @@
# Spec Delta
## Purpose
Unifica la verificación del efectivo y pagos QR con el cierre de la jornada, evitando que el usuario cierre una jornada sin cuadrar los valores disponibles.
## ADDED Requirements
### Requirement: Cierre unificado
The system SHALL require counted cash and optionally accept the QR bank total before closing a day.
#### Scenario: Cierre con efectivo
- **GIVEN** an open day with recorded sales and expenses
- **WHEN** user enters counted cash and confirms closing
- **THEN** cash difference is calculated and saved
- **AND** the cash reconciliation and day are closed together
#### Scenario: Cierre con banco QR pendiente
- **WHEN** user closes with counted cash and no bank total
- **THEN** the day closes
- **AND** QR bank comparison remains explicitly pending
### Requirement: Ajuste dentro de inventario
The system SHALL expose inventory adjustments from the inventory area with product quantity and reason.
#### Scenario: Ajuste desde inventario
- **WHEN** user selects a product, enters a new quantity and reason
- **THEN** the inventory balance changes and the reason is recorded

View File

@@ -0,0 +1,24 @@
# Tasks
## 1. POS móvil y cobro
- [x] 1.1 Rediseñar POS con carrito visible, selección en diálogo/lista filtrable y tabs responsive
- [x] 1.2 Añadir efectivo recibido, devuelta y validación de pago insuficiente
- [x] 1.3 Cubrir selección móvil y devuelta con tests unitarios/E2E
## 2. Cierre de jornada
- [x] 2.1 Cambiar servicio de cierre para recibir efectivo obligatorio y banco QR opcional en una transacción
- [x] 2.2 Rediseñar Jornada con formulario de cierre y diferencias visibles
- [x] 2.3 Retirar Cierre de caja del menú y cubrir cierre efectivo/QR/pending con tests
## 3. Inventario y formularios
- [x] 3.1 Mover Ajustes al área Inventario con tabs y quitar módulo independiente del menú
- [x] 3.2 Convertir Gastos y Ajustes a formularios Vuetify
- [x] 3.3 Añadir pruebas de regresión de inventario, gastos y ajustes
## 4. Verificación y entrega
- [x] 4.1 Ejecutar tests unitarios, `vue-tsc` y Playwright responsive
- [x] 4.2 Generar APK y verificar metadatos/flujo de cierre

View File

@@ -1,4 +1,5 @@
export * from './entities';
export * from './rules/cuadreRules';
export * from './rules/jornadaRules';
export * from './rules/ventaRules';
export * from './entities/JornadaToldo';

View File

@@ -0,0 +1,8 @@
export function calcularDevuelta(total: number, recibido: number) {
return recibido - total;
}
export function validarPagoEfectivo(total: number, recibido: number) {
if (!Number.isFinite(recibido) || recibido < total) return 'El efectivo recibido no alcanza para cubrir la venta';
return null;
}

View File

@@ -60,13 +60,12 @@ export class JornadaService {
return gastoId;
}
async cerrarJornada(jornadaId: string, cuadreId?: string): Promise<string> {
async cerrarJornada(jornadaId: string, saldoRealEfectivo: number, totalQRBanco?: number): Promise<string> {
if (!Number.isFinite(saldoRealEfectivo) || saldoRealEfectivo < 0) throw new Error('El efectivo contado es obligatorio y no puede ser negativo');
const jornada: any = await db.jornadas.get(jornadaId);
if (!jornada) throw new Error('Jornada no encontrada');
if (jornada.estado === 'CERRADA') throw new Error('Jornada ya cerrada');
let cid = cuadreId;
if (!cid) {
const cuadreParcial = await this.cuadreService.calcularCuadreDelDia(jornada.fecha, jornada.baseInicial);
const cuadre: CuadreCaja = {
id: crypto.randomUUID(),
@@ -78,17 +77,19 @@ export class JornadaService {
totalGastosQR: cuadreParcial.totalGastosQR || 0,
saldoEsperadoEfectivo: cuadreParcial.saldoEsperadoEfectivo || 0,
totalVentas: cuadreParcial.totalVentas || 0,
saldoRealEfectivo,
totalQRBanco,
createdAt: new Date()
} as CuadreCaja;
cid = await this.cuadreService.guardarCuadre(cuadre);
cuadre.descuadreEfectivo = saldoRealEfectivo - cuadre.saldoEsperadoEfectivo;
if (totalQRBanco !== undefined) {
cuadre.totalQRRegistrado = cuadre.totalVentasQR - cuadre.totalGastosQR;
cuadre.descuadreQR = totalQRBanco - cuadre.totalQRRegistrado;
}
await db.jornadas.put({
...jornada,
estado: 'CERRADA',
fechaCierre: new Date(),
cuadreId: cid,
updatedAt: new Date()
const cid = cuadre.id;
await db.transaction('rw', db.jornadas, db.cuadres, async () => {
await db.cuadres.add(cuadre);
await db.jornadas.put({ ...jornada, estado: 'CERRADA', fechaCierre: new Date(), cuadreId: cid, updatedAt: new Date() });
});
return cid;
}

View File

@@ -30,7 +30,7 @@ describe('Caso de uso jornada', () => {
expect(resumen?.totalVentas).toBe(30000);
expect(resumen?.totalGastos).toBe(5000);
const cuadreId = await service.cerrarJornada(jid);
const cuadreId = await service.cerrarJornada(jid, 125000);
expect(cuadreId).toBeTruthy();
const abierta = await service.getJornadaAbierta();