feat: mejorar ux y validaciones del toldo

This commit is contained in:
mono
2026-10-25 18:09:01 -05:00
parent 4ffce3e3cd
commit 8e286d6fec
24 changed files with 387 additions and 121 deletions

View File

@@ -22,6 +22,7 @@
},
"devDependencies": {
"@capacitor/cli": "^6.2.1",
"@playwright/test": "^1.64.1",
"@testing-library/vue": "^8.1.0",
"@types/node": "^24.19.2",
"@vitejs/plugin-vue": "^6.0.10",
@@ -948,6 +949,21 @@
"url": "https://github.com/sponsors/oxc-project"
}
},
"node_modules/@playwright/test": {
"version": "1.64.1",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.64.1.tgz",
"integrity": "sha512-IaYGml/nSOiZWROfPtuIL7neuu9qDOZ3TNvNljUIrhDMxiGTgPjzj/g9p9RxpS2tSepPDRY+UwAaEeiKtUvIAg==",
"dev": true,
"dependencies": {
"playwright": "1.64.1"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=20"
}
},
"node_modules/@rolldown/binding-android-arm-eabi": {
"version": "1.2.15",
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.15.tgz",
@@ -4039,6 +4055,33 @@
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/playwright": {
"version": "1.64.1",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.64.1.tgz",
"integrity": "sha512-HCrVhHr5QQuwYoswEUGTb7gGp522yflbv/ST8nF4sFWGuLjQCt17Q/EubpGGSNKYn1tsTCPZxqHhHCfVURu7UA==",
"dev": true,
"dependencies": {
"playwright-core": "1.64.1"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=20"
}
},
"node_modules/playwright-core": {
"version": "1.64.1",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.64.1.tgz",
"integrity": "sha512-BC4s8y6SEVA6Q1rSxZTto8hI1KUI3xXlHGZgvGqP4IqPdI/a6FX9H29IDNz7vKo9l21b6LfYosScPL6M3xxyDQ==",
"dev": true,
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=20"
}
},
"node_modules/plist": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/plist/-/plist-3.1.1.tgz",

View File

@@ -8,7 +8,8 @@
"build": "vite build",
"preview": "vite preview",
"test": "vitest",
"test:run": "vitest run"
"test:run": "vitest run",
"test:e2e": "playwright test"
},
"dependencies": {
"@capacitor/android": "^6.2.1",
@@ -25,6 +26,7 @@
},
"devDependencies": {
"@capacitor/cli": "^6.2.1",
"@playwright/test": "^1.64.1",
"@testing-library/vue": "^8.1.0",
"@types/node": "^24.19.2",
"@vitejs/plugin-vue": "^6.0.10",

View File

@@ -0,0 +1,9 @@
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
testDir: './tests/e2e',
timeout: 30_000,
use: { baseURL: 'http://127.0.0.1:4173', trace: 'on-first-retry' },
webServer: { command: 'npm run dev -- --host 127.0.0.1 --port 4173', url: 'http://127.0.0.1:4173', reuseExistingServer: true },
projects: [{ name: 'chromium', use: { ...devices['Desktop Chrome'], launchOptions: { executablePath: '/usr/bin/chromium' } } }, { name: 'mobile', use: { ...devices['Pixel 5'], launchOptions: { executablePath: '/usr/bin/chromium' } } }],
});

View File

@@ -0,0 +1,22 @@
import { describe, expect, it } from 'vitest';
import { ExportImportService } from '@toldo/services';
describe('contratos de operación y respaldo', () => {
it('exporta jornadas junto con los datos operativos', () => {
const service = new ExportImportService();
const result = service.exportAll({
productores: [], productos: [], movimientosInventario: [], ventas: [], ventaLineas: [],
gastos: [], ajustes: [], cuadres: [], jornadas: [{ id: 'day-1' } as any],
});
expect(result.data.jornadas).toHaveLength(1);
expect(service.import(result).data.jornadas?.[0].id).toBe('day-1');
});
it('acepta respaldos v1 sin jornadas y los migra al contrato actual', () => {
const legacy = {
schemaVersion: 1, appVersion: '0.0.1', exportedAt: new Date().toISOString(),
data: { productores: [], productos: [], movimientosInventario: [], ventas: [], ventaLineas: [], gastos: [], ajustes: [], cuadres: [] },
};
expect(new ExportImportService().import(legacy).data.jornadas).toEqual([]);
});
});

