feat: mejorar ux y validaciones del toldo
This commit is contained in:
43
apps/toldo-app/package-lock.json
generated
43
apps/toldo-app/package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
9
apps/toldo-app/playwright.config.ts
Normal file
9
apps/toldo-app/playwright.config.ts
Normal 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' } } }],
|
||||
});
|
||||
22
apps/toldo-app/src/__tests__/critical-flow.test.ts
Normal file
22
apps/toldo-app/src/__tests__/critical-flow.test.ts
Normal 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([]);
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
|
||||
@@ -14,6 +14,12 @@ export default createVuetify({
|
||||
primary: '#2d5f3b',
|
||||
secondary: '#4a7c59',
|
||||
accent: '#6b8e23',
|
||||
surface: '#fffdf8',
|
||||
background: '#f5f7f2',
|
||||
info: '#286c7a',
|
||||
},
|
||||
variables: {
|
||||
'border-radius-root': '12px',
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
16
apps/toldo-app/tests/e2e/navigation.spec.ts
Normal file
16
apps/toldo-app/tests/e2e/navigation.spec.ts
Normal 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/);
|
||||
});
|
||||
@@ -8,6 +8,7 @@ export default defineConfig({
|
||||
globals: true,
|
||||
environment: 'jsdom',
|
||||
setupFiles: ['./tests/setup.ts'],
|
||||
exclude: ['**/node_modules/**', '**/dist/**', '**/tests/e2e/**'],
|
||||
},
|
||||
resolve: {
|
||||
alias: {
|
||||
|
||||
Reference in New Issue
Block a user