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": {
|
"devDependencies": {
|
||||||
"@capacitor/cli": "^6.2.1",
|
"@capacitor/cli": "^6.2.1",
|
||||||
|
"@playwright/test": "^1.64.1",
|
||||||
"@testing-library/vue": "^8.1.0",
|
"@testing-library/vue": "^8.1.0",
|
||||||
"@types/node": "^24.19.2",
|
"@types/node": "^24.19.2",
|
||||||
"@vitejs/plugin-vue": "^6.0.10",
|
"@vitejs/plugin-vue": "^6.0.10",
|
||||||
@@ -948,6 +949,21 @@
|
|||||||
"url": "https://github.com/sponsors/oxc-project"
|
"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": {
|
"node_modules/@rolldown/binding-android-arm-eabi": {
|
||||||
"version": "1.2.15",
|
"version": "1.2.15",
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.15.tgz",
|
"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"
|
"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": {
|
"node_modules/plist": {
|
||||||
"version": "3.1.1",
|
"version": "3.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/plist/-/plist-3.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/plist/-/plist-3.1.1.tgz",
|
||||||
|
|||||||
@@ -8,7 +8,8 @@
|
|||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"test": "vitest",
|
"test": "vitest",
|
||||||
"test:run": "vitest run"
|
"test:run": "vitest run",
|
||||||
|
"test:e2e": "playwright test"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@capacitor/android": "^6.2.1",
|
"@capacitor/android": "^6.2.1",
|
||||||
@@ -25,6 +26,7 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@capacitor/cli": "^6.2.1",
|
"@capacitor/cli": "^6.2.1",
|
||||||
|
"@playwright/test": "^1.64.1",
|
||||||
"@testing-library/vue": "^8.1.0",
|
"@testing-library/vue": "^8.1.0",
|
||||||
"@types/node": "^24.19.2",
|
"@types/node": "^24.19.2",
|
||||||
"@vitejs/plugin-vue": "^6.0.10",
|
"@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>
|
<template>
|
||||||
<v-app>
|
<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-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-app-bar>
|
||||||
|
|
||||||
<v-navigation-drawer permanent>
|
<v-navigation-drawer v-model="drawer" :permanent="!isMobile" :temporary="isMobile">
|
||||||
<v-list density="compact">
|
<v-list density="comfortable" nav>
|
||||||
<v-list-item to="/" prepend-icon="mdi-home" title="Inicio"></v-list-item>
|
<v-list-subheader>OPERACION</v-list-subheader>
|
||||||
<v-list-item to="/jornada" prepend-icon="mdi-calendar-check" title="Jornada (Apertura/Cierre)"></v-list-item>
|
<v-list-item to="/" prepend-icon="mdi-home" title="Inicio" @click="closeOnMobile"></v-list-item>
|
||||||
<v-list-item to="/inventario" prepend-icon="mdi-package-variant" title="Inventario"></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-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-list>
|
</v-list>
|
||||||
</v-navigation-drawer>
|
</v-navigation-drawer>
|
||||||
|
|
||||||
@@ -26,3 +21,27 @@
|
|||||||
</v-main>
|
</v-main>
|
||||||
</v-app>
|
</v-app>
|
||||||
</template>
|
</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',
|
primary: '#2d5f3b',
|
||||||
secondary: '#4a7c59',
|
secondary: '#4a7c59',
|
||||||
accent: '#6b8e23',
|
accent: '#6b8e23',
|
||||||
|
surface: '#fffdf8',
|
||||||
|
background: '#f5f7f2',
|
||||||
|
info: '#286c7a',
|
||||||
|
},
|
||||||
|
variables: {
|
||||||
|
'border-radius-root': '12px',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -17,7 +17,7 @@
|
|||||||
|
|
||||||
font: 18px/145% var(--sans);
|
font: 18px/145% var(--sans);
|
||||||
letter-spacing: 0.18px;
|
letter-spacing: 0.18px;
|
||||||
color-scheme: light dark;
|
color-scheme: light;
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
background: var(--bg);
|
background: var(--bg);
|
||||||
font-synthesis: none;
|
font-synthesis: none;
|
||||||
@@ -52,6 +52,8 @@
|
|||||||
|
|
||||||
body {
|
body {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
|
min-width: 320px;
|
||||||
|
background: #f5f7f2;
|
||||||
}
|
}
|
||||||
|
|
||||||
h1,
|
h1,
|
||||||
@@ -156,17 +158,7 @@ code {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#app {
|
#app { min-height: 100svh; }
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
#center {
|
#center {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<template>
|
<template>
|
||||||
<div>
|
<div>
|
||||||
<h2>Ajustes</h2>
|
<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>
|
<p><strong>Requiere motivo obligatorio</strong></p>
|
||||||
<section>
|
<section>
|
||||||
<select v-model="productoSel">
|
<select v-model="productoSel">
|
||||||
@@ -33,6 +34,8 @@ const productoSel = ref('');
|
|||||||
const cantidadNueva = ref(0);
|
const cantidadNueva = ref(0);
|
||||||
const motivo = ref('');
|
const motivo = ref('');
|
||||||
const ajustes = ref<any[]>([]);
|
const ajustes = ref<any[]>([]);
|
||||||
|
const mensaje = ref('');
|
||||||
|
const tipoMensaje = ref<'success' | 'error'>('success');
|
||||||
|
|
||||||
async function cargar() {
|
async function cargar() {
|
||||||
productos.value = await db.productos.toArray();
|
productos.value = await db.productos.toArray();
|
||||||
@@ -42,7 +45,8 @@ async function cargar() {
|
|||||||
async function ajustar() {
|
async function ajustar() {
|
||||||
try {
|
try {
|
||||||
validateAjusteMotivo(motivo.value);
|
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 saldoActual = await invService.calcularSaldoProducto(productoSel.value);
|
||||||
const diferencia = cantidadNueva.value - saldoActual;
|
const diferencia = cantidadNueva.value - saldoActual;
|
||||||
await ajustesService.crearAjuste({
|
await ajustesService.crearAjuste({
|
||||||
@@ -58,9 +62,9 @@ async function ajustar() {
|
|||||||
motivo.value = '';
|
motivo.value = '';
|
||||||
cantidadNueva.value = 0;
|
cantidadNueva.value = 0;
|
||||||
await cargar();
|
await cargar();
|
||||||
alert('Ajuste registrado con motivo');
|
tipoMensaje.value = 'success'; mensaje.value = 'Ajuste registrado con motivo.';
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
alert(e.message);
|
tipoMensaje.value = 'error'; mensaje.value = e.message;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,29 +1,16 @@
|
|||||||
<template>
|
<template>
|
||||||
<div>
|
<div>
|
||||||
<h2>Cuadre de Caja</h2>
|
<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>
|
||||||
<section>
|
<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>
|
||||||
<label>Base Inicial: </label>
|
|
||||||
<input v-model.number="baseInicial" type="number" />
|
|
||||||
<button @click="calcular">Calcular Cuadre</button>
|
|
||||||
</section>
|
|
||||||
<section v-if="cuadre">
|
<section v-if="cuadre">
|
||||||
<h3>Resultado</h3>
|
<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>
|
||||||
<p>Ventas Efectivo: ${{ cuadre.totalVentasEfectivo }}</p>
|
<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>
|
||||||
<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>
|
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref } from 'vue';
|
import { ref, computed } from 'vue';
|
||||||
import { CuadreService } from '@toldo/services';
|
import { CuadreService } from '@toldo/services';
|
||||||
|
|
||||||
const cuadreService = new CuadreService();
|
const cuadreService = new CuadreService();
|
||||||
@@ -31,6 +18,11 @@ const baseInicial = ref(0);
|
|||||||
const cuadre = ref<any>(null);
|
const cuadre = ref<any>(null);
|
||||||
const saldoReal = ref(0);
|
const saldoReal = ref(0);
|
||||||
const descuadre = ref<number | null>(null);
|
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() {
|
async function calcular() {
|
||||||
cuadre.value = await cuadreService.calcularCuadreDelDia(new Date(), baseInicial.value);
|
cuadre.value = await cuadreService.calcularCuadreDelDia(new Date(), baseInicial.value);
|
||||||
@@ -39,6 +31,7 @@ async function calcular() {
|
|||||||
async function guardar() {
|
async function guardar() {
|
||||||
if (!cuadre.value) return;
|
if (!cuadre.value) return;
|
||||||
cuadre.value.saldoRealEfectivo = saldoReal.value;
|
cuadre.value.saldoRealEfectivo = saldoReal.value;
|
||||||
|
cuadre.value.totalQRBanco = totalQRBanco.value;
|
||||||
await cuadreService.guardarCuadre({ ...cuadre.value, id: crypto.randomUUID(), createdAt: new Date() });
|
await cuadreService.guardarCuadre({ ...cuadre.value, id: crypto.randomUUID(), createdAt: new Date() });
|
||||||
descuadre.value = saldoReal.value - cuadre.value.saldoEsperadoEfectivo;
|
descuadre.value = saldoReal.value - cuadre.value.saldoEsperadoEfectivo;
|
||||||
alert('Cuadre guardado');
|
alert('Cuadre guardado');
|
||||||
|
|||||||
@@ -1,16 +1,13 @@
|
|||||||
<template>
|
<template>
|
||||||
<div>
|
<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>
|
<section>
|
||||||
<h3>Exportar Datos</h3>
|
<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>
|
||||||
<button @click="exportar">Exportar JSON</button>
|
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section>
|
<section>
|
||||||
<h3>Importar Datos</h3>
|
<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>
|
||||||
<input type="file" accept=".json" @change="onFileChange" />
|
|
||||||
<button @click="importar" :disabled="!archivo">Importar JSON</button>
|
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -22,6 +19,8 @@ import { ExportImportService } from '@toldo/services';
|
|||||||
|
|
||||||
const exportService = new ExportImportService();
|
const exportService = new ExportImportService();
|
||||||
const archivo = ref<File | null>(null);
|
const archivo = ref<File | null>(null);
|
||||||
|
const mensaje = ref('');
|
||||||
|
const tipoMensaje = ref<'success' | 'error'>('success');
|
||||||
|
|
||||||
function onFileChange(e: any) {
|
function onFileChange(e: any) {
|
||||||
archivo.value = e.target.files[0];
|
archivo.value = e.target.files[0];
|
||||||
@@ -37,6 +36,7 @@ async function exportar() {
|
|||||||
gastos: await db.gastos.toArray(),
|
gastos: await db.gastos.toArray(),
|
||||||
ajustes: await db.ajustes.toArray(),
|
ajustes: await db.ajustes.toArray(),
|
||||||
cuadres: await db.cuadres.toArray(),
|
cuadres: await db.cuadres.toArray(),
|
||||||
|
jornadas: await db.jornadas.toArray(),
|
||||||
};
|
};
|
||||||
const exp = exportService.exportAll(data);
|
const exp = exportService.exportAll(data);
|
||||||
const blob = new Blob([JSON.stringify(exp, null, 2)], { type: 'application/json' });
|
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.gastos.length) await db.gastos.bulkPut(imp.data.gastos);
|
||||||
if (imp.data.ajustes.length) await db.ajustes.bulkPut(imp.data.ajustes);
|
if (imp.data.ajustes.length) await db.ajustes.bulkPut(imp.data.ajustes);
|
||||||
if (imp.data.cuadres.length) await db.cuadres.bulkPut(imp.data.cuadres);
|
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) {
|
} catch (e: any) {
|
||||||
alert(e.message);
|
tipoMensaje.value = 'error'; mensaje.value = e.message;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<template>
|
<template>
|
||||||
<div>
|
<div>
|
||||||
<h2>Gastos</h2>
|
<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-if="!jornadaAbierta" style="color: orange">Sin jornada abierta - debe abrir jornada para registrar gastos</p>
|
||||||
<p v-else style="color: green">Jornada abierta</p>
|
<p v-else style="color: green">Jornada abierta</p>
|
||||||
<section>
|
<section>
|
||||||
@@ -32,6 +33,8 @@ const monto = ref(0);
|
|||||||
const formaPago = ref<'EFECTIVO' | 'QR'>('EFECTIVO');
|
const formaPago = ref<'EFECTIVO' | 'QR'>('EFECTIVO');
|
||||||
const gastos = ref<any[]>([]);
|
const gastos = ref<any[]>([]);
|
||||||
const jornadaAbierta = ref<any>(null);
|
const jornadaAbierta = ref<any>(null);
|
||||||
|
const mensaje = ref('');
|
||||||
|
const tipoMensaje = ref<'success' | 'error'>('success');
|
||||||
|
|
||||||
async function cargar() {
|
async function cargar() {
|
||||||
gastos.value = await gastosService.getGastosDelDia(new Date());
|
gastos.value = await gastosService.getGastosDelDia(new Date());
|
||||||
@@ -39,9 +42,9 @@ async function cargar() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function registrar() {
|
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) {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
@@ -56,9 +59,9 @@ async function registrar() {
|
|||||||
concepto.value = '';
|
concepto.value = '';
|
||||||
monto.value = 0;
|
monto.value = 0;
|
||||||
await cargar();
|
await cargar();
|
||||||
alert('Gasto registrado');
|
tipoMensaje.value = 'success'; mensaje.value = 'Gasto registrado.';
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
alert(e.message);
|
tipoMensaje.value = 'error'; mensaje.value = e.message;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,35 +1,38 @@
|
|||||||
<template>
|
<template>
|
||||||
<div>
|
<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-row>
|
||||||
<v-col cols="12" md="3">
|
<v-col cols="12" md="3">
|
||||||
<v-card>
|
<v-card class="summary-card" color="surface">
|
||||||
<v-card-text>
|
<v-card-text>
|
||||||
<div class="text-h6">Ventas del día</div>
|
<div class="text-body-2 text-medium-emphasis">Ventas del día</div>
|
||||||
<div class="text-h4">${{ totalVentas }}</div>
|
<div class="text-h4 font-weight-bold text-primary">${{ totalVentas.toLocaleString() }}</div>
|
||||||
</v-card-text>
|
</v-card-text>
|
||||||
</v-card>
|
</v-card>
|
||||||
</v-col>
|
</v-col>
|
||||||
<v-col cols="12" md="3">
|
<v-col cols="12" md="3">
|
||||||
<v-card>
|
<v-card class="summary-card" color="surface">
|
||||||
<v-card-text>
|
<v-card-text>
|
||||||
<div class="text-h6">Gastos del día</div>
|
<div class="text-body-2 text-medium-emphasis">Gastos del día</div>
|
||||||
<div class="text-h4">${{ totalGastos }}</div>
|
<div class="text-h4 font-weight-bold">${{ totalGastos.toLocaleString() }}</div>
|
||||||
</v-card-text>
|
</v-card-text>
|
||||||
</v-card>
|
</v-card>
|
||||||
</v-col>
|
</v-col>
|
||||||
<v-col cols="12" md="3">
|
<v-col cols="12" md="3">
|
||||||
<v-card>
|
<v-card class="summary-card" color="surface">
|
||||||
<v-card-text>
|
<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>
|
<div class="text-h4">{{ productos.length }}</div>
|
||||||
</v-card-text>
|
</v-card-text>
|
||||||
</v-card>
|
</v-card>
|
||||||
</v-col>
|
</v-col>
|
||||||
<v-col cols="12" md="3">
|
<v-col cols="12" md="3">
|
||||||
<v-card>
|
<v-card class="summary-card" color="surface">
|
||||||
<v-card-text>
|
<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>
|
<div class="text-h4">{{ productores.length }}</div>
|
||||||
</v-card-text>
|
</v-card-text>
|
||||||
</v-card>
|
</v-card>
|
||||||
@@ -37,26 +40,31 @@
|
|||||||
</v-row>
|
</v-row>
|
||||||
<v-row class="mt-4">
|
<v-row class="mt-4">
|
||||||
<v-col cols="12">
|
<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-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">Sin jornada activa. <router-link to="/jornada">Abrir jornada</router-link></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-col>
|
||||||
</v-row>
|
</v-row>
|
||||||
<v-row class="mt-4">
|
<v-row class="mt-4">
|
||||||
<v-col>
|
<v-col>
|
||||||
<v-card>
|
<v-card>
|
||||||
<v-card-title>Acciones Rápidas</v-card-title>
|
<v-card-title>Comenzar</v-card-title>
|
||||||
<v-card-text>
|
<v-card-text>
|
||||||
<v-btn to="/jornada" color="success" class="mr-2">Jornada (Apertura/Cierre)</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" class="mr-2">Nueva Venta</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" class="mr-2">Registrar Gasto</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">Gestionar Inventario</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-text>
|
||||||
</v-card>
|
</v-card>
|
||||||
</v-col>
|
</v-col>
|
||||||
</v-row>
|
</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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.summary-card { border-top: 3px solid rgb(var(--v-theme-primary)); }
|
||||||
|
</style>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted } from 'vue';
|
import { ref, onMounted } from 'vue';
|
||||||
import { db } from '@toldo/data';
|
import { db } from '@toldo/data';
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<template>
|
<template>
|
||||||
<div>
|
<div>
|
||||||
<h1 class="text-h4 mb-4">Inventario y Productos</h1>
|
<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-row>
|
||||||
<v-col cols="12" md="6">
|
<v-col cols="12" md="6">
|
||||||
@@ -82,6 +83,9 @@ const cantInv = ref(0);
|
|||||||
const prodInv2 = ref('');
|
const prodInv2 = ref('');
|
||||||
const cantInv2 = ref(0);
|
const cantInv2 = ref(0);
|
||||||
const recibidoPor = ref('');
|
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() {
|
async function cargar() {
|
||||||
productores.value = await db.productores.toArray();
|
productores.value = await db.productores.toArray();
|
||||||
@@ -89,33 +93,35 @@ async function cargar() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function crearProductor() {
|
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);
|
await db.productores.add({ id: crypto.randomUUID(), nombre: nuevoProductor.value, activo: true, createdAt: new Date() } as any);
|
||||||
nuevoProductor.value = '';
|
nuevoProductor.value = '';
|
||||||
await cargar();
|
await cargar();
|
||||||
|
avisar('Productor creado.', 'success');
|
||||||
}
|
}
|
||||||
|
|
||||||
async function crearProducto() {
|
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);
|
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 = '';
|
nombreProd.value = '';
|
||||||
precioProd.value = 0;
|
precioProd.value = 0;
|
||||||
await cargar();
|
await cargar();
|
||||||
|
avisar('Producto creado.', 'success');
|
||||||
}
|
}
|
||||||
|
|
||||||
async function ingresoInicial() {
|
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());
|
await invService.registrarIngresoInicial(prodInv.value, Number(cantInv.value), new Date());
|
||||||
cantInv.value = 0;
|
cantInv.value = 0;
|
||||||
alert('Ingreso inicial registrado');
|
avisar('Ingreso inicial registrado.', 'success');
|
||||||
}
|
}
|
||||||
|
|
||||||
async function ingresoSemana() {
|
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);
|
await invService.registrarIngresoSemana(prodInv2.value, Number(cantInv2.value), new Date(), recibidoPor.value);
|
||||||
cantInv2.value = 0;
|
cantInv2.value = 0;
|
||||||
recibidoPor.value = '';
|
recibidoPor.value = '';
|
||||||
alert('Ingreso entre semana registrado');
|
avisar('Ingreso entre semana registrado.', 'success');
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(cargar);
|
onMounted(cargar);
|
||||||
|
|||||||
@@ -1,16 +1,17 @@
|
|||||||
<template>
|
<template>
|
||||||
<div>
|
<div>
|
||||||
<h2>Reportes</h2>
|
<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>
|
||||||
<button @click="generarPorProductor">Reporte por Productor</button>
|
<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>
|
||||||
<button @click="generarCuadre">Reporte de Cuadre</button>
|
<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">
|
<section v-if="reporteProd.length">
|
||||||
<h3>Por Productor</h3>
|
<h3>Por Productor</h3>
|
||||||
<div v-for="r in reporteProd" :key="r.productor">
|
<div v-for="r in reporteProd" :key="r.productor">
|
||||||
<h4>{{ r.productor }}</h4>
|
<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>
|
<p>Ajustes: {{ r.ajustes.length }}</p>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</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">
|
<section v-if="reporteCua.length">
|
||||||
<h3>Cuadre</h3>
|
<h3>Cuadre</h3>
|
||||||
<div v-for="c in reporteCua" :key="c.id">
|
<div v-for="c in reporteCua" :key="c.id">
|
||||||
@@ -27,6 +28,10 @@ import { ReportesService } from '@toldo/services';
|
|||||||
const reportesService = new ReportesService();
|
const reportesService = new ReportesService();
|
||||||
const reporteProd = ref<any[]>([]);
|
const reporteProd = ref<any[]>([]);
|
||||||
const reporteCua = 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() {
|
async function generarPorProductor() {
|
||||||
reporteProd.value = await reportesService.reportePorProductor(new Date());
|
reporteProd.value = await reportesService.reportePorProductor(new Date());
|
||||||
@@ -35,4 +40,11 @@ async function generarPorProductor() {
|
|||||||
async function generarCuadre() {
|
async function generarCuadre() {
|
||||||
reporteCua.value = await reportesService.reporteCuadre(new Date());
|
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>
|
</script>
|
||||||
|
|||||||
@@ -1,17 +1,17 @@
|
|||||||
<template>
|
<template>
|
||||||
<div>
|
<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-row>
|
||||||
<v-col cols="12" md="8">
|
<v-col cols="12" md="8">
|
||||||
<v-card>
|
<v-card>
|
||||||
<v-card-title>Productos Disponibles</v-card-title>
|
<v-card-title>Productos disponibles</v-card-title>
|
||||||
<v-card-text>
|
<v-card-text>
|
||||||
<v-row>
|
<v-row>
|
||||||
<v-col cols="12" sm="6" md="4" v-for="prod in productos" :key="prod.id">
|
<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 @click="agregarProducto(prod)" class="cursor-pointer" hover>
|
||||||
<v-card-text>
|
<v-card-text>
|
||||||
<div class="text-subtitle-1 font-weight-bold">{{ prod.nombrePresentacion }}</div>
|
<div class="text-subtitle-1 font-weight-bold">{{ prod.nombrePresentacion }}</div>
|
||||||
<div class="text-h6 text-primary">${{ prod.precioUnitario }}</div>
|
<div class="text-h6 text-primary">${{ prod.precioUnitario.toLocaleString() }}</div>
|
||||||
</v-card-text>
|
</v-card-text>
|
||||||
</v-card>
|
</v-card>
|
||||||
</v-col>
|
</v-col>
|
||||||
@@ -24,8 +24,7 @@
|
|||||||
<v-card>
|
<v-card>
|
||||||
<v-card-title>Carrito de Compras</v-card-title>
|
<v-card-title>Carrito de Compras</v-card-title>
|
||||||
<v-card-text>
|
<v-card-text>
|
||||||
<v-alert v-if="!jornadaAbierta" type="warning" density="compact" class="mb-2">Sin 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-alert v-else type="success" density="compact" class="mb-2">Jornada abierta</v-alert>
|
|
||||||
<v-list v-if="lineas.length">
|
<v-list v-if="lineas.length">
|
||||||
<v-list-item v-for="(l, i) in lineas" :key="i">
|
<v-list-item v-for="(l, i) in lineas" :key="i">
|
||||||
<v-list-item-title>{{ getProdNombre(l.productoId) }}</v-list-item-title>
|
<v-list-item-title>{{ getProdNombre(l.productoId) }}</v-list-item-title>
|
||||||
@@ -44,9 +43,11 @@
|
|||||||
</v-list-item>
|
</v-list-item>
|
||||||
</v-list>
|
</v-list>
|
||||||
<v-divider class="my-3"></v-divider>
|
<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-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-text>
|
||||||
</v-card>
|
</v-card>
|
||||||
</v-col>
|
</v-col>
|
||||||
@@ -65,6 +66,8 @@ const productos = ref<any[]>([]);
|
|||||||
const lineas = ref<any[]>([]);
|
const lineas = ref<any[]>([]);
|
||||||
const formaPago = ref('EFECTIVO');
|
const formaPago = ref('EFECTIVO');
|
||||||
const jornadaAbierta = ref<any>(null);
|
const jornadaAbierta = ref<any>(null);
|
||||||
|
const error = ref('');
|
||||||
|
const exito = ref('');
|
||||||
|
|
||||||
async function cargar() {
|
async function cargar() {
|
||||||
productos.value = await db.productos.toArray();
|
productos.value = await db.productos.toArray();
|
||||||
@@ -94,6 +97,7 @@ function agregarProducto(prod: any) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function actualizarLinea(i: number) {
|
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;
|
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));
|
const total = computed(() => lineas.value.reduce((acc, l) => acc + l.subtotal, 0));
|
||||||
|
|
||||||
async function registrarVenta() {
|
async function registrarVenta() {
|
||||||
|
error.value = '';
|
||||||
|
exito.value = '';
|
||||||
if (lineas.value.length === 0) return;
|
if (lineas.value.length === 0) return;
|
||||||
if (!jornadaAbierta.value) {
|
if (!jornadaAbierta.value) {
|
||||||
alert('Debe abrir una jornada primero (Jornada > Apertura)');
|
error.value = 'Debe abrir una jornada antes de registrar ventas.';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const ventaId = crypto.randomUUID();
|
const ventaId = crypto.randomUUID();
|
||||||
@@ -122,9 +128,9 @@ async function registrarVenta() {
|
|||||||
try {
|
try {
|
||||||
await jornadaService.registrarVentaEnJornada(jornadaAbierta.value.id, venta, lineasVenta);
|
await jornadaService.registrarVentaEnJornada(jornadaAbierta.value.id, venta, lineasVenta);
|
||||||
lineas.value = [];
|
lineas.value = [];
|
||||||
alert(`Venta registrada por $${total.value} - ${formaPago.value}`);
|
exito.value = `Venta registrada por $${total.value.toLocaleString()} en ${formaPago.value}.`;
|
||||||
} catch (e: any) {
|
} 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,
|
globals: true,
|
||||||
environment: 'jsdom',
|
environment: 'jsdom',
|
||||||
setupFiles: ['./tests/setup.ts'],
|
setupFiles: ['./tests/setup.ts'],
|
||||||
|
exclude: ['**/node_modules/**', '**/dist/**', '**/tests/e2e/**'],
|
||||||
},
|
},
|
||||||
resolve: {
|
resolve: {
|
||||||
alias: {
|
alias: {
|
||||||
|
|||||||
87
openspec/changes/improve-ui-ux-pos/specs/toldo-poc/spec.md
Normal file
87
openspec/changes/improve-ui-ux-pos/specs/toldo-poc/spec.md
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
# Spec Delta
|
||||||
|
|
||||||
|
## MODIFIED Requirements
|
||||||
|
|
||||||
|
### Requirement: Realizar cuadre de caja
|
||||||
|
The system SHALL allow completing an end-of-day reconciliation with cash counted and QR bank total, while clearly showing the expected values and calculated differences.
|
||||||
|
|
||||||
|
#### Scenario: Cuadre completo
|
||||||
|
- **GIVEN** base inicial, ventas (cash/QR), gastos (cash/QR) recorded
|
||||||
|
- **WHEN** user performs cuadre
|
||||||
|
- **THEN** saldoEsperadoEfectivo is calculated (base + ventas efectivo - gastos efectivo)
|
||||||
|
- **AND** user can enter saldoRealEfectivo
|
||||||
|
- **AND** user can enter total QR reported by the bank
|
||||||
|
- **AND** descuadreEfectivo and descuadreQR are calculated
|
||||||
|
- **AND** totalVentas = ventas efectivo + ventas QR
|
||||||
|
|
||||||
|
### Requirement: Reporte por productor
|
||||||
|
The system SHALL generate reports by producer including initial real inventory, expected inventory, differences, sales movements, and sales value.
|
||||||
|
|
||||||
|
#### Scenario: Reporte por productor completo
|
||||||
|
- **GIVEN** sales and inventory data for the day
|
||||||
|
- **WHEN** generating report by producer
|
||||||
|
- **THEN** shows inventory differences vs previous day
|
||||||
|
- **AND** shows initial inventory (real)
|
||||||
|
- **AND** shows inventory movements from sales (salidas)
|
||||||
|
- **AND** shows sales value for producer's products
|
||||||
|
- **AND** shows total per producer
|
||||||
|
|
||||||
|
### Requirement: Reporte consolidado
|
||||||
|
The system SHALL generate consolidated totals for a user-selected date range and identify the selected period in the result.
|
||||||
|
|
||||||
|
#### Scenario: Consolidado por rango
|
||||||
|
- **GIVEN** multiple days of data
|
||||||
|
- **WHEN** generating consolidated report for date range
|
||||||
|
- **THEN** aggregates sales, expenses, adjustments
|
||||||
|
- **AND** shows totals by producer
|
||||||
|
- **AND** rejects an end date before the start date with a clear validation message
|
||||||
|
|
||||||
|
### Requirement: Exportar datos
|
||||||
|
The system SHALL export all operational data, including market days, in versioned JSON format.
|
||||||
|
|
||||||
|
#### Scenario: Exportar jornada completa
|
||||||
|
- **GIVEN** data exists in the app
|
||||||
|
- **WHEN** user exports
|
||||||
|
- **THEN** JSON file is generated with schemaVersion, appVersion, exportedAt, data
|
||||||
|
- **AND** data includes jornadas so an imported device preserves day status and traceability
|
||||||
|
|
||||||
|
## ADDED Requirements
|
||||||
|
|
||||||
|
### Requirement: Navegación adaptada al dispositivo
|
||||||
|
The system SHALL provide consistent navigation and visible context on desktop, tablet, and mobile widths.
|
||||||
|
|
||||||
|
#### Scenario: Navegación móvil
|
||||||
|
- **WHEN** the app is opened on a narrow viewport
|
||||||
|
- **THEN** the navigation can be opened and closed without covering the active content
|
||||||
|
- **AND** the current section and active day status remain visible
|
||||||
|
|
||||||
|
### Requirement: POS seguro y comprensible
|
||||||
|
The system SHALL guide the user through product selection, quantity editing, payment selection, and sale confirmation without allowing invalid quantities or sales outside an open day.
|
||||||
|
|
||||||
|
#### Scenario: Venta válida desde POS
|
||||||
|
- **GIVEN** an open day and products with available stock
|
||||||
|
- **WHEN** user adds products, adjusts quantities, selects cash or QR, and confirms
|
||||||
|
- **THEN** a confirmation feedback identifies amount and payment method
|
||||||
|
- **AND** the cart is cleared only after successful persistence
|
||||||
|
|
||||||
|
#### Scenario: Venta inválida
|
||||||
|
- **GIVEN** no open day or a non-positive quantity
|
||||||
|
- **WHEN** user attempts to confirm the sale
|
||||||
|
- **THEN** the sale is not persisted
|
||||||
|
- **AND** an inline, actionable validation message is shown
|
||||||
|
|
||||||
|
### Requirement: Flujo de configuración inicial
|
||||||
|
The system SHALL guide a first-time user to create at least one producer and product before attempting an operational sale.
|
||||||
|
|
||||||
|
#### Scenario: Configuración vacía
|
||||||
|
- **WHEN** no producers or products exist
|
||||||
|
- **THEN** the dashboard explains the next setup step and links directly to inventory setup
|
||||||
|
- **AND** the POS explains why a sale cannot yet be created
|
||||||
|
|
||||||
|
### Requirement: Pruebas de flujo crítico
|
||||||
|
The project SHALL include automated browser coverage for the main operational flow and responsive navigation.
|
||||||
|
|
||||||
|
#### Scenario: Jornada completa en navegador
|
||||||
|
- **WHEN** the test opens a day, creates catalog data, records a multi-line sale and expense, and closes the day
|
||||||
|
- **THEN** the expected totals and closed status are visible
|
||||||
|
- **AND** the test can run with network access disabled after the app has loaded
|
||||||
@@ -1,29 +1,32 @@
|
|||||||
# Tasks
|
# Tasks
|
||||||
|
|
||||||
## 1. Research & Setup
|
## 1. Research & Setup
|
||||||
- [ ] 1.1 Research POS UI patterns
|
- [x] 1.1 Audit README requirements against current implementation
|
||||||
- [ ] 1.2 Install Vuetify 3, mdi icons
|
- [x] 1.2 Confirm Vuetify 3 and Material Design icons are available
|
||||||
- [ ] 1.3 Configure Vuetify theme
|
- [x] 1.3 Configure responsive theme and shared visual language
|
||||||
|
|
||||||
## 2. UI Overhaul
|
## 2. UI Overhaul
|
||||||
- [ ] 2.1 Redesign Layout with Vuetify (app bar, navigation)
|
- [x] 2.1 Redesign Layout with responsive navigation and day status
|
||||||
- [ ] 2.2 Redesign Dashboard with day summary
|
- [x] 2.2 Redesign Dashboard with setup guidance and day summary
|
||||||
- [ ] 2.3 Redesign Inventory/Producers/Products with data tables
|
- [x] 2.3 Improve Inventory/Producers/Products forms and validation
|
||||||
- [ ] 2.4 Redesign POS Sales screen (product grid + cart)
|
- [x] 2.4 Redesign POS Sales screen with stock-aware cart and inline feedback
|
||||||
- [ ] 2.5 Redesign Expenses, Adjustments
|
- [x] 2.5 Redesign Expenses and Adjustments with inline validation
|
||||||
- [ ] 2.6 Redesign Cash Closure with detailed breakdown
|
- [x] 2.6 Redesign Cash Closure with cash and QR breakdown
|
||||||
- [ ] 2.7 Redesign Reports
|
- [x] 2.7 Redesign Reports with producer and date-range summaries
|
||||||
- [ ] 2.8 Redesign Export/Import
|
- [x] 2.8 Redesign Export/Import and include jornadas
|
||||||
|
|
||||||
## 3. UX Features
|
## 3. UX Features
|
||||||
- [ ] 3.1 Add setup wizard for first run
|
- [x] 3.1 Add first-run setup guidance
|
||||||
- [ ] 3.2 Add quick product creation
|
- [x] 3.2 Keep quick producer/product creation in inventory
|
||||||
- [ ] 3.3 Improve validation
|
- [x] 3.3 Replace silent validation and alerts with actionable feedback
|
||||||
|
- [x] 3.4 Prevent invalid quantities and sales outside an open day
|
||||||
|
|
||||||
## 4. Testing
|
## 4. Testing
|
||||||
- [ ] 4.1 Setup Playwright
|
- [x] 4.1 Setup Playwright
|
||||||
- [ ] 4.2 Create realistic test scenarios
|
- [ ] 4.2 Add realistic service cases for QR, discrepancies, reports, and export
|
||||||
- [ ] 4.3 Test full day flow
|
- [ ] 4.3 Test full day browser flow
|
||||||
|
- [x] 4.4 Test responsive navigation and empty setup state
|
||||||
|
|
||||||
## 5. Build & Deploy
|
## 5. Build & Deploy
|
||||||
- [ ] 5.1 Build web and APK
|
- [x] 5.1 Build web and APK
|
||||||
|
- [x] 5.2 Verify APK artifact and record validation commands
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ describe('ExportImportService', () => {
|
|||||||
it('exporta con versionado', () => {
|
it('exporta con versionado', () => {
|
||||||
const data = {
|
const data = {
|
||||||
productores: [], productos: [], movimientosInventario: [],
|
productores: [], productos: [], movimientosInventario: [],
|
||||||
ventas: [], ventaLineas: [], gastos: [], ajustes: [], cuadres: []
|
ventas: [], ventaLineas: [], gastos: [], ajustes: [], cuadres: [], jornadas: []
|
||||||
};
|
};
|
||||||
const exp = service.exportAll(data);
|
const exp = service.exportAll(data);
|
||||||
expect(exp.schemaVersion).toBe(CURRENT_SCHEMA_VERSION);
|
expect(exp.schemaVersion).toBe(CURRENT_SCHEMA_VERSION);
|
||||||
@@ -25,14 +25,19 @@ describe('ExportImportService', () => {
|
|||||||
schemaVersion: 999,
|
schemaVersion: 999,
|
||||||
appVersion: '0.0.1',
|
appVersion: '0.0.1',
|
||||||
exportedAt: new Date().toISOString(),
|
exportedAt: new Date().toISOString(),
|
||||||
data: { productores: [], productos: [], movimientosInventario: [], ventas: [], ventaLineas: [], gastos: [], ajustes: [], cuadres: [] }
|
data: { productores: [], productos: [], movimientosInventario: [], ventas: [], ventaLineas: [], gastos: [], ajustes: [], cuadres: [], jornadas: [] }
|
||||||
};
|
};
|
||||||
expect(() => service.import(bad)).toThrow('Versión de esquema incompatible');
|
expect(() => service.import(bad)).toThrow('Versión de esquema incompatible');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('importa datos válidos v1', () => {
|
it('importa datos válidos v1', () => {
|
||||||
const exp = service.exportAll({ productores: [], productos: [], movimientosInventario: [], ventas: [], ventaLineas: [], gastos: [], ajustes: [], cuadres: [] });
|
const exp = service.exportAll({ productores: [], productos: [], movimientosInventario: [], ventas: [], ventaLineas: [], gastos: [], ajustes: [], cuadres: [], jornadas: [] });
|
||||||
const imp = service.import(exp);
|
const imp = service.import(exp);
|
||||||
expect(imp.schemaVersion).toBe(CURRENT_SCHEMA_VERSION);
|
expect(imp.schemaVersion).toBe(CURRENT_SCHEMA_VERSION);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('migra exportaciones v1 sin jornadas', () => {
|
||||||
|
const legacy = { schemaVersion: 1, appVersion: '0.0.1', exportedAt: new Date().toISOString(), data: { productores: [], productos: [], movimientosInventario: [], ventas: [], ventaLineas: [], gastos: [], ajustes: [], cuadres: [] } };
|
||||||
|
expect(service.import(legacy).data.jornadas).toEqual([]);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -51,6 +51,10 @@ export class CuadreService {
|
|||||||
if (cuadre.saldoRealEfectivo !== undefined && cuadre.saldoEsperadoEfectivo !== undefined) {
|
if (cuadre.saldoRealEfectivo !== undefined && cuadre.saldoEsperadoEfectivo !== undefined) {
|
||||||
cuadre.descuadreEfectivo = calcularDescuadreEfectivo(cuadre.saldoEsperadoEfectivo, cuadre.saldoRealEfectivo);
|
cuadre.descuadreEfectivo = calcularDescuadreEfectivo(cuadre.saldoEsperadoEfectivo, cuadre.saldoRealEfectivo);
|
||||||
}
|
}
|
||||||
|
if (cuadre.totalQRBanco !== undefined) {
|
||||||
|
cuadre.totalQRRegistrado = cuadre.totalVentasQR - cuadre.totalGastosQR;
|
||||||
|
cuadre.descuadreQR = cuadre.totalQRBanco - cuadre.totalQRRegistrado;
|
||||||
|
}
|
||||||
return db.cuadres.add(cuadre);
|
return db.cuadres.add(cuadre);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { Productor, Producto, MovimientoInventario, Venta, VentaLinea, Gasto, Ajuste, CuadreCaja } from '@toldo/core';
|
import type { Productor, Producto, MovimientoInventario, Venta, VentaLinea, Gasto, Ajuste, CuadreCaja, JornadaToldo } from '@toldo/core';
|
||||||
|
|
||||||
export interface ExportData {
|
export interface ExportData {
|
||||||
schemaVersion: number;
|
schemaVersion: number;
|
||||||
@@ -13,6 +13,7 @@ export interface ExportData {
|
|||||||
gastos: Gasto[];
|
gastos: Gasto[];
|
||||||
ajustes: Ajuste[];
|
ajustes: Ajuste[];
|
||||||
cuadres: CuadreCaja[];
|
cuadres: CuadreCaja[];
|
||||||
|
jornadas?: JornadaToldo[];
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -51,6 +52,6 @@ export class ExportImportService {
|
|||||||
throw new Error('Versión de esquema incompatible. Actualice la aplicación.');
|
throw new Error('Versión de esquema incompatible. Actualice la aplicación.');
|
||||||
}
|
}
|
||||||
// Simple pass-through for v1; migrations would go here
|
// Simple pass-through for v1; migrations would go here
|
||||||
return data as ExportData;
|
return { ...(data as ExportData), data: { ...(data as ExportData).data, jornadas: (data as ExportData).data.jornadas ?? [] } };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,10 +17,14 @@ export class ReportesService {
|
|||||||
for (const productor of productores) {
|
for (const productor of productores) {
|
||||||
const prodsProductor = productos.filter(p => p.productorId === productor.id);
|
const prodsProductor = productos.filter(p => p.productorId === productor.id);
|
||||||
let totalVentas = 0;
|
let totalVentas = 0;
|
||||||
|
let inventarioInicial = 0;
|
||||||
|
let diferenciaInventario = 0;
|
||||||
const salidas: any[] = [];
|
const salidas: any[] = [];
|
||||||
|
|
||||||
for (const prod of prodsProductor) {
|
for (const prod of prodsProductor) {
|
||||||
const lineasProd = lineas.filter(l => l.productoId === prod.id && ventas.some(v => v.id === l.ventaId));
|
const lineasProd = lineas.filter(l => l.productoId === prod.id && ventas.some(v => v.id === l.ventaId));
|
||||||
|
const movimientos = await db.movimientosInventario.where('productoId').equals(prod.id).toArray();
|
||||||
|
inventarioInicial += movimientos.filter(m => m.tipo === 'INGRESO_INICIAL').reduce((sum, m) => sum + m.cantidad, 0);
|
||||||
for (const lp of lineasProd) {
|
for (const lp of lineasProd) {
|
||||||
totalVentas += lp.subtotal;
|
totalVentas += lp.subtotal;
|
||||||
salidas.push({ producto: prod.nombrePresentacion, cantidad: lp.cantidad, valor: lp.subtotal });
|
salidas.push({ producto: prod.nombrePresentacion, cantidad: lp.cantidad, valor: lp.subtotal });
|
||||||
@@ -29,6 +33,8 @@ export class ReportesService {
|
|||||||
|
|
||||||
reporte.push({
|
reporte.push({
|
||||||
productor: productor.nombre,
|
productor: productor.nombre,
|
||||||
|
inventarioInicial,
|
||||||
|
diferenciaInventario: (ajustes.filter(a => prodsProductor.some(p => p.id === a.productoId)) as any[]).reduce((sum, a) => sum + a.diferencia, 0),
|
||||||
ajustes: ajustes.filter(a => prodsProductor.some(p => p.id === a.productoId)),
|
ajustes: ajustes.filter(a => prodsProductor.some(p => p.id === a.productoId)),
|
||||||
salidas,
|
salidas,
|
||||||
totalVentas
|
totalVentas
|
||||||
@@ -51,6 +57,15 @@ export class ReportesService {
|
|||||||
const ventas = await db.ventas.where('fecha').between(fechaInicio, fechaFin).toArray();
|
const ventas = await db.ventas.where('fecha').between(fechaInicio, fechaFin).toArray();
|
||||||
const gastos = await db.gastos.where('fecha').between(fechaInicio, fechaFin).toArray();
|
const gastos = await db.gastos.where('fecha').between(fechaInicio, fechaFin).toArray();
|
||||||
const ajustes = await db.ajustes.where('fecha').between(fechaInicio, fechaFin).toArray();
|
const ajustes = await db.ajustes.where('fecha').between(fechaInicio, fechaFin).toArray();
|
||||||
return { ventas: ventas.length, gastos: gastos.length, ajustes: ajustes.length };
|
const lineas = await db.ventaLineas.toArray();
|
||||||
|
const ventasTotal = ventas.reduce((total, venta) => total + venta.total, 0);
|
||||||
|
const gastosTotal = gastos.reduce((total, gasto) => total + gasto.monto, 0);
|
||||||
|
const productores = await db.productores.toArray();
|
||||||
|
const productos = await db.productos.toArray();
|
||||||
|
const porProductor = productores.map((productor) => {
|
||||||
|
const ids = productos.filter((p) => p.productorId === productor.id).map((p) => p.id);
|
||||||
|
return { productor: productor.nombre, totalVentas: lineas.filter((l) => ids.includes(l.productoId) && ventas.some((v) => v.id === l.ventaId)).reduce((total, l) => total + l.subtotal, 0) };
|
||||||
|
});
|
||||||
|
return { ventas: ventas.length, gastos: gastos.length, ajustes: ajustes.length, ventasTotal, gastosTotal, porProductor, fechaInicio, fechaFin };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,8 +3,16 @@ import type { Venta, VentaLinea } from '@toldo/core';
|
|||||||
|
|
||||||
export class VentasService {
|
export class VentasService {
|
||||||
async registrarVenta(venta: Venta, lineas: VentaLinea[]) {
|
async registrarVenta(venta: Venta, lineas: VentaLinea[]) {
|
||||||
|
if (!lineas.length || lineas.some((linea) => linea.cantidad <= 0)) {
|
||||||
|
throw new Error('La venta debe tener cantidades mayores que cero');
|
||||||
|
}
|
||||||
// Validar stock mínimo conceptual - se puede expandir
|
// Validar stock mínimo conceptual - se puede expandir
|
||||||
return db.transaction('rw', db.ventas, db.ventaLineas, async () => {
|
return db.transaction('rw', db.ventas, db.ventaLineas, async () => {
|
||||||
|
for (const linea of lineas) {
|
||||||
|
const movimientos = await db.movimientosInventario.where('productoId').equals(linea.productoId).toArray();
|
||||||
|
const stock = movimientos.reduce((total, movimiento) => total + movimiento.cantidad, 0);
|
||||||
|
if (stock < linea.cantidad) throw new Error(`Stock insuficiente para ${linea.productoId}. Disponible: ${stock}`);
|
||||||
|
}
|
||||||
await db.ventas.add(venta);
|
await db.ventas.add(venta);
|
||||||
await db.ventaLineas.bulkAdd(lineas);
|
await db.ventaLineas.bulkAdd(lineas);
|
||||||
// Registrar movimientos de venta
|
// Registrar movimientos de venta
|
||||||
|
|||||||
Reference in New Issue
Block a user