View File

@@ -1,21 +1,16 @@
<template>
<v-app>
<v-app-bar color="primary" density="compact">
<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-app-bar-title>ToldoConjunto - CESTA</v-app-bar-title>
<v-spacer></v-spacer>
<v-chip v-if="jornada" color="success" variant="tonal" class="mr-3" prepend-icon="mdi-circle">Jornada activa</v-chip>
</v-app-bar>
<v-navigation-drawer permanent>
<v-list density="compact">
<v-list-item to="/" prepend-icon="mdi-home" title="Inicio"></v-list-item>
<v-list-item to="/jornada" prepend-icon="mdi-calendar-check" title="Jornada (Apertura/Cierre)"></v-list-item>
<v-list-item to="/inventario" prepend-icon="mdi-package-variant" title="Inventario"></v-list-item>
<v-list-item to="/ventas" prepend-icon="mdi-cart" title="Ventas (POS)"></v-list-item>
<v-list-item to="/gastos" prepend-icon="mdi-cash-minus" title="Gastos"></v-list-item>
<v-list-item to="/cuadre" prepend-icon="mdi-cash-register" title="Cierre de Caja"></v-list-item>
<v-list-item to="/ajustes" prepend-icon="mdi-pencil" title="Ajustes"></v-list-item>
<v-list-item to="/reportes" prepend-icon="mdi-chart-bar" title="Reportes"></v-list-item>
<v-list-item to="/exportar" prepend-icon="mdi-export" title="Exportar/Importar"></v-list-item>
<v-navigation-drawer v-model="drawer" :permanent="!isMobile" :temporary="isMobile">
<v-list density="comfortable" nav>
<v-list-subheader>OPERACION</v-list-subheader>
<v-list-item to="/" prepend-icon="mdi-home" title="Inicio" @click="closeOnMobile"></v-list-item>
<v-list-item v-for="item in navItems" :key="item.to" :to="item.to" :prepend-icon="item.icon" :title="item.title" @click="closeOnMobile"></v-list-item>
</v-list>
</v-navigation-drawer>
@@ -26,3 +21,27 @@
</v-main>
</v-app>
</template>
<script setup lang="ts">
import { onMounted, onUnmounted, ref } from 'vue';
import { JornadaService } from '@toldo/services';
const drawer = ref(true);
const isMobile = ref(false);
const jornada = ref<any>(null);
const jornadaService = new JornadaService();
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' },
];
function updateViewport() { isMobile.value = window.innerWidth < 960; if (isMobile.value) drawer.value = false; }
function closeOnMobile() { if (isMobile.value) drawer.value = false; }
onMounted(async () => { updateViewport(); window.addEventListener('resize', updateViewport); jornada.value = await jornadaService.getJornadaAbierta(); });
onUnmounted(() => window.removeEventListener('resize', updateViewport));
</script>

View File

@@ -14,6 +14,12 @@ export default createVuetify({
primary: '#2d5f3b',
secondary: '#4a7c59',
accent: '#6b8e23',
surface: '#fffdf8',
background: '#f5f7f2',
info: '#286c7a',
},
variables: {
'border-radius-root': '12px',
},
},
},

View File

@@ -17,7 +17,7 @@
font: 18px/145% var(--sans);
letter-spacing: 0.18px;
color-scheme: light dark;
color-scheme: light;
color: var(--text);
background: var(--bg);
font-synthesis: none;
@@ -52,6 +52,8 @@
body {
margin: 0;
min-width: 320px;
background: #f5f7f2;
}
h1,
@@ -156,17 +158,7 @@ code {
}
}
#app {
width: 1126px;
max-width: 100%;
margin: 0 auto;
text-align: center;
border-inline: 1px solid var(--border);
min-height: 100svh;
display: flex;
flex-direction: column;
box-sizing: border-box;
}
#app { min-height: 100svh; }
#center {
display: flex;

View File

@@ -1,6 +1,7 @@
<template>
<div>
<h2>Ajustes</h2>
<v-alert v-if="mensaje" :type="tipoMensaje" variant="tonal" class="mb-3">{{ mensaje }}</v-alert>
<p><strong>Requiere motivo obligatorio</strong></p>
<section>
<select v-model="productoSel">
@@ -33,6 +34,8 @@ const productoSel = ref('');
const cantidadNueva = ref(0);
const motivo = ref('');
const ajustes = ref<any[]>([]);
const mensaje = ref('');
const tipoMensaje = ref<'success' | 'error'>('success');
async function cargar() {
productos.value = await db.productos.toArray();
@@ -42,7 +45,8 @@ async function cargar() {
async function ajustar() {
try {
validateAjusteMotivo(motivo.value);
if (!productoSel.value) return;
if (!productoSel.value) { tipoMensaje.value = 'error'; mensaje.value = 'Selecciona un producto.'; return; }
if (cantidadNueva.value < 0) { tipoMensaje.value = 'error'; mensaje.value = 'La cantidad no puede ser negativa.'; return; }
const saldoActual = await invService.calcularSaldoProducto(productoSel.value);
const diferencia = cantidadNueva.value - saldoActual;
await ajustesService.crearAjuste({
@@ -58,9 +62,9 @@ async function ajustar() {
motivo.value = '';
cantidadNueva.value = 0;
await cargar();
alert('Ajuste registrado con motivo');
tipoMensaje.value = 'success'; mensaje.value = 'Ajuste registrado con motivo.';
} catch (e: any) {
alert(e.message);
tipoMensaje.value = 'error'; mensaje.value = e.message;
}
}

View File

@@ -1,29 +1,16 @@
<template>
<div>
<h2>Cuadre de Caja</h2>
<section>
<label>Base Inicial: </label>
<input v-model.number="baseInicial" type="number" />
<button @click="calcular">Calcular Cuadre</button>
</section>
<div class="mb-5"><div class="text-overline text-primary">CIERRE DEL DÍA</div><h1 class="text-h4 mb-1">Cuadre de caja</h1><div class="text-body-2 text-medium-emphasis">Compara lo registrado con el efectivo y el banco.</div></div>
<v-card class="mb-4"><v-card-text><v-row align="end"><v-col cols="12" sm="6"><v-text-field v-model.number="baseInicial" type="number" label="Base inicial" prefix="$" /></v-col><v-col cols="12" sm="6"><v-btn @click="calcular" color="primary" block size="large" prepend-icon="mdi-calculator">Calcular cuadre</v-btn></v-col></v-row></v-card-text></v-card>
<section v-if="cuadre">
<h3>Resultado</h3>
<p>Ventas Efectivo: ${{ cuadre.totalVentasEfectivo }}</p>
<p>Ventas QR: ${{ cuadre.totalVentasQR }}</p>
<p>Gastos Efectivo: ${{ cuadre.totalGastosEfectivo }}</p>
<p>Gastos QR: ${{ cuadre.totalGastosQR }}</p>
<p><strong>Saldo Esperado Efectivo: ${{ cuadre.saldoEsperadoEfectivo }}</strong></p>
<p>Total Ventas: ${{ cuadre.totalVentas }}</p>
<label>Saldo Real en Efectivo: </label>
<input v-model.number="saldoReal" type="number" />
<button @click="guardar">Guardar Cuadre</button>
<p v-if="descuadre !== null">Descuadre: ${{ descuadre }}</p>
<v-row><v-col v-for="item in resumen" :key="item.label" cols="6" md="3"><v-card variant="tonal"><v-card-text><div class="text-caption">{{ item.label }}</div><div class="text-h6 font-weight-bold">${{ item.value.toLocaleString() }}</div></v-card-text></v-card></v-col></v-row>
<v-card class="mt-4"><v-card-title>Verificación final</v-card-title><v-card-text><v-row><v-col cols="12" md="6"><v-text-field v-model.number="saldoReal" type="number" label="Efectivo contado" prefix="$" /></v-col><v-col cols="12" md="6"><v-text-field v-model.number="totalQRBanco" type="number" label="Total QR según banco" prefix="$" /></v-col></v-row><v-alert v-if="descuadre !== null" :type="descuadre === 0 ? 'success' : 'warning'" variant="tonal" class="mb-3">Diferencia de efectivo: ${{ descuadre.toLocaleString() }}</v-alert><v-btn @click="guardar" color="success" block size="large" prepend-icon="mdi-content-save">Guardar cuadre</v-btn></v-card-text></v-card>
</section>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { ref, computed } from 'vue';
import { CuadreService } from '@toldo/services';
const cuadreService = new CuadreService();
@@ -31,6 +18,11 @@ const baseInicial = ref(0);
const cuadre = ref<any>(null);
const saldoReal = ref(0);
const descuadre = ref<number | null>(null);
const totalQRBanco = ref(0);
const resumen = computed(() => cuadre.value ? [
{ label: 'Ventas efectivo', value: cuadre.value.totalVentasEfectivo }, { label: 'Ventas QR', value: cuadre.value.totalVentasQR },
{ label: 'Gastos efectivo', value: cuadre.value.totalGastosEfectivo }, { label: 'Saldo esperado', value: cuadre.value.saldoEsperadoEfectivo },
] : []);
async function calcular() {
cuadre.value = await cuadreService.calcularCuadreDelDia(new Date(), baseInicial.value);
@@ -39,6 +31,7 @@ async function calcular() {
async function guardar() {
if (!cuadre.value) return;
cuadre.value.saldoRealEfectivo = saldoReal.value;
cuadre.value.totalQRBanco = totalQRBanco.value;
await cuadreService.guardarCuadre({ ...cuadre.value, id: crypto.randomUUID(), createdAt: new Date() });
descuadre.value = saldoReal.value - cuadre.value.saldoEsperadoEfectivo;
alert('Cuadre guardado');

View File

@@ -1,16 +1,13 @@
<template>
<div>
<h2>Exportar / Importar</h2>
<div class="mb-5"><div class="text-overline text-primary">DATOS LOCALES</div><h1 class="text-h4 mb-1">Exportar e importar</h1><div class="text-body-2 text-medium-emphasis">Haz copias de seguridad para continuar trabajando sin internet.</div></div>
<section>
<h3>Exportar Datos</h3>
<button @click="exportar">Exportar JSON</button>
<v-card class="mb-4"><v-card-title>Exportar copia de seguridad</v-card-title><v-card-text><p class="mb-3">Incluye catálogo, movimientos, ventas, gastos, cuadres y jornadas.</p><v-btn @click="exportar" color="primary" prepend-icon="mdi-download">Descargar JSON</v-btn></v-card-text></v-card>
</section>
<section>
<h3>Importar Datos</h3>
<input type="file" accept=".json" @change="onFileChange" />
<button @click="importar" :disabled="!archivo">Importar JSON</button>
<v-card><v-card-title>Restaurar datos</v-card-title><v-card-text><v-file-input accept=".json" label="Seleccionar copia JSON" @update:model-value="archivo = $event" /><v-alert v-if="mensaje" :type="tipoMensaje" variant="tonal" class="mb-3">{{ mensaje }}</v-alert><v-btn @click="importar" :disabled="!archivo" color="secondary" prepend-icon="mdi-upload">Importar datos</v-btn></v-card-text></v-card>
</section>
</div>
</template>
@@ -22,6 +19,8 @@ import { ExportImportService } from '@toldo/services';
const exportService = new ExportImportService();
const archivo = ref<File | null>(null);
const mensaje = ref('');
const tipoMensaje = ref<'success' | 'error'>('success');
function onFileChange(e: any) {
archivo.value = e.target.files[0];
@@ -37,6 +36,7 @@ async function exportar() {
gastos: await db.gastos.toArray(),
ajustes: await db.ajustes.toArray(),
cuadres: await db.cuadres.toArray(),
jornadas: await db.jornadas.toArray(),
};
const exp = exportService.exportAll(data);
const blob = new Blob([JSON.stringify(exp, null, 2)], { type: 'application/json' });
@@ -62,9 +62,10 @@ async function importar() {
if (imp.data.gastos.length) await db.gastos.bulkPut(imp.data.gastos);
if (imp.data.ajustes.length) await db.ajustes.bulkPut(imp.data.ajustes);
if (imp.data.cuadres.length) await db.cuadres.bulkPut(imp.data.cuadres);
alert('Datos importados correctamente');
if (imp.data.jornadas?.length) await db.jornadas.bulkPut(imp.data.jornadas);
tipoMensaje.value = 'success'; mensaje.value = 'Datos importados correctamente.';
} catch (e: any) {
alert(e.message);
tipoMensaje.value = 'error'; mensaje.value = e.message;
}
}
</script>

View File

@@ -1,6 +1,7 @@
<template>
<div>
<h2>Gastos</h2>
<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>
@@ -32,6 +33,8 @@ const monto = ref(0);
const formaPago = ref<'EFECTIVO' | 'QR'>('EFECTIVO');
const gastos = ref<any[]>([]);
const jornadaAbierta = ref<any>(null);
const mensaje = ref('');
const tipoMensaje = ref<'success' | 'error'>('success');
async function cargar() {
gastos.value = await gastosService.getGastosDelDia(new Date());
@@ -39,9 +42,9 @@ async function cargar() {
}
async function registrar() {
if (!concepto.value || monto.value <= 0) return;
if (!concepto.value.trim() || monto.value <= 0) { tipoMensaje.value = 'error'; mensaje.value = 'Escribe un concepto y un monto mayor que cero.'; return; }
if (!jornadaAbierta.value) {
alert('Debe abrir una jornada primero (Jornada > Apertura)');
tipoMensaje.value = 'error'; mensaje.value = 'Debe abrir una jornada antes de registrar gastos.';
return;
}
try {
@@ -56,9 +59,9 @@ async function registrar() {
concepto.value = '';
monto.value = 0;
await cargar();
alert('Gasto registrado');
tipoMensaje.value = 'success'; mensaje.value = 'Gasto registrado.';
} catch (e: any) {
alert(e.message);
tipoMensaje.value = 'error'; mensaje.value = e.message;
}
}

View File

@@ -1,35 +1,38 @@
<template>
<div>
<h1 class="text-h4 mb-4">Panel de Control</h1>
<div class="d-flex flex-wrap align-center justify-space-between mb-5 ga-3">
<div><div class="text-overline text-primary">CESTA · TOLDO DOMINICAL</div><h1 class="text-h4 mb-1">Panel de control</h1><div class="text-body-2 text-medium-emphasis">Resumen de la operación de hoy</div></div>
<v-btn to="/ventas" color="primary" prepend-icon="mdi-plus">Nueva venta</v-btn>
</div>
<v-row>
<v-col cols="12" md="3">
<v-card>
<v-card class="summary-card" color="surface">
<v-card-text>
<div class="text-h6">Ventas del día</div>
<div class="text-h4">${{ totalVentas }}</div>
<div class="text-body-2 text-medium-emphasis">Ventas del día</div>
<div class="text-h4 font-weight-bold text-primary">${{ totalVentas.toLocaleString() }}</div>
</v-card-text>
</v-card>
</v-col>
<v-col cols="12" md="3">
<v-card>
<v-card class="summary-card" color="surface">
<v-card-text>
<div class="text-h6">Gastos del día</div>
<div class="text-h4">${{ totalGastos }}</div>
<div class="text-body-2 text-medium-emphasis">Gastos del día</div>
<div class="text-h4 font-weight-bold">${{ totalGastos.toLocaleString() }}</div>
</v-card-text>
</v-card>
</v-col>
<v-col cols="12" md="3">
<v-card>
<v-card class="summary-card" color="surface">
<v-card-text>
<div class="text-h6">Productos</div>
<div class="text-body-2 text-medium-emphasis">Productos</div>
<div class="text-h4">{{ productos.length }}</div>
</v-card-text>
</v-card>
</v-col>
<v-col cols="12" md="3">
<v-card>
<v-card class="summary-card" color="surface">
<v-card-text>
<div class="text-h6">Productores</div>
<div class="text-body-2 text-medium-emphasis">Productores</div>
<div class="text-h4">{{ productores.length }}</div>
</v-card-text>
</v-card>
@@ -37,26 +40,31 @@
</v-row>
<v-row class="mt-4">
<v-col cols="12">
<v-alert v-if="jornada" type="success">Jornada ACTIVA | Responsables: {{ (jornada.responsables||[]).join(', ') }} | Base: ${{ jornada.baseInicial }}</v-alert>
<v-alert v-else type="warning">Sin jornada activa. <router-link to="/jornada">Abrir jornada</router-link></v-alert>
<v-alert v-if="jornada" type="success" variant="tonal" title="Jornada activa" prepend-icon="mdi-check-circle">Responsables: {{ (jornada.responsables||[]).join(', ') }} · Base: ${{ jornada.baseInicial.toLocaleString() }}</v-alert>
<v-alert v-else type="warning" variant="tonal" title="Aún no hay jornada abierta" text="Abre la jornada para registrar ventas y gastos."><template #append><v-btn to="/jornada" color="warning" variant="flat">Abrir jornada</v-btn></template></v-alert>
</v-col>
</v-row>
<v-row class="mt-4">
<v-col>
<v-card>
<v-card-title>Acciones Rápidas</v-card-title>
<v-card-title>Comenzar</v-card-title>
<v-card-text>
<v-btn to="/jornada" color="success" class="mr-2">Jornada (Apertura/Cierre)</v-btn>
<v-btn to="/ventas" color="primary" class="mr-2">Nueva Venta</v-btn>
<v-btn to="/gastos" color="secondary" class="mr-2">Registrar Gasto</v-btn>
<v-btn to="/inventario" color="accent">Gestionar Inventario</v-btn>
<v-btn to="/jornada" color="success" variant="tonal" class="mr-2 mb-2" prepend-icon="mdi-calendar-check">Abrir jornada</v-btn>
<v-btn to="/ventas" color="primary" variant="tonal" class="mr-2 mb-2" prepend-icon="mdi-cart-plus">Registrar venta</v-btn>
<v-btn to="/gastos" color="secondary" variant="tonal" class="mr-2 mb-2" prepend-icon="mdi-cash-minus">Registrar gasto</v-btn>
<v-btn to="/inventario" color="accent" variant="tonal" class="mb-2" prepend-icon="mdi-package-variant">Gestionar inventario</v-btn>
</v-card-text>
</v-card>
</v-col>
</v-row>
<v-alert v-if="!productos.length || !productores.length" type="info" variant="tonal" class="mt-4" title="Configura tu catálogo" text="Crea un productor y al menos un producto antes de registrar ventas."><template #append><v-btn to="/inventario" color="info">Ir a inventario</v-btn></template></v-alert>
</div>
</template>
<style scoped>
.summary-card { border-top: 3px solid rgb(var(--v-theme-primary)); }
</style>
<script setup lang="ts">
import { ref, onMounted } from 'vue';
import { db } from '@toldo/data';

View File

@@ -1,6 +1,7 @@
<template>
<div>
<h1 class="text-h4 mb-4">Inventario y Productos</h1>
<v-alert v-if="mensaje" :type="tipoMensaje" variant="tonal" class="mb-4">{{ mensaje }}</v-alert>
<v-row>
<v-col cols="12" md="6">
@@ -82,6 +83,9 @@ const cantInv = ref(0);
const prodInv2 = ref('');
const cantInv2 = ref(0);
const recibidoPor = ref('');
const mensaje = ref('');
const tipoMensaje = ref<'success' | 'error'>('success');
function avisar(texto: string, tipo: 'success' | 'error' = 'error') { mensaje.value = texto; tipoMensaje.value = tipo; }
async function cargar() {
productores.value = await db.productores.toArray();
@@ -89,33 +93,35 @@ async function cargar() {
}
async function crearProductor() {
if (!nuevoProductor.value) return;
if (!nuevoProductor.value.trim()) return avisar('Escribe el nombre del productor.');
await db.productores.add({ id: crypto.randomUUID(), nombre: nuevoProductor.value, activo: true, createdAt: new Date() } as any);
nuevoProductor.value = '';
await cargar();
avisar('Productor creado.', 'success');
}
async function crearProducto() {
if (!productorSel.value || !nombreProd.value) return;
if (!productorSel.value || !nombreProd.value.trim() || precioProd.value <= 0) return avisar('Selecciona productor, nombre y un precio mayor que cero.');
await db.productos.add({ id: crypto.randomUUID(), productorId: productorSel.value, nombrePresentacion: nombreProd.value, precioUnitario: Number(precioProd.value), activo: true, createdAt: new Date() } as any);
nombreProd.value = '';
precioProd.value = 0;
await cargar();
avisar('Producto creado.', 'success');
}
async function ingresoInicial() {
if (!prodInv.value || cantInv.value <= 0) return;
if (!prodInv.value || cantInv.value <= 0) return avisar('Selecciona un producto y una cantidad mayor que cero.');
await invService.registrarIngresoInicial(prodInv.value, Number(cantInv.value), new Date());
cantInv.value = 0;
alert('Ingreso inicial registrado');
avisar('Ingreso inicial registrado.', 'success');
}
async function ingresoSemana() {
if (!prodInv2.value || cantInv2.value <= 0 || !recibidoPor.value) return;
if (!prodInv2.value || cantInv2.value <= 0 || !recibidoPor.value.trim()) return avisar('Completa producto, cantidad y persona que recibe.');
await invService.registrarIngresoSemana(prodInv2.value, Number(cantInv2.value), new Date(), recibidoPor.value);
cantInv2.value = 0;
recibidoPor.value = '';
alert('Ingreso entre semana registrado');
avisar('Ingreso entre semana registrado.', 'success');
}
onMounted(cargar);

View File

@@ -1,16 +1,17 @@
<template>
<div>
<h2>Reportes</h2>
<button @click="generarPorProductor">Reporte por Productor</button>
<button @click="generarCuadre">Reporte de Cuadre</button>
<div class="mb-5"><div class="text-overline text-primary">CONTROL Y SEGUIMIENTO</div><h1 class="text-h4 mb-1">Reportes</h1><div class="text-body-2 text-medium-emphasis">Consulta el resultado del día o consolida un período.</div></div>
<v-card class="mb-4"><v-card-text><v-row align="end"><v-col cols="12" sm="4"><v-text-field v-model="inicio" type="date" label="Desde" /></v-col><v-col cols="12" sm="4"><v-text-field v-model="fin" type="date" label="Hasta" /></v-col><v-col cols="12" sm="4"><v-btn @click="generarConsolidado" color="primary" block>Consolidar período</v-btn></v-col></v-row><v-alert v-if="error" type="error" variant="tonal">{{ error }}</v-alert></v-card-text></v-card>
<div class="d-flex ga-2 mb-4"><v-btn @click="generarPorProductor" color="secondary" variant="tonal">Por productor</v-btn><v-btn @click="generarCuadre" color="secondary" variant="tonal">Cuadre de caja</v-btn></div>
<section v-if="reporteProd.length">
<h3>Por Productor</h3>
<div v-for="r in reporteProd" :key="r.productor">
<h4>{{ r.productor }}</h4>
<p>Ventas: ${{ r.totalVentas }}</p>
<p>Ventas: ${{ r.totalVentas.toLocaleString() }}</p><p>Inventario inicial: {{ r.inventarioInicial }} · Diferencia: {{ r.diferenciaInventario }}</p>
<p>Ajustes: {{ r.ajustes.length }}</p>
</div>
</section>
<v-card v-if="consolidado" class="mt-4" variant="tonal"><v-card-title>Consolidado</v-card-title><v-card-text><div class="text-body-1">{{ consolidado.fechaInicio.toLocaleDateString() }} al {{ consolidado.fechaFin.toLocaleDateString() }}</div><div class="text-h6">Ventas: ${{ consolidado.ventasTotal.toLocaleString() }}</div><div>Gastos: ${{ consolidado.gastosTotal.toLocaleString() }} · Registros: {{ consolidado.ventas }} ventas, {{ consolidado.gastos }} gastos</div></v-card-text></v-card>
<section v-if="reporteCua.length">
<h3>Cuadre</h3>
<div v-for="c in reporteCua" :key="c.id">
@@ -27,6 +28,10 @@ import { ReportesService } from '@toldo/services';
const reportesService = new ReportesService();
const reporteProd = ref<any[]>([]);
const reporteCua = ref<any[]>([]);
const inicio = ref(new Date().toISOString().slice(0, 10));
const fin = ref(new Date().toISOString().slice(0, 10));
const consolidado = ref<any>(null);
const error = ref('');
async function generarPorProductor() {
reporteProd.value = await reportesService.reportePorProductor(new Date());
@@ -35,4 +40,11 @@ async function generarPorProductor() {
async function generarCuadre() {
reporteCua.value = await reportesService.reporteCuadre(new Date());
}
async function generarConsolidado() {
error.value = '';
const desde = new Date(`${inicio.value}T00:00:00`);
const hasta = new Date(`${fin.value}T23:59:59.999`);
if (desde > hasta) { error.value = 'La fecha final debe ser igual o posterior a la fecha inicial.'; return; }
consolidado.value = await reportesService.consolidadoPorRango(desde, hasta);
}
</script>

View File

@@ -1,17 +1,17 @@
<template>
<div>
<h1 class="text-h4 mb-4">Punto de Venta (POS)</h1>
<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>
<v-row>
<v-col cols="12" md="8">
<v-card>
<v-card-title>Productos Disponibles</v-card-title>
<v-card-title>Productos disponibles</v-card-title>
<v-card-text>
<v-row>
<v-col cols="12" sm="6" md="4" v-for="prod in productos" :key="prod.id">
<v-card @click="agregarProducto(prod)" class="cursor-pointer" hover>
<v-card-text>
<div class="text-subtitle-1 font-weight-bold">{{ prod.nombrePresentacion }}</div>
<div class="text-h6 text-primary">${{ prod.precioUnitario }}</div>
<div class="text-h6 text-primary">${{ prod.precioUnitario.toLocaleString() }}</div>
</v-card-text>
</v-card>
</v-col>
@@ -24,8 +24,7 @@
<v-card>
<v-card-title>Carrito de Compras</v-card-title>
<v-card-text>
<v-alert v-if="!jornadaAbierta" type="warning" density="compact" class="mb-2">Sin jornada abierta</v-alert>
<v-alert v-else type="success" density="compact" class="mb-2">Jornada abierta</v-alert>
<v-alert v-if="!jornadaAbierta" type="warning" variant="tonal" density="compact" class="mb-2" text="Abre una jornada antes de cobrar."><template #append><v-btn to="/jornada" size="small" color="warning">Abrir</v-btn></template></v-alert>
<v-list v-if="lineas.length">
<v-list-item v-for="(l, i) in lineas" :key="i">
<v-list-item-title>{{ getProdNombre(l.productoId) }}</v-list-item-title>
@@ -44,9 +43,11 @@
</v-list-item>
</v-list>
<v-divider class="my-3"></v-divider>
<div class="text-h5">Total: ${{ total }}</div>
<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-btn @click="registrarVenta" color="success" block size="large" :disabled="lineas.length === 0" class="mt-2">COBRAR</v-btn>
<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-card-text>
</v-card>
</v-col>
@@ -65,6 +66,8 @@ const productos = ref<any[]>([]);
const lineas = ref<any[]>([]);
const formaPago = ref('EFECTIVO');
const jornadaAbierta = ref<any>(null);
const error = ref('');
const exito = ref('');
async function cargar() {
productos.value = await db.productos.toArray();
@@ -94,6 +97,7 @@ function agregarProducto(prod: any) {
}
function actualizarLinea(i: number) {
if (!Number.isFinite(lineas.value[i].cantidad) || lineas.value[i].cantidad < 1) lineas.value[i].cantidad = 1;
lineas.value[i].subtotal = lineas.value[i].cantidad * lineas.value[i].precioUnitario;
}
@@ -104,9 +108,11 @@ function eliminarLinea(i: number) {
const total = computed(() => lineas.value.reduce((acc, l) => acc + l.subtotal, 0));
async function registrarVenta() {
error.value = '';
exito.value = '';
if (lineas.value.length === 0) return;
if (!jornadaAbierta.value) {
alert('Debe abrir una jornada primero (Jornada > Apertura)');
error.value = 'Debe abrir una jornada antes de registrar ventas.';
return;
}
const ventaId = crypto.randomUUID();
@@ -122,9 +128,9 @@ async function registrarVenta() {
try {
await jornadaService.registrarVentaEnJornada(jornadaAbierta.value.id, venta, lineasVenta);
lineas.value = [];
alert(`Venta registrada por $${total.value} - ${formaPago.value}`);
exito.value = `Venta registrada por $${total.value.toLocaleString()} en ${formaPago.value}.`;
} catch (e: any) {
alert(e.message);
error.value = e.message;
}
}

View File

@@ -0,0 +1,16 @@
import { expect, test } from '@playwright/test';
test('muestra el panel y permite abrir navegación móvil', async ({ page }) => {
await page.goto('/');
await expect(page.getByText('Panel de control')).toBeVisible();
await page.setViewportSize({ width: 390, height: 844 });
await page.getByTestId('navigation-toggle').click();
await expect(page.getByText('Ventas POS')).toBeVisible();
});
test('guía la configuración cuando el catálogo está vacío', async ({ page }) => {
await page.goto('/');
await expect(page.getByText('Configura tu catálogo')).toBeVisible();
await page.getByRole('link', { name: 'Ir a inventario' }).click();
await expect(page).toHaveURL(/inventario/);
});

View File

@@ -8,6 +8,7 @@ export default defineConfig({
globals: true,
environment: 'jsdom',
setupFiles: ['./tests/setup.ts'],
exclude: ['**/node_modules/**', '**/dist/**', '**/tests/e2e/**'],
},
resolve: {
alias: {