Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
488f383904 | ||
|
|
6a3352a96c | ||
|
|
a21c43684c | ||
|
|
1470ffd0de | ||
|
|
ba18b73ed9 |
13
AGENTS.md
13
AGENTS.md
@@ -25,3 +25,16 @@ Skills del proyecto en `skills/`:
|
|||||||
Seguir OpenSpec workflow: propose -> apply -> archive
|
Seguir OpenSpec workflow: propose -> apply -> archive
|
||||||
Usar skills cuando aplique al contexto
|
Usar skills cuando aplique al contexto
|
||||||
Mantener código siguiendo arquitectura limpia
|
Mantener código siguiendo arquitectura limpia
|
||||||
|
|
||||||
|
## Release Android
|
||||||
|
- Antes de publicar una APK, ejecutar `npm run test:run`, `npm run test:e2e`, `npm exec vue-tsc -- --noEmit` y `npm run build`.
|
||||||
|
- Ejecutar `npx cap sync android` y `./gradlew assembleDebug` usando el builder Android del repositorio cuando el host no tenga Java.
|
||||||
|
- Verificar el APK con `scripts/verify-apk.sh <apk> <versionName> <versionCode>` antes de adjuntarlo a Gitea.
|
||||||
|
- El script debe confirmar `applicationId`, `versionName`, `versionCode`, bundle JavaScript, fuentes MDI y textos críticos de la versión.
|
||||||
|
- Incrementar `versionCode` para cada APK publicable; mantener la misma firma y publicar versiones beta como prerelease.
|
||||||
|
- Después de subir un asset, comparar su SHA256 con el APK verificado localmente y comprobar el asset de la release mediante la API de Gitea.
|
||||||
|
|
||||||
|
## Persistencia local
|
||||||
|
- La app debe abrir Dexie explícitamente antes de montar Vue.
|
||||||
|
- Las migraciones deben agregar una nueva versión Dexie y transformar datos; nunca deben borrar datos automáticamente.
|
||||||
|
- El reset de la base es una acción manual, confirmada y visible sólo como último recurso.
|
||||||
|
|||||||
4
apps/toldo-app/package-lock.json
generated
4
apps/toldo-app/package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "toldo-app",
|
"name": "toldo-app",
|
||||||
"version": "0.0.3",
|
"version": "0.0.6",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "toldo-app",
|
"name": "toldo-app",
|
||||||
"version": "0.0.3",
|
"version": "0.0.6",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@capacitor/android": "^6.2.1",
|
"@capacitor/android": "^6.2.1",
|
||||||
"@capacitor/core": "^6.2.1",
|
"@capacitor/core": "^6.2.1",
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "toldo-app",
|
"name": "toldo-app",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.0.3",
|
"version": "0.0.6",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { defineConfig, devices } from '@playwright/test';
|
|||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
testDir: './tests/e2e',
|
testDir: './tests/e2e',
|
||||||
timeout: 30_000,
|
timeout: 30_000,
|
||||||
|
workers: 1,
|
||||||
use: { baseURL: 'http://127.0.0.1:4173', trace: 'on-first-retry' },
|
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 },
|
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' } } }],
|
projects: [{ name: 'chromium', use: { ...devices['Desktop Chrome'], launchOptions: { executablePath: '/usr/bin/chromium' } } }, { name: 'mobile', use: { ...devices['Pixel 5'], launchOptions: { executablePath: '/usr/bin/chromium' } } }],
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ describe('contratos de operación y respaldo', () => {
|
|||||||
|
|
||||||
it('expone la versión actual en las exportaciones', () => {
|
it('expone la versión actual en las exportaciones', () => {
|
||||||
const result = new ExportImportService().exportAll({ productores: [], productos: [], movimientosInventario: [], ventas: [], ventaLineas: [], gastos: [], ajustes: [], cuadres: [] });
|
const result = new ExportImportService().exportAll({ productores: [], productos: [], movimientosInventario: [], ventas: [], ventaLineas: [], gastos: [], ajustes: [], cuadres: [] });
|
||||||
expect(result.appVersion).toBe('0.0.3');
|
expect(result.appVersion).toBe('0.0.6');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('exporta jornadas junto con los datos operativos', () => {
|
it('exporta jornadas junto con los datos operativos', () => {
|
||||||
|
|||||||
22
apps/toldo-app/src/__tests__/database-bootstrap.test.ts
Normal file
22
apps/toldo-app/src/__tests__/database-bootstrap.test.ts
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import { beforeEach, describe, expect, it } from 'vitest';
|
||||||
|
import { db, openDatabase, resetDatabase } from '@toldo/data';
|
||||||
|
|
||||||
|
describe('bootstrap y recuperación de base local', () => {
|
||||||
|
beforeEach(async () => {
|
||||||
|
await resetDatabase();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('abre el esquema actual y conserva datos al cerrar y abrir', async () => {
|
||||||
|
await db.productores.add({ id: 'persisted', nombre: 'Productor persistente', activo: true, createdAt: new Date() } as any);
|
||||||
|
db.close();
|
||||||
|
await openDatabase();
|
||||||
|
expect(await db.productores.get('persisted')).toMatchObject({ nombre: 'Productor persistente' });
|
||||||
|
expect(db.auditorias).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('restablece sólo cuando se solicita explícitamente', async () => {
|
||||||
|
await db.productores.add({ id: 'to-reset', nombre: 'Temporal', activo: true, createdAt: new Date() } as any);
|
||||||
|
await resetDatabase();
|
||||||
|
expect(await db.productores.count()).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
8
apps/toldo-app/src/__tests__/sale-payment.test.ts
Normal file
8
apps/toldo-app/src/__tests__/sale-payment.test.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { calcularDevuelta, validarPagoEfectivo } from '@toldo/core';
|
||||||
|
|
||||||
|
describe('pago en efectivo', () => {
|
||||||
|
it('calcula la devuelta', () => expect(calcularDevuelta(12000, 20000)).toBe(8000));
|
||||||
|
it('rechaza efectivo insuficiente', () => expect(validarPagoEfectivo(12000, 10000)).toContain('no alcanza'));
|
||||||
|
it('acepta efectivo exacto', () => expect(validarPagoEfectivo(12000, 12000)).toBeNull());
|
||||||
|
});
|
||||||
@@ -29,10 +29,21 @@ describe('casos de uso operativos offline', () => {
|
|||||||
const service = new JornadaService();
|
const service = new JornadaService();
|
||||||
const jornadaId = await service.abrirJornada(['Ana'], 50000);
|
const jornadaId = await service.abrirJornada(['Ana'], 50000);
|
||||||
await expect(service.abrirJornada(['Luis'], 10000)).rejects.toThrow('Ya existe una jornada abierta');
|
await expect(service.abrirJornada(['Luis'], 10000)).rejects.toThrow('Ya existe una jornada abierta');
|
||||||
await service.cerrarJornada(jornadaId);
|
await service.cerrarJornada(jornadaId, 0);
|
||||||
await expect(service.registrarGastoEnJornada(jornadaId, { id: 'gasto-cerrado', fecha, concepto: 'Bolsas', monto: 1000, formaPago: 'EFECTIVO', createdAt: fecha } as any)).rejects.toThrow('jornada cerrada');
|
await expect(service.registrarGastoEnJornada(jornadaId, { id: 'gasto-cerrado', fecha, concepto: 'Bolsas', monto: 1000, formaPago: 'EFECTIVO', createdAt: fecha } as any)).rejects.toThrow('jornada cerrada');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('cierra caja y jornada juntos con banco QR opcional', async () => {
|
||||||
|
const service = new JornadaService();
|
||||||
|
const jornadaId = await service.abrirJornada(['Ana'], 50000);
|
||||||
|
const cuadreId = await service.cerrarJornada(jornadaId, 48000, 12000);
|
||||||
|
const cuadre = await db.cuadres.get(cuadreId);
|
||||||
|
const jornada = await db.jornadas.get(jornadaId);
|
||||||
|
expect(cuadre?.saldoRealEfectivo).toBe(48000);
|
||||||
|
expect(cuadre?.totalQRBanco).toBe(12000);
|
||||||
|
expect(jornada?.estado).toBe('CERRADA');
|
||||||
|
});
|
||||||
|
|
||||||
it('calcula cuadre mixto de efectivo y QR y registra ambos descuadres', async () => {
|
it('calcula cuadre mixto de efectivo y QR y registra ambos descuadres', async () => {
|
||||||
await crearProducto();
|
await crearProducto();
|
||||||
const jornada = new JornadaService();
|
const jornada = new JornadaService();
|
||||||
|
|||||||
@@ -16,15 +16,10 @@
|
|||||||
|
|
||||||
<v-main>
|
<v-main>
|
||||||
<v-container fluid class="pa-4 pa-md-6">
|
<v-container fluid class="pa-4 pa-md-6">
|
||||||
<div class="d-flex flex-wrap align-center justify-space-between mb-3 ga-2">
|
<div class="d-flex justify-end mb-3 ga-2">
|
||||||
<v-breadcrumbs :items="breadcrumbs" class="pa-0" aria-label="Migas de pan">
|
|
||||||
<template #prepend><v-icon icon="mdi-home-outline" size="small" /></template>
|
|
||||||
</v-breadcrumbs>
|
|
||||||
<div class="d-flex ga-2">
|
|
||||||
<v-btn variant="text" size="small" prepend-icon="mdi-arrow-left" data-testid="back-button" @click="goBack">Atrás</v-btn>
|
<v-btn variant="text" size="small" prepend-icon="mdi-arrow-left" data-testid="back-button" @click="goBack">Atrás</v-btn>
|
||||||
<v-btn to="/" variant="text" size="small" prepend-icon="mdi-home">Inicio</v-btn>
|
<v-btn to="/" variant="text" size="small" prepend-icon="mdi-home">Inicio</v-btn>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
<router-view />
|
<router-view />
|
||||||
</v-container>
|
</v-container>
|
||||||
</v-main>
|
</v-main>
|
||||||
@@ -32,30 +27,26 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onMounted, onUnmounted, ref } from 'vue';
|
import { onMounted, onUnmounted, ref } from 'vue';
|
||||||
import { JornadaService } from '@toldo/services';
|
import { JornadaService } from '@toldo/services';
|
||||||
import { useRoute, useRouter } from 'vue-router';
|
import { useRouter } from 'vue-router';
|
||||||
|
|
||||||
const drawer = ref(true);
|
const drawer = ref(true);
|
||||||
const isMobile = ref(false);
|
const isMobile = ref(false);
|
||||||
const jornada = ref<any>(null);
|
const jornada = ref<any>(null);
|
||||||
const jornadaService = new JornadaService();
|
const jornadaService = new JornadaService();
|
||||||
const route = useRoute();
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const navItems = [
|
const navItems = [
|
||||||
{ to: '/jornada', icon: 'mdi-calendar-check', title: 'Jornada' },
|
{ to: '/jornada', icon: 'mdi-calendar-check', title: 'Jornada' },
|
||||||
{ to: '/inventario', icon: 'mdi-package-variant', title: 'Inventario' },
|
{ to: '/inventario', icon: 'mdi-package-variant', title: 'Inventario' },
|
||||||
{ to: '/ventas', icon: 'mdi-cart', title: 'Ventas POS' },
|
{ to: '/ventas', icon: 'mdi-cart', title: 'Ventas POS' },
|
||||||
{ to: '/gastos', icon: 'mdi-cash-minus', title: 'Gastos' },
|
{ 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: '/reportes', icon: 'mdi-chart-bar', title: 'Reportes' },
|
||||||
{ to: '/exportar', icon: 'mdi-export', title: 'Exportar / importar' },
|
{ to: '/exportar', icon: 'mdi-export', title: 'Exportar / importar' },
|
||||||
];
|
];
|
||||||
const breadcrumbs = computed(() => [{ title: 'Inicio', disabled: false, to: '/' }, ...(route.path === '/' ? [] : [{ title: String(route.meta.title ?? 'Sección'), disabled: true }])]);
|
|
||||||
function goBack() { if (window.history.state?.back) router.back(); else router.push('/'); }
|
|
||||||
function updateViewport() { isMobile.value = window.innerWidth < 960; if (isMobile.value) drawer.value = false; }
|
function updateViewport() { isMobile.value = window.innerWidth < 960; if (isMobile.value) drawer.value = false; }
|
||||||
function closeOnMobile() { if (isMobile.value) drawer.value = false; }
|
function closeOnMobile() { if (isMobile.value) drawer.value = false; }
|
||||||
|
function goBack() { if (window.history.state?.back) router.back(); else router.push('/'); }
|
||||||
onMounted(async () => { updateViewport(); window.addEventListener('resize', updateViewport); jornada.value = await jornadaService.getJornadaAbierta(); });
|
onMounted(async () => { updateViewport(); window.addEventListener('resize', updateViewport); jornada.value = await jornadaService.getJornadaAbierta(); });
|
||||||
onUnmounted(() => window.removeEventListener('resize', updateViewport));
|
onUnmounted(() => window.removeEventListener('resize', updateViewport));
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -2,5 +2,27 @@ import { createApp } from 'vue'
|
|||||||
import App from './App.vue'
|
import App from './App.vue'
|
||||||
import router from './router'
|
import router from './router'
|
||||||
import vuetify from './plugins/vuetify'
|
import vuetify from './plugins/vuetify'
|
||||||
|
import { openDatabase, resetDatabase } from '@toldo/data'
|
||||||
|
|
||||||
createApp(App).use(router).use(vuetify).mount('#app')
|
async function bootstrap() {
|
||||||
|
try {
|
||||||
|
await openDatabase()
|
||||||
|
createApp(App).use(router).use(vuetify).mount('#app')
|
||||||
|
} catch (error) {
|
||||||
|
const root = document.querySelector('#app')
|
||||||
|
if (!root) return
|
||||||
|
root.innerHTML = ''
|
||||||
|
const card = document.createElement('main')
|
||||||
|
card.className = 'database-recovery'
|
||||||
|
card.innerHTML = '<h1>No se pudo abrir los datos locales</h1><p>La aplicación no continuará para evitar mostrar información incompleta. Puedes intentar recargar o restablecer el entorno como último recurso.</p>'
|
||||||
|
const details = document.createElement('pre')
|
||||||
|
details.textContent = error instanceof Error ? error.message : String(error)
|
||||||
|
const button = document.createElement('button')
|
||||||
|
button.textContent = 'Restablecer entorno local'
|
||||||
|
button.onclick = async () => { button.disabled = true; await resetDatabase(); window.location.reload() }
|
||||||
|
card.append(details, button)
|
||||||
|
root.append(card)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bootstrap()
|
||||||
|
|||||||
@@ -4,9 +4,7 @@ import Home from '../views/Home.vue';
|
|||||||
import Inventario from '../views/Inventario.vue';
|
import Inventario from '../views/Inventario.vue';
|
||||||
import Ventas from '../views/Ventas.vue';
|
import Ventas from '../views/Ventas.vue';
|
||||||
import Gastos from '../views/Gastos.vue';
|
import Gastos from '../views/Gastos.vue';
|
||||||
import Cuadre from '../views/Cuadre.vue';
|
|
||||||
import Reportes from '../views/Reportes.vue';
|
import Reportes from '../views/Reportes.vue';
|
||||||
import Ajustes from '../views/Ajustes.vue';
|
|
||||||
import Exportar from '../views/Exportar.vue';
|
import Exportar from '../views/Exportar.vue';
|
||||||
import Jornada from '../views/Jornada.vue';
|
import Jornada from '../views/Jornada.vue';
|
||||||
|
|
||||||
@@ -22,9 +20,7 @@ const router = createRouter({
|
|||||||
{ path: 'inventario', component: Inventario, meta: { title: 'Inventario' } },
|
{ path: 'inventario', component: Inventario, meta: { title: 'Inventario' } },
|
||||||
{ path: 'ventas', component: Ventas, meta: { title: 'Ventas POS' } },
|
{ path: 'ventas', component: Ventas, meta: { title: 'Ventas POS' } },
|
||||||
{ path: 'gastos', component: Gastos, meta: { title: 'Gastos' } },
|
{ path: 'gastos', component: Gastos, meta: { title: 'Gastos' } },
|
||||||
{ path: 'cuadre', component: Cuadre, meta: { title: 'Cierre de caja' } },
|
|
||||||
{ path: 'reportes', component: Reportes, meta: { title: 'Reportes' } },
|
{ path: 'reportes', component: Reportes, meta: { title: 'Reportes' } },
|
||||||
{ path: 'ajustes', component: Ajustes, meta: { title: 'Ajustes' } },
|
|
||||||
{ path: 'exportar', component: Exportar, meta: { title: 'Exportar / importar' } },
|
{ path: 'exportar', component: Exportar, meta: { title: 'Exportar / importar' } },
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -56,6 +56,11 @@ body {
|
|||||||
background: #f5f7f2;
|
background: #f5f7f2;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.database-recovery { max-width: 640px; margin: 12vh auto; padding: 24px; font-family: system-ui, sans-serif; color: #26352b; background: #fffdf8; border: 1px solid #d9e2d7; border-radius: 12px; box-shadow: 0 8px 30px rgba(38, 53, 43, .12); }
|
||||||
|
.database-recovery p { margin: 12px 0; }
|
||||||
|
.database-recovery pre { white-space: pre-wrap; font-size: 12px; color: #6b4a25; background: #f7f1e7; padding: 12px; border-radius: 8px; }
|
||||||
|
.database-recovery button { padding: 12px 16px; color: white; background: #2d5f3b; border: 0; border-radius: 8px; cursor: pointer; }
|
||||||
|
|
||||||
h1,
|
h1,
|
||||||
h2 {
|
h2 {
|
||||||
font-family: var(--heading);
|
font-family: var(--heading);
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<template>
|
<template>
|
||||||
<div>
|
<div>
|
||||||
<h2>Ajustes</h2>
|
<div class="mb-5"><div class="text-overline text-primary">INVENTARIO</div><h1 class="text-h4 mb-1">Historial de ajustes</h1><div class="text-body-2 text-medium-emphasis">Los ajustes se realizan desde Inventario.</div></div>
|
||||||
<v-alert v-if="mensaje" :type="tipoMensaje" variant="tonal" class="mb-3">{{ mensaje }}</v-alert>
|
<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>
|
||||||
|
|||||||
@@ -8,19 +8,25 @@
|
|||||||
|
|
||||||
<section>
|
<section>
|
||||||
<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>
|
<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>
|
||||||
|
<v-card class="mt-4" variant="tonal" color="error"><v-card-title>Mantenimiento</v-card-title><v-card-text><div class="text-body-2 mb-3">Restablece la base local sólo si la aplicación no puede migrarla. Esta acción elimina ventas, inventario, jornadas y respaldos locales.</div><v-btn color="error" variant="outlined" prepend-icon="mdi-database-refresh" @click="reiniciarEntorno">Restablecer entorno local</v-btn></v-card-text></v-card>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref } from 'vue';
|
import { ref } from 'vue';
|
||||||
import { db } from '@toldo/data';
|
import { db, resetDatabase } from '@toldo/data';
|
||||||
import { ExportImportService } from '@toldo/services';
|
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 mensaje = ref('');
|
||||||
const tipoMensaje = ref<'success' | 'error'>('success');
|
const tipoMensaje = ref<'success' | 'error'>('success');
|
||||||
|
async function reiniciarEntorno() {
|
||||||
|
if (!window.confirm('Se eliminarán todos los datos locales. Exporta un respaldo antes de continuar. ¿Restablecer?')) return;
|
||||||
|
await resetDatabase();
|
||||||
|
window.location.reload();
|
||||||
|
}
|
||||||
|
|
||||||
function onFileChange(e: any) {
|
function onFileChange(e: any) {
|
||||||
archivo.value = e.target.files[0];
|
archivo.value = e.target.files[0];
|
||||||
|
|||||||
@@ -1,24 +1,10 @@
|
|||||||
<template>
|
<template>
|
||||||
<div>
|
<div>
|
||||||
<h2>Gastos</h2>
|
<div class="mb-5"><div class="text-overline text-primary">OPERACION</div><h1 class="text-h4 mb-1">Gastos</h1><div class="text-body-2 text-medium-emphasis">Registra compras y pagos de la jornada.</div></div>
|
||||||
<v-alert v-if="mensaje" :type="tipoMensaje" variant="tonal" class="mb-3">{{ mensaje }}</v-alert>
|
<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>
|
<v-alert :type="jornadaAbierta ? 'success' : 'warning'" variant="tonal" class="mb-4">{{ jornadaAbierta ? 'Jornada abierta' : 'Abre una jornada para registrar gastos' }}</v-alert>
|
||||||
<p v-else style="color: green">Jornada abierta</p>
|
<v-card class="mb-4"><v-card-title>Registrar gasto</v-card-title><v-card-text><v-form @submit.prevent="registrar"><v-text-field v-model="concepto" label="Concepto" placeholder="Cinta, lapicero, bolsas..." prepend-inner-icon="mdi-receipt-text" /><v-row><v-col cols="12" sm="6"><v-text-field v-model.number="monto" type="number" label="Monto" prefix="$" /></v-col><v-col cols="12" sm="6"><v-select v-model="formaPago" :items="['EFECTIVO', 'QR']" label="Forma de pago" /></v-col></v-row><v-btn type="submit" color="primary" block size="large" :disabled="!jornadaAbierta" prepend-icon="mdi-plus-circle">Registrar gasto</v-btn></v-form></v-card-text></v-card>
|
||||||
<section>
|
<v-card><v-card-title>Gastos del día</v-card-title><v-list><v-list-item v-for="g in gastos" :key="g.id" :title="g.concepto" :subtitle="g.formaPago"><template #append><span class="font-weight-bold">${{ g.monto.toLocaleString() }}</span></template></v-list-item><v-list-item v-if="!gastos.length" title="Sin gastos registrados" /></v-list></v-card>
|
||||||
<input v-model="concepto" placeholder="Concepto (cinta, lapicero, bolsas...)" />
|
|
||||||
<input v-model.number="monto" type="number" placeholder="Monto" />
|
|
||||||
<select v-model="formaPago">
|
|
||||||
<option value="EFECTIVO">Efectivo</option>
|
|
||||||
<option value="QR">QR</option>
|
|
||||||
</select>
|
|
||||||
<button @click="registrar" :disabled="!jornadaAbierta">Registrar Gasto</button>
|
|
||||||
</section>
|
|
||||||
<section>
|
|
||||||
<h3>Gastos del día</h3>
|
|
||||||
<ul>
|
|
||||||
<li v-for="g in gastos" :key="g.id">{{ g.concepto }} - ${{ g.monto }} ({{ g.formaPago }})</li>
|
|
||||||
</ul>
|
|
||||||
</section>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|||||||
@@ -6,8 +6,9 @@
|
|||||||
<v-card-title><v-icon icon="mdi-file-delimited" class="mr-2" />Importar inventario inicial</v-card-title>
|
<v-card-title><v-icon icon="mdi-file-delimited" class="mr-2" />Importar inventario inicial</v-card-title>
|
||||||
<v-card-text><div class="text-body-2 mb-3">CSV con: Nombre de producto, Nombre Productor, precio unitario y cantidad.</div><v-file-input v-model="csvFile" accept=".csv,text/csv" label="Seleccionar CSV" density="compact" prepend-icon="mdi-paperclip" @update:model-value="previsualizarCsv" /><v-alert v-if="previewRows.length" variant="outlined" density="compact" class="mb-3"><div class="text-caption font-weight-bold mb-1">Previsualización</div><div v-for="(row, index) in previewRows" :key="index" class="text-caption text-truncate">{{ row }}</div></v-alert><v-btn :disabled="!csvFile || !previewRows.length" color="info" @click="importarCsv" prepend-icon="mdi-upload">Confirmar importación</v-btn></v-card-text>
|
<v-card-text><div class="text-body-2 mb-3">CSV con: Nombre de producto, Nombre Productor, precio unitario y cantidad.</div><v-file-input v-model="csvFile" accept=".csv,text/csv" label="Seleccionar CSV" density="compact" prepend-icon="mdi-paperclip" @update:model-value="previsualizarCsv" /><v-alert v-if="previewRows.length" variant="outlined" density="compact" class="mb-3"><div class="text-caption font-weight-bold mb-1">Previsualización</div><div v-for="(row, index) in previewRows" :key="index" class="text-caption text-truncate">{{ row }}</div></v-alert><v-btn :disabled="!csvFile || !previewRows.length" color="info" @click="importarCsv" prepend-icon="mdi-upload">Confirmar importación</v-btn></v-card-text>
|
||||||
</v-card>
|
</v-card>
|
||||||
|
<v-tabs v-model="inventoryTab" class="d-md-none mb-4" color="primary" grow><v-tab value="catalogo">Catálogo</v-tab><v-tab value="ingresos">Ingresos</v-tab><v-tab value="ajustes">Ajustes</v-tab></v-tabs>
|
||||||
|
|
||||||
<v-row>
|
<v-row v-show="!isMobile || inventoryTab === 'catalogo'">
|
||||||
<v-col cols="12" md="6">
|
<v-col cols="12" md="6">
|
||||||
<v-card class="mb-4">
|
<v-card class="mb-4">
|
||||||
<v-card-title>Productores</v-card-title>
|
<v-card-title>Productores</v-card-title>
|
||||||
@@ -45,7 +46,7 @@
|
|||||||
</v-col>
|
</v-col>
|
||||||
</v-row>
|
</v-row>
|
||||||
|
|
||||||
<v-row>
|
<v-row v-show="!isMobile || inventoryTab === 'ingresos'">
|
||||||
<v-col cols="12" md="6">
|
<v-col cols="12" md="6">
|
||||||
<v-card>
|
<v-card>
|
||||||
<v-card-title>Ingreso Inicial del Día</v-card-title>
|
<v-card-title>Ingreso Inicial del Día</v-card-title>
|
||||||
@@ -69,16 +70,19 @@
|
|||||||
</v-card>
|
</v-card>
|
||||||
</v-col>
|
</v-col>
|
||||||
</v-row>
|
</v-row>
|
||||||
|
<v-card v-show="!isMobile || inventoryTab === 'ajustes'" class="mt-4"><v-card-title>Ajustar inventario</v-card-title><v-card-text><v-select v-model="productoAjuste" :items="productos" item-title="nombrePresentacion" item-value="id" label="Producto" /><v-text-field v-model.number="cantidadAjuste" type="number" label="Nueva cantidad" /><v-textarea v-model="motivoAjuste" label="Motivo obligatorio" rows="2" /><v-btn color="warning" block prepend-icon="mdi-scale-balance" @click="ajustarInventario">Guardar ajuste</v-btn></v-card-text></v-card>
|
||||||
<v-dialog v-model="dialogoEdicion" max-width="520"><v-card><v-card-title>Editar {{ entidadEdicion === 'PRODUCTOR' ? 'productor' : 'producto' }}</v-card-title><v-card-text><v-text-field v-if="entidadEdicion === 'PRODUCTOR'" v-model="edicionProductor.nombre" label="Nombre" /><template v-else><v-text-field v-model="edicionProducto.nombrePresentacion" label="Nombre / presentación" /><v-text-field v-model.number="edicionProducto.precioUnitario" type="number" label="Precio unitario" /></template><v-divider class="my-3" /><v-text-field v-model="actor" label="Quién realiza el cambio" /><v-textarea v-model="motivo" label="Motivo del cambio" rows="2" /></v-card-text><v-card-actions><v-spacer /><v-btn variant="text" @click="dialogoEdicion = false">Cancelar</v-btn><v-btn color="primary" @click="guardarEdicion">Guardar cambio</v-btn></v-card-actions></v-card></v-dialog>
|
<v-dialog v-model="dialogoEdicion" max-width="520"><v-card><v-card-title>Editar {{ entidadEdicion === 'PRODUCTOR' ? 'productor' : 'producto' }}</v-card-title><v-card-text><v-text-field v-if="entidadEdicion === 'PRODUCTOR'" v-model="edicionProductor.nombre" label="Nombre" /><template v-else><v-text-field v-model="edicionProducto.nombrePresentacion" label="Nombre / presentación" /><v-text-field v-model.number="edicionProducto.precioUnitario" type="number" label="Precio unitario" /></template><v-divider class="my-3" /><v-text-field v-model="actor" label="Quién realiza el cambio" /><v-textarea v-model="motivo" label="Motivo del cambio" rows="2" /></v-card-text><v-card-actions><v-spacer /><v-btn variant="text" @click="dialogoEdicion = false">Cancelar</v-btn><v-btn color="primary" @click="guardarEdicion">Guardar cambio</v-btn></v-card-actions></v-card></v-dialog>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, onMounted } from 'vue';
|
import { ref, computed, onMounted, onUnmounted } from 'vue';
|
||||||
import { db } from '@toldo/data';
|
import { db } from '@toldo/data';
|
||||||
import { InventarioService, ImportInventarioCsvService, EdicionService } from '@toldo/services';
|
import { InventarioService, ImportInventarioCsvService, EdicionService, AjustesService } from '@toldo/services';
|
||||||
|
import { validateAjusteMotivo } from '@toldo/core';
|
||||||
|
|
||||||
const invService = new InventarioService();
|
const invService = new InventarioService();
|
||||||
|
const ajustesService = new AjustesService();
|
||||||
const productores = ref<any[]>([]);
|
const productores = ref<any[]>([]);
|
||||||
const productos = ref<any[]>([]);
|
const productos = ref<any[]>([]);
|
||||||
const nuevoProductor = ref('');
|
const nuevoProductor = ref('');
|
||||||
@@ -103,11 +107,26 @@ const edicionProducto = ref<any>({});
|
|||||||
const originalEdicion = ref<any>({});
|
const originalEdicion = ref<any>({});
|
||||||
const actor = ref('');
|
const actor = ref('');
|
||||||
const motivo = ref('');
|
const motivo = ref('');
|
||||||
|
const productoAjuste = ref('');
|
||||||
|
const cantidadAjuste = ref(0);
|
||||||
|
const motivoAjuste = ref('');
|
||||||
|
const inventoryTab = ref('catalogo');
|
||||||
|
const isMobile = ref(false);
|
||||||
const busquedaProductores = ref('');
|
const busquedaProductores = ref('');
|
||||||
const busquedaProductos = ref('');
|
const busquedaProductos = ref('');
|
||||||
const productoresFiltrados = computed(() => productores.value.filter((productor) => !busquedaProductores.value.trim() || productor.nombre.toLocaleLowerCase().includes(busquedaProductores.value.trim().toLocaleLowerCase())));
|
const productoresFiltrados = computed(() => productores.value.filter((productor) => !busquedaProductores.value.trim() || productor.nombre.toLocaleLowerCase().includes(busquedaProductores.value.trim().toLocaleLowerCase())));
|
||||||
const productosFiltrados = computed(() => productos.value.filter((producto) => !busquedaProductos.value.trim() || producto.nombrePresentacion.toLocaleLowerCase().includes(busquedaProductos.value.trim().toLocaleLowerCase())));
|
const productosFiltrados = computed(() => productos.value.filter((producto) => !busquedaProductos.value.trim() || producto.nombrePresentacion.toLocaleLowerCase().includes(busquedaProductos.value.trim().toLocaleLowerCase())));
|
||||||
function avisar(texto: string, tipo: 'success' | 'error' = 'error') { mensaje.value = texto; tipoMensaje.value = tipo; }
|
function avisar(texto: string, tipo: 'success' | 'error' = 'error') { mensaje.value = texto; tipoMensaje.value = tipo; }
|
||||||
|
function updateViewport() { isMobile.value = window.innerWidth < 960; }
|
||||||
|
async function ajustarInventario() {
|
||||||
|
try {
|
||||||
|
validateAjusteMotivo(motivoAjuste.value);
|
||||||
|
if (!productoAjuste.value || cantidadAjuste.value < 0) return avisar('Selecciona un producto y una cantidad válida.');
|
||||||
|
const anterior = await invService.calcularSaldoProducto(productoAjuste.value);
|
||||||
|
await ajustesService.crearAjuste({ id: crypto.randomUUID(), fecha: new Date(), productoId: productoAjuste.value, cantidadAnterior: anterior, cantidadNueva: cantidadAjuste.value, diferencia: cantidadAjuste.value - anterior, motivo: motivoAjuste.value, createdAt: new Date() } as any);
|
||||||
|
cantidadAjuste.value = 0; motivoAjuste.value = ''; await cargar(); avisar('Ajuste registrado.', 'success');
|
||||||
|
} catch (error: any) { avisar(error.message); }
|
||||||
|
}
|
||||||
function editarProductor(productor: any) { entidadEdicion.value = 'PRODUCTOR'; originalEdicion.value = { ...productor }; edicionProductor.value = { ...productor }; actor.value = ''; motivo.value = ''; dialogoEdicion.value = true; }
|
function editarProductor(productor: any) { entidadEdicion.value = 'PRODUCTOR'; originalEdicion.value = { ...productor }; edicionProductor.value = { ...productor }; actor.value = ''; motivo.value = ''; dialogoEdicion.value = true; }
|
||||||
function editarProducto(producto: any) { entidadEdicion.value = 'PRODUCTO'; originalEdicion.value = { ...producto }; edicionProducto.value = { ...producto }; actor.value = ''; motivo.value = ''; dialogoEdicion.value = true; }
|
function editarProducto(producto: any) { entidadEdicion.value = 'PRODUCTO'; originalEdicion.value = { ...producto }; edicionProducto.value = { ...producto }; actor.value = ''; motivo.value = ''; dialogoEdicion.value = true; }
|
||||||
async function guardarEdicion() {
|
async function guardarEdicion() {
|
||||||
@@ -165,5 +184,6 @@ async function ingresoSemana() {
|
|||||||
avisar('Ingreso entre semana registrado.', 'success');
|
avisar('Ingreso entre semana registrado.', 'success');
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(cargar);
|
onMounted(() => { updateViewport(); window.addEventListener('resize', updateViewport); cargar(); });
|
||||||
|
onUnmounted(() => window.removeEventListener('resize', updateViewport));
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -26,9 +26,13 @@
|
|||||||
<div v-if="resumen">
|
<div v-if="resumen">
|
||||||
<div class="mb-2">Ventas: ${{ resumen.totalVentas }}</div>
|
<div class="mb-2">Ventas: ${{ resumen.totalVentas }}</div>
|
||||||
<div class="mb-2">Gastos: ${{ resumen.totalGastos }}</div>
|
<div class="mb-2">Gastos: ${{ resumen.totalGastos }}</div>
|
||||||
<div class="mb-2 font-weight-bold">Saldo esperado: ${{ jornadaAbierta ? jornadaAbierta.baseInicial + resumen.totalVentas - resumen.totalGastos : 0 }}</div>
|
<div class="mb-2 font-weight-bold">Saldo esperado en efectivo: ${{ saldoEsperadoEfectivo.toLocaleString() }}</div>
|
||||||
</div>
|
</div>
|
||||||
<v-btn @click="cerrar" color="error" block class="mt-2" :disabled="!jornadaAbierta">Cerrar Jornada (generar cuadre)</v-btn>
|
<v-text-field v-model.number="saldoRealEfectivo" type="number" label="Efectivo contado (obligatorio)" prefix="$" class="mt-4" />
|
||||||
|
<v-text-field v-model.number="totalQRBanco" type="number" label="Dinero en banco por QR (opcional)" prefix="$" />
|
||||||
|
<v-alert v-if="saldoRealEfectivo !== null && resumen" :type="diferenciaEfectivo === 0 ? 'success' : 'warning'" variant="tonal" class="mb-2">Diferencia de efectivo: ${{ diferenciaEfectivo.toLocaleString() }}</v-alert>
|
||||||
|
<v-alert v-if="cierreError" :type="cierreCompletado ? 'success' : 'error'" variant="tonal" class="mb-2">{{ cierreError }}</v-alert>
|
||||||
|
<v-btn @click="cerrar" color="error" block class="mt-2" :disabled="!jornadaAbierta || saldoRealEfectivo < 0" prepend-icon="mdi-lock-check">Cerrar caja y jornada</v-btn>
|
||||||
</v-card-text>
|
</v-card-text>
|
||||||
</v-card>
|
</v-card>
|
||||||
</v-col>
|
</v-col>
|
||||||
@@ -37,14 +41,21 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted } from 'vue';
|
import { ref, computed, onMounted } from 'vue';
|
||||||
import { JornadaService } from '@toldo/services';
|
import { JornadaService, CuadreService } from '@toldo/services';
|
||||||
|
|
||||||
const service = new JornadaService();
|
const service = new JornadaService();
|
||||||
|
const cuadreService = new CuadreService();
|
||||||
const jornadaAbierta = ref<any>(null);
|
const jornadaAbierta = ref<any>(null);
|
||||||
const responsablesStr = ref('');
|
const responsablesStr = ref('');
|
||||||
const baseInicial = ref(0);
|
const baseInicial = ref(0);
|
||||||
const resumen = ref<any>(null);
|
const resumen = ref<any>(null);
|
||||||
|
const saldoRealEfectivo = ref<number | null>(null);
|
||||||
|
const totalQRBanco = ref<number | null>(null);
|
||||||
|
const cierreError = ref('');
|
||||||
|
const cierreCompletado = ref(false);
|
||||||
|
const saldoEsperadoEfectivo = ref(0);
|
||||||
|
const diferenciaEfectivo = computed(() => saldoRealEfectivo.value === null ? 0 : saldoRealEfectivo.value - saldoEsperadoEfectivo.value);
|
||||||
|
|
||||||
function formatFecha(d: any) {
|
function formatFecha(d: any) {
|
||||||
try { return new Date(d).toLocaleString(); } catch { return d; }
|
try { return new Date(d).toLocaleString(); } catch { return d; }
|
||||||
@@ -54,8 +65,11 @@ async function cargar() {
|
|||||||
jornadaAbierta.value = await service.getJornadaAbierta();
|
jornadaAbierta.value = await service.getJornadaAbierta();
|
||||||
if (jornadaAbierta.value) {
|
if (jornadaAbierta.value) {
|
||||||
resumen.value = await service.getResumenJornada(jornadaAbierta.value.id);
|
resumen.value = await service.getResumenJornada(jornadaAbierta.value.id);
|
||||||
|
const cuadre = await cuadreService.calcularCuadreDelDia(jornadaAbierta.value.fecha, jornadaAbierta.value.baseInicial);
|
||||||
|
saldoEsperadoEfectivo.value = cuadre.saldoEsperadoEfectivo ?? 0;
|
||||||
} else {
|
} else {
|
||||||
resumen.value = null;
|
resumen.value = null;
|
||||||
|
saldoEsperadoEfectivo.value = 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -75,10 +89,14 @@ async function abrir() {
|
|||||||
|
|
||||||
async function cerrar() {
|
async function cerrar() {
|
||||||
if (!jornadaAbierta.value) return;
|
if (!jornadaAbierta.value) return;
|
||||||
|
cierreError.value = '';
|
||||||
|
cierreCompletado.value = false;
|
||||||
|
if (saldoRealEfectivo.value === null || saldoRealEfectivo.value === undefined || saldoRealEfectivo.value < 0) { cierreError.value = 'El efectivo contado es obligatorio.'; return; }
|
||||||
try {
|
try {
|
||||||
const cid = await service.cerrarJornada(jornadaAbierta.value.id);
|
const cid = await service.cerrarJornada(jornadaAbierta.value.id, Number(saldoRealEfectivo.value), totalQRBanco.value === null ? undefined : Number(totalQRBanco.value));
|
||||||
await cargar();
|
await cargar();
|
||||||
alert(`Jornada cerrada. Cuadre: ${cid}`);
|
cierreCompletado.value = true;
|
||||||
|
cierreError.value = `Cierre de caja y jornada completado: ${cid}`;
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
alert(e.message);
|
alert(e.message);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
<template>
|
<template>
|
||||||
<div>
|
<div>
|
||||||
<div class="d-flex flex-wrap align-center justify-space-between mb-4 ga-2"><div><h1 class="text-h4 mb-1">Punto de venta</h1><div class="text-body-2 text-medium-emphasis">Toca un producto para agregarlo al carrito</div></div><v-chip :color="jornadaAbierta ? 'success' : 'warning'" variant="tonal" :prepend-icon="jornadaAbierta ? 'mdi-check-circle' : 'mdi-alert'">{{ jornadaAbierta ? 'Jornada abierta' : 'Abre una jornada' }}</v-chip></div>
|
<div class="d-flex flex-wrap align-center justify-space-between mb-4 ga-2"><div><h1 class="text-h4 mb-1">Punto de venta</h1><div class="text-body-2 text-medium-emphasis">Busca un producto y agrégalo al carrito</div></div><div class="d-flex align-center ga-2"><v-btn v-if="isMobile" color="primary" prepend-icon="mdi-store-search" @click="catalogoDialog = true">Buscar productos</v-btn><v-chip :color="jornadaAbierta ? 'success' : 'warning'" variant="tonal" :prepend-icon="jornadaAbierta ? 'mdi-check-circle' : 'mdi-alert'">{{ jornadaAbierta ? 'Jornada abierta' : 'Abre una jornada' }}</v-chip></div></div>
|
||||||
<v-row>
|
<v-row>
|
||||||
<v-col cols="12" md="8">
|
<v-col v-if="!isMobile" 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>
|
||||||
@@ -47,22 +47,26 @@
|
|||||||
<v-divider class="my-3"></v-divider>
|
<v-divider class="my-3"></v-divider>
|
||||||
<div class="text-h5 font-weight-bold">Total: ${{ total.toLocaleString() }}</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-text-field v-if="formaPago === 'EFECTIVO'" v-model.number="efectivoRecibido" type="number" label="Efectivo recibido" prefix="$" prepend-inner-icon="mdi-cash" />
|
||||||
|
<v-alert v-if="formaPago === 'EFECTIVO' && efectivoRecibido > 0" :type="devuelta >= 0 ? 'success' : 'warning'" variant="tonal" class="mb-2">{{ devuelta >= 0 ? `Devuelta: $${devuelta.toLocaleString()}` : `Faltan $${Math.abs(devuelta).toLocaleString()}` }}</v-alert>
|
||||||
<v-alert v-if="error" type="error" variant="tonal" density="compact" class="mb-2">{{ error }}</v-alert>
|
<v-alert v-if="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-alert v-if="exito" type="success" variant="tonal" density="compact" class="mb-2">{{ exito }}</v-alert>
|
||||||
<v-btn @click="registrarVenta" color="success" block size="large" :disabled="lineas.length === 0 || !jornadaAbierta" class="mt-2" prepend-icon="mdi-cash-register">Cobrar ${{ total.toLocaleString() }}</v-btn>
|
<v-btn @click="registrarVenta" color="success" block size="large" :disabled="lineas.length === 0 || !jornadaAbierta || (formaPago === 'EFECTIVO' && devuelta < 0)" class="mt-2" prepend-icon="mdi-cash-register">Cobrar ${{ total.toLocaleString() }}</v-btn>
|
||||||
</v-card-text>
|
</v-card-text>
|
||||||
</v-card>
|
</v-card>
|
||||||
</v-col>
|
</v-col>
|
||||||
</v-row>
|
</v-row>
|
||||||
<v-card class="mt-4"><v-card-title>Ventas recientes</v-card-title><v-card-text><v-list v-if="ventas.length"><v-list-item v-for="venta in ventas" :key="venta.id" :title="`Venta $${venta.total.toLocaleString()}`" :subtitle="`${new Date(venta.fecha).toLocaleString()} · ${venta.lineas.length} productos`"><template #append><v-btn variant="tonal" size="small" prepend-icon="mdi-pencil" @click="editarVenta(venta)">Editar</v-btn></template></v-list-item></v-list><v-alert v-else type="info" variant="tonal">Las ventas registradas durante el día aparecerán aquí.</v-alert></v-card-text></v-card>
|
<v-card class="mt-4"><v-card-title>Ventas recientes</v-card-title><v-card-text><v-list v-if="ventas.length"><v-list-item v-for="venta in ventas" :key="venta.id" :title="`Venta $${venta.total.toLocaleString()}`" :subtitle="`${new Date(venta.fecha).toLocaleString()} · ${venta.lineas.length} productos`"><template #append><v-btn variant="tonal" size="small" prepend-icon="mdi-pencil" @click="editarVenta(venta)">Editar</v-btn></template></v-list-item></v-list><v-alert v-else type="info" variant="tonal">Las ventas registradas durante el día aparecerán aquí.</v-alert></v-card-text></v-card>
|
||||||
<v-dialog v-model="dialogoVenta" max-width="560"><v-card><v-card-title>Editar venta</v-card-title><v-card-text><v-list><v-list-item v-for="linea in ventaEditada.lineas" :key="linea.id" :title="getProdNombre(linea.productoId)"><template #append><v-text-field v-model.number="linea.cantidad" type="number" label="Cantidad" density="compact" hide-details style="max-width: 130px" /></template></v-list-item></v-list><v-divider class="my-3" /><v-text-field v-model="actorVenta" label="Quién realiza el cambio" /><v-textarea v-model="motivoVenta" label="Motivo del cambio" rows="2" /></v-card-text><v-card-actions><v-spacer /><v-btn variant="text" @click="dialogoVenta = false">Cancelar</v-btn><v-btn color="primary" @click="guardarVenta">Guardar cambio</v-btn></v-card-actions></v-card></v-dialog>
|
<v-dialog v-model="dialogoVenta" max-width="560"><v-card><v-card-title>Editar venta</v-card-title><v-card-text><v-list><v-list-item v-for="linea in ventaEditada.lineas" :key="linea.id" :title="getProdNombre(linea.productoId)"><template #append><v-text-field v-model.number="linea.cantidad" type="number" label="Cantidad" density="compact" hide-details style="max-width: 130px" /></template></v-list-item></v-list><v-divider class="my-3" /><v-text-field v-model="actorVenta" label="Quién realiza el cambio" /><v-textarea v-model="motivoVenta" label="Motivo del cambio" rows="2" /></v-card-text><v-card-actions><v-spacer /><v-btn variant="text" @click="dialogoVenta = false">Cancelar</v-btn><v-btn color="primary" @click="guardarVenta">Guardar cambio</v-btn></v-card-actions></v-card></v-dialog>
|
||||||
|
<v-dialog v-model="catalogoDialog" fullscreen><v-card><v-toolbar color="primary"><v-btn icon="mdi-close" aria-label="Cerrar catálogo" @click="catalogoDialog = false" /><v-toolbar-title>Seleccionar producto</v-toolbar-title></v-toolbar><v-card-text><v-text-field v-model="busqueda" label="Buscar producto o productor" prepend-inner-icon="mdi-magnify" clearable autofocus /><v-list><v-list-item v-for="prod in productosFiltrados" :key="prod.id" :title="prod.nombrePresentacion" :subtitle="`Disponible: ${prod.stock} · $${prod.precioUnitario.toLocaleString()}`" :disabled="prod.stock <= 0" @click="agregarDesdeDialogo(prod)"><template #append><v-icon icon="mdi-plus-circle" /></template></v-list-item></v-list></v-card-text></v-card></v-dialog>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, onMounted } from 'vue';
|
import { ref, computed, onMounted, onUnmounted } from 'vue';
|
||||||
import { db } from '@toldo/data';
|
import { db } from '@toldo/data';
|
||||||
import { VentasService, JornadaService, EdicionService } from '@toldo/services';
|
import { VentasService, JornadaService, EdicionService } from '@toldo/services';
|
||||||
|
import { calcularDevuelta, validarPagoEfectivo } from '@toldo/core';
|
||||||
|
|
||||||
const ventasService = new VentasService();
|
const ventasService = new VentasService();
|
||||||
const jornadaService = new JornadaService();
|
const jornadaService = new JornadaService();
|
||||||
@@ -71,6 +75,10 @@ const stockByProduct = ref<Record<string, number>>({});
|
|||||||
const lineas = ref<any[]>([]);
|
const lineas = ref<any[]>([]);
|
||||||
const formaPago = ref('EFECTIVO');
|
const formaPago = ref('EFECTIVO');
|
||||||
const jornadaAbierta = ref<any>(null);
|
const jornadaAbierta = ref<any>(null);
|
||||||
|
const isMobile = ref(false);
|
||||||
|
const catalogoDialog = ref(false);
|
||||||
|
const efectivoRecibido = ref(0);
|
||||||
|
const devuelta = computed(() => calcularDevuelta(total.value, efectivoRecibido.value));
|
||||||
const busqueda = ref('');
|
const busqueda = ref('');
|
||||||
const productosFiltrados = computed(() => {
|
const productosFiltrados = computed(() => {
|
||||||
const termino = busqueda.value.trim().toLocaleLowerCase();
|
const termino = busqueda.value.trim().toLocaleLowerCase();
|
||||||
@@ -102,6 +110,8 @@ function getProdNombre(id: string) {
|
|||||||
return productos.value.find(p => p.id === id)?.nombrePresentacion || id;
|
return productos.value.find(p => p.id === id)?.nombrePresentacion || id;
|
||||||
}
|
}
|
||||||
function getStock(id: string) { return stockByProduct.value[id] ?? 0; }
|
function getStock(id: string) { return stockByProduct.value[id] ?? 0; }
|
||||||
|
function updateViewport() { isMobile.value = window.innerWidth < 960; }
|
||||||
|
function agregarDesdeDialogo(prod: any) { agregarProducto(prod); if (!error.value) catalogoDialog.value = false; }
|
||||||
|
|
||||||
function agregarProducto(prod: any) {
|
function agregarProducto(prod: any) {
|
||||||
if (getStock(prod.id) <= 0) { error.value = 'Este producto no tiene inventario disponible. Registra primero el inventario inicial.'; return; }
|
if (getStock(prod.id) <= 0) { error.value = 'Este producto no tiene inventario disponible. Registra primero el inventario inicial.'; return; }
|
||||||
@@ -154,6 +164,7 @@ async function registrarVenta() {
|
|||||||
error.value = 'Debe abrir una jornada antes de registrar ventas.';
|
error.value = 'Debe abrir una jornada antes de registrar ventas.';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (formaPago.value === 'EFECTIVO') { const errorPago = validarPagoEfectivo(total.value, efectivoRecibido.value); if (errorPago) { error.value = errorPago; return; } }
|
||||||
const ventaId = crypto.randomUUID();
|
const ventaId = crypto.randomUUID();
|
||||||
const lineasVenta = lineas.value.map(l => ({ ...l, ventaId, formaPago: formaPago.value }));
|
const lineasVenta = lineas.value.map(l => ({ ...l, ventaId, formaPago: formaPago.value }));
|
||||||
const venta = {
|
const venta = {
|
||||||
@@ -167,11 +178,13 @@ 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 = [];
|
||||||
|
efectivoRecibido.value = 0;
|
||||||
exito.value = `Venta registrada por $${total.value.toLocaleString()} en ${formaPago.value}.`;
|
exito.value = `Venta registrada por $${total.value.toLocaleString()} en ${formaPago.value}.`;
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
error.value = e.message.includes('objectStore') ? 'No se pudo abrir el almacenamiento local. Cierra y vuelve a abrir la aplicación para actualizar la base de datos.' : e.message;
|
error.value = e.message.includes('objectStore') ? 'No se pudo abrir el almacenamiento local. Cierra y vuelve a abrir la aplicación para actualizar la base de datos.' : e.message;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(cargar);
|
onMounted(() => { updateViewport(); window.addEventListener('resize', updateViewport); cargar(); });
|
||||||
|
onUnmounted(() => window.removeEventListener('resize', updateViewport));
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
52
apps/toldo-app/tests/e2e/full-day.spec.ts
Normal file
52
apps/toldo-app/tests/e2e/full-day.spec.ts
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
import { expect, test } from '@playwright/test';
|
||||||
|
|
||||||
|
test.describe('jornada completa', () => {
|
||||||
|
test('abre jornada, crea catálogo, registra venta y gasto, y cierra caja', async ({ page }, testInfo) => {
|
||||||
|
test.skip(testInfo.project.name !== 'chromium', 'El flujo completo se ejecuta una vez en escritorio.');
|
||||||
|
await page.goto('/inventario');
|
||||||
|
await page.getByLabel('Nombre del productor').fill('Productor E2E');
|
||||||
|
await page.getByRole('button', { name: 'Agregar', exact: true }).click();
|
||||||
|
await expect(page.getByText('Productor E2E')).toBeVisible();
|
||||||
|
|
||||||
|
await page.getByRole('combobox', { name: 'Productor' }).click({ force: true });
|
||||||
|
await page.getByText('Productor E2E', { exact: true }).last().click();
|
||||||
|
await page.getByLabel(/Nombre\/Presentación/).fill('Producto E2E');
|
||||||
|
await page.getByLabel('Precio unitario').fill('12000');
|
||||||
|
await page.getByRole('button', { name: 'Agregar Producto' }).click();
|
||||||
|
await expect(page.getByText('Producto E2E')).toBeVisible();
|
||||||
|
|
||||||
|
await page.getByRole('combobox', { name: 'Producto', exact: true }).first().click({ force: true });
|
||||||
|
await page.locator('.v-overlay-container .v-list-item').filter({ hasText: 'Producto E2E' }).click();
|
||||||
|
await page.getByLabel('Cantidad').first().fill('3');
|
||||||
|
await page.getByRole('button', { name: 'Registrar Ingreso Inicial' }).click();
|
||||||
|
await expect(page.getByText('Ingreso inicial registrado.')).toBeVisible();
|
||||||
|
|
||||||
|
await page.goto('/jornada');
|
||||||
|
await page.getByLabel('Responsables (separados por coma)').fill('Ana E2E');
|
||||||
|
await page.getByLabel('Base inicial (efectivo)').fill('10000');
|
||||||
|
const openDialog = page.waitForEvent('dialog');
|
||||||
|
await page.getByRole('button', { name: 'Abrir Jornada' }).click();
|
||||||
|
await (await openDialog).accept();
|
||||||
|
await expect(page.getByText(/Jornada ABIERTA/)).toBeVisible();
|
||||||
|
|
||||||
|
await page.goto('/ventas');
|
||||||
|
await expect(page.getByText('Disponible: 3')).toBeVisible();
|
||||||
|
await page.getByText('Producto E2E', { exact: true }).first().click({ force: true });
|
||||||
|
await expect(page.getByRole('button', { name: /Cobrar/ })).toContainText('12,000');
|
||||||
|
await page.getByLabel('Efectivo recibido').fill('20000');
|
||||||
|
await expect(page.getByText('Devuelta: $8,000')).toBeVisible();
|
||||||
|
await page.getByRole('button', { name: /Cobrar/ }).click();
|
||||||
|
await expect(page.getByText(/Venta registrada/)).toBeVisible();
|
||||||
|
|
||||||
|
await page.goto('/gastos');
|
||||||
|
await page.getByLabel('Concepto').fill('Bolsas E2E');
|
||||||
|
await page.getByLabel('Monto').fill('2000');
|
||||||
|
await page.getByRole('button', { name: 'Registrar gasto' }).click();
|
||||||
|
await expect(page.getByText('Bolsas E2E')).toBeVisible();
|
||||||
|
|
||||||
|
await page.goto('/jornada');
|
||||||
|
await page.getByLabel('Efectivo contado (obligatorio)').fill('20000');
|
||||||
|
await page.getByRole('button', { name: 'Cerrar caja y jornada' }).click();
|
||||||
|
await expect(page.getByText(/Cierre de caja y jornada completado/)).toBeVisible();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -21,13 +21,26 @@ test('permite volver a inicio desde Jornada e Inventario', async ({ page }) => {
|
|||||||
if ((await page.viewportSize())!.width < 960) await page.getByTestId('navigation-toggle').click();
|
if ((await page.viewportSize())!.width < 960) await page.getByTestId('navigation-toggle').click();
|
||||||
await page.getByRole('link', { name: 'Jornada', exact: true }).click();
|
await page.getByRole('link', { name: 'Jornada', exact: true }).click();
|
||||||
await expect(page.getByText('Jornada del Toldo')).toBeVisible();
|
await expect(page.getByText('Jornada del Toldo')).toBeVisible();
|
||||||
await expect(page.getByLabel('Migas de pan')).toContainText('Inicio');
|
|
||||||
await page.getByRole('link', { name: 'Inicio' }).last().click();
|
await page.getByRole('link', { name: 'Inicio' }).last().click();
|
||||||
await expect(page.getByText('Panel de control')).toBeVisible();
|
await expect(page.getByText('Panel de control')).toBeVisible();
|
||||||
if ((await page.viewportSize())!.width < 960) await page.getByTestId('navigation-toggle').click();
|
if ((await page.viewportSize())!.width < 960) await page.getByTestId('navigation-toggle').click();
|
||||||
await page.getByRole('link', { name: 'Inventario', exact: true }).click();
|
await page.getByRole('link', { name: 'Inventario', exact: true }).click();
|
||||||
await expect(page.getByText('Inventario y Productos')).toBeVisible();
|
await expect(page.getByText('Inventario y Productos')).toBeVisible();
|
||||||
await expect(page.locator('.mdi-home-outline')).toBeVisible();
|
|
||||||
await page.getByTestId('back-button').click();
|
await page.getByTestId('back-button').click();
|
||||||
await expect(page.getByText('Panel de control')).toBeVisible();
|
await expect(page.getByText('Panel de control')).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('muestra los flujos compactos en móvil', async ({ page }) => {
|
||||||
|
test.skip(test.info().project.name !== 'mobile', 'Este escenario se ejecuta en el proyecto móvil.');
|
||||||
|
await page.setViewportSize({ width: 390, height: 844 });
|
||||||
|
await page.goto('/inventario');
|
||||||
|
await expect(page.getByRole('tab', { name: 'Catálogo' })).toBeVisible();
|
||||||
|
await expect(page.getByRole('tab', { name: 'Ingresos' })).toBeVisible();
|
||||||
|
await expect(page.getByRole('tab', { name: 'Ajustes' })).toBeVisible();
|
||||||
|
await expect(page.getByText('Migas de pan')).toHaveCount(0);
|
||||||
|
await page.goto('/ventas');
|
||||||
|
await expect(page.getByRole('button', { name: 'Buscar productos' })).toBeVisible();
|
||||||
|
await page.goto('/jornada');
|
||||||
|
await expect(page.getByLabel('Efectivo contado (obligatorio)')).toBeVisible();
|
||||||
|
await expect(page.getByLabel('Dinero en banco por QR (opcional)')).toBeVisible();
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,2 +1,2 @@
|
|||||||
versionName=0.0.3
|
versionName=0.0.6
|
||||||
versionCode=3
|
versionCode=6
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
schema: spec-driven
|
||||||
|
created: 2026-10-25
|
||||||
23
openspec/changes/harden-database-bootstrap-reset/design.md
Normal file
23
openspec/changes/harden-database-bootstrap-reset/design.md
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
# Design
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
Dexie abre la base al ejecutar la primera consulta. El esquema tiene migraciones 1, 2 y 3, pero `main.ts` monta Vue inmediatamente y no distingue una base en migración de un error de operación.
|
||||||
|
|
||||||
|
## Goals / Non-Goals
|
||||||
|
|
||||||
|
**Goals:** hacer explícita la apertura, evitar operaciones contra una base no lista, ofrecer recuperación manual y preservar datos durante actualizaciones compatibles.
|
||||||
|
|
||||||
|
**Non-Goals:** borrar o reconstruir automáticamente una base con error, sincronizar datos con servidor o reemplazar las migraciones Dexie.
|
||||||
|
|
||||||
|
## Decisions
|
||||||
|
|
||||||
|
- `db.open()` se ejecutará antes de montar la aplicación.
|
||||||
|
- Un fallo de apertura montará una vista mínima de recuperación con mensaje técnico resumido y botón de reset destructivo.
|
||||||
|
- El reset se expondrá también desde Exportar/Importar, exigiendo confirmación y recomendando exportar antes.
|
||||||
|
- Las migraciones se mantendrán como versiones Dexie; cada cambio futuro de esquema deberá agregar una versión y upgrade explícito.
|
||||||
|
|
||||||
|
## Risks / Trade-offs
|
||||||
|
|
||||||
|
- El usuario puede resetear por error -> confirmación explícita y texto de pérdida de datos.
|
||||||
|
- Una base realmente corrupta puede no permitir exportación -> la pantalla explicará que el reset es último recurso.
|
||||||
29
openspec/changes/harden-database-bootstrap-reset/proposal.md
Normal file
29
openspec/changes/harden-database-bootstrap-reset/proposal.md
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
# Proposal
|
||||||
|
|
||||||
|
## Why
|
||||||
|
|
||||||
|
La app abre Dexie de forma implícita y no informa cuando una migración o apertura de IndexedDB falla. Esto puede parecer una pérdida de datos y obliga a borrar manualmente el almacenamiento para continuar.
|
||||||
|
|
||||||
|
## What Changes
|
||||||
|
|
||||||
|
- Abrir y validar la base local antes de montar Vue.
|
||||||
|
- Mostrar una pantalla de recuperación si la apertura/migración falla.
|
||||||
|
- Añadir reset manual del entorno desde Exportar/Importar, con confirmación destructiva.
|
||||||
|
- Mantener las migraciones Dexie y documentar que nunca deben borrar datos automáticamente.
|
||||||
|
- Añadir pruebas de persistencia y reset.
|
||||||
|
|
||||||
|
## Capabilities
|
||||||
|
|
||||||
|
### New Capabilities
|
||||||
|
|
||||||
|
- `database-bootstrap-recovery`: apertura explícita, diagnóstico y recuperación controlada de IndexedDB.
|
||||||
|
|
||||||
|
### Modified Capabilities
|
||||||
|
|
||||||
|
- `toldo-poc`: persistencia local y operación offline.
|
||||||
|
|
||||||
|
## Impact
|
||||||
|
|
||||||
|
- `packages/data`: bootstrap/reset de base.
|
||||||
|
- `apps/toldo-app`: arranque, pantalla de error y mantenimiento.
|
||||||
|
- Tests Vitest, documentación de agentes y APK.
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
# Spec Delta
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
|
||||||
|
Protege los datos locales durante actualizaciones y ofrece una recuperación explícita cuando IndexedDB no puede abrirse o migrarse.
|
||||||
|
|
||||||
|
## ADDED Requirements
|
||||||
|
|
||||||
|
### Requirement: Apertura explícita de base
|
||||||
|
The system SHALL open and complete local database migrations before allowing operational views to run.
|
||||||
|
|
||||||
|
#### Scenario: Actualización compatible
|
||||||
|
- **GIVEN** data exists in an older supported schema
|
||||||
|
- **WHEN** the app starts after an update
|
||||||
|
- **THEN** migrations complete before the main UI mounts
|
||||||
|
- **AND** existing records remain available
|
||||||
|
|
||||||
|
### Requirement: Recuperación ante error
|
||||||
|
The system SHALL show a recovery screen when the local database cannot be opened.
|
||||||
|
|
||||||
|
#### Scenario: Migración fallida
|
||||||
|
- **WHEN** database opening fails
|
||||||
|
- **THEN** the app shows an actionable error
|
||||||
|
- **AND** no operational action is rendered as if data were empty
|
||||||
|
- **AND** user can explicitly reset local data
|
||||||
|
|
||||||
|
### Requirement: Reset manual
|
||||||
|
The system SHALL require explicit confirmation before deleting all local data.
|
||||||
|
|
||||||
|
#### Scenario: Reset confirmado
|
||||||
|
- **WHEN** user confirms reset from maintenance
|
||||||
|
- **THEN** local database is deleted and recreated on reload
|
||||||
|
- **AND** the app starts with an empty environment
|
||||||
12
openspec/changes/harden-database-bootstrap-reset/tasks.md
Normal file
12
openspec/changes/harden-database-bootstrap-reset/tasks.md
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
# Tasks
|
||||||
|
|
||||||
|
## 1. Bootstrap y recuperación
|
||||||
|
|
||||||
|
- [x] 1.1 Añadir apertura explícita Dexie antes de montar Vue y vista de error recuperable
|
||||||
|
- [x] 1.2 Añadir helper de reset local sin usarlo automáticamente
|
||||||
|
- [x] 1.3 Añadir reset manual en Exportar/Importar con confirmación
|
||||||
|
|
||||||
|
## 2. Verificación
|
||||||
|
|
||||||
|
- [x] 2.1 Cubrir apertura, persistencia y reset con tests
|
||||||
|
- [x] 2.2 Ejecutar tests, `vue-tsc`, Playwright y build/APK
|
||||||
@@ -24,5 +24,5 @@
|
|||||||
- [x] 4.5 Dashboard con jornada activa
|
- [x] 4.5 Dashboard con jornada activa
|
||||||
|
|
||||||
## 5. Tests Playwright
|
## 5. Tests Playwright
|
||||||
- [ ] 5.1 Setup Playwright
|
- [x] 5.1 Setup Playwright
|
||||||
- [ ] 5.2 Test caso uso completo: abrir jornada, agregar productos, venta múltiple, gastos, cierre
|
- [x] 5.2 Test caso uso completo: abrir jornada, agregar productos, venta múltiple, gastos, cierre
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
schema: spec-driven
|
||||||
|
created: 2026-10-25
|
||||||
25
openspec/changes/improve-mobile-pos-closing/design.md
Normal file
25
openspec/changes/improve-mobile-pos-closing/design.md
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
# Design
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
El Layout ya tiene menú y navegación suficiente; breadcrumbs duplican acciones. `JornadaService.cerrarJornada` crea un cuadre sin saldo real, mientras `Cuadre.vue` obliga a trabajar en otra ruta. POS muestra el catálogo completo antes del carrito.
|
||||||
|
|
||||||
|
## Goals / Non-Goals
|
||||||
|
|
||||||
|
**Goals:** priorizar carrito y cobro en móvil, mantener operaciones densas separadas por tabs, y hacer que el cierre sea una operación única con datos completos.
|
||||||
|
|
||||||
|
**Non-Goals:** eliminar la capacidad de consultar cuadres históricos ni cambiar el esquema de pagos.
|
||||||
|
|
||||||
|
## Decisions
|
||||||
|
|
||||||
|
- **POS:** mostrar búsqueda y botón de selección; el catálogo completo vive en un `v-dialog` con lista filtrable. El carrito permanece visible arriba en móvil y en columna fija en pantallas grandes.
|
||||||
|
- **Devuelta:** sólo se solicita para efectivo; se calcula como recibido menos total y no permite confirmar si es negativo.
|
||||||
|
- **Cierre:** `JornadaService.cerrarJornada` recibe efectivo contado obligatorio y banco QR opcional, calcula diferencias, guarda `CuadreCaja` y marca la jornada cerrada en una transacción.
|
||||||
|
- **Inventario:** tabs `Catálogo`, `Ingresos` y `Ajustes` en móvil; el menú deja de exponer Ajustes como módulo aislado.
|
||||||
|
- **Vuetify:** reemplazar inputs/select/button HTML de Gastos y Ajustes por `v-form`, `v-text-field`, `v-select`, `v-btn`, `v-list` y alertas.
|
||||||
|
|
||||||
|
## Risks / Trade-offs
|
||||||
|
|
||||||
|
- [Cierre incompleto] El usuario puede no conocer el banco -> QR es opcional y se muestra como pendiente, sin bloquear el cierre.
|
||||||
|
- [Cambio de API] Cerrar jornadas existentes sin saldo real requiere migración de comportamiento -> los registros históricos permanecen intactos.
|
||||||
|
- [Modal POS] Un modal añade un paso -> evita que el carrito desaparezca y permite buscar entre catálogos grandes.
|
||||||
31
openspec/changes/improve-mobile-pos-closing/proposal.md
Normal file
31
openspec/changes/improve-mobile-pos-closing/proposal.md
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
# Proposal
|
||||||
|
|
||||||
|
## Why
|
||||||
|
|
||||||
|
En celulares el POS ocupa toda la pantalla con el catálogo y el carrito queda fuera de vista. Además, breadcrumbs ya no son necesarios, Gastos y Ajustes conservan HTML nativo, y el cierre de caja está separado de la jornada aunque ambos deben ser una única operación de cierre.
|
||||||
|
|
||||||
|
## What Changes
|
||||||
|
|
||||||
|
- Retirar breadcrumbs y usar tabs responsive en Inventario y otras secciones densas.
|
||||||
|
- Convertir el selector de productos del POS en una búsqueda compacta con modal/lista de productos y carrito siempre accesible.
|
||||||
|
- Rediseñar Gastos y Ajustes con componentes Vuetify.
|
||||||
|
- Mover el ajuste de inventario al flujo de Inventario y retirar Ajustes como operación principal del menú.
|
||||||
|
- Fusionar cierre de caja y cierre de jornada con efectivo contado obligatorio y banco QR opcional.
|
||||||
|
- Calcular devuelta para pagos en efectivo antes de confirmar la venta.
|
||||||
|
|
||||||
|
## Capabilities
|
||||||
|
|
||||||
|
### New Capabilities
|
||||||
|
|
||||||
|
- `mobile-pos-flow`: selección de productos, carrito y devuelta optimizados para pantalla pequeña.
|
||||||
|
- `unified-day-closing`: cierre atómico de caja y jornada.
|
||||||
|
|
||||||
|
### Modified Capabilities
|
||||||
|
|
||||||
|
- `toldo-poc`: navegación, inventario, gastos, ajustes y reportes operativos.
|
||||||
|
|
||||||
|
## Impact
|
||||||
|
|
||||||
|
- Vistas Vue/Vuetify de POS, Inventario, Gastos, Ajustes y Jornada.
|
||||||
|
- `JornadaService` y modelos de cuadre.
|
||||||
|
- Tests de casos de uso, Playwright y APK.
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
# Spec Delta
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
|
||||||
|
Permite vender cómodamente desde celulares mostrando el carrito, la búsqueda de productos y la devuelta sin obligar a recorrer una lista larga.
|
||||||
|
|
||||||
|
## ADDED Requirements
|
||||||
|
|
||||||
|
### Requirement: Catálogo compacto en POS
|
||||||
|
The system SHALL keep the cart visible and allow selecting products through a searchable compact list or dialog.
|
||||||
|
|
||||||
|
#### Scenario: Selección en móvil
|
||||||
|
- **WHEN** user opens Ventas on a narrow viewport
|
||||||
|
- **THEN** the cart and total remain visible
|
||||||
|
- **AND** user can search and add a product from the catalog dialog
|
||||||
|
|
||||||
|
### Requirement: Calcular devuelta
|
||||||
|
The system SHALL calculate and display change for cash payments before saving a sale.
|
||||||
|
|
||||||
|
#### Scenario: Pago en efectivo
|
||||||
|
- **GIVEN** sale total is 12000
|
||||||
|
- **WHEN** user enters 20000 as received cash
|
||||||
|
- **THEN** the UI displays change of 8000
|
||||||
|
- **AND** the sale can be confirmed
|
||||||
|
|
||||||
|
#### Scenario: Efectivo insuficiente
|
||||||
|
- **WHEN** received cash is below the sale total
|
||||||
|
- **THEN** confirmation is disabled and an actionable message is shown
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
# Spec Delta
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
|
||||||
|
Unifica la verificación del efectivo y pagos QR con el cierre de la jornada, evitando que el usuario cierre una jornada sin cuadrar los valores disponibles.
|
||||||
|
|
||||||
|
## ADDED Requirements
|
||||||
|
|
||||||
|
### Requirement: Cierre unificado
|
||||||
|
The system SHALL require counted cash and optionally accept the QR bank total before closing a day.
|
||||||
|
|
||||||
|
#### Scenario: Cierre con efectivo
|
||||||
|
- **GIVEN** an open day with recorded sales and expenses
|
||||||
|
- **WHEN** user enters counted cash and confirms closing
|
||||||
|
- **THEN** cash difference is calculated and saved
|
||||||
|
- **AND** the cash reconciliation and day are closed together
|
||||||
|
|
||||||
|
#### Scenario: Cierre con banco QR pendiente
|
||||||
|
- **WHEN** user closes with counted cash and no bank total
|
||||||
|
- **THEN** the day closes
|
||||||
|
- **AND** QR bank comparison remains explicitly pending
|
||||||
|
|
||||||
|
### Requirement: Ajuste dentro de inventario
|
||||||
|
The system SHALL expose inventory adjustments from the inventory area with product quantity and reason.
|
||||||
|
|
||||||
|
#### Scenario: Ajuste desde inventario
|
||||||
|
- **WHEN** user selects a product, enters a new quantity and reason
|
||||||
|
- **THEN** the inventory balance changes and the reason is recorded
|
||||||
24
openspec/changes/improve-mobile-pos-closing/tasks.md
Normal file
24
openspec/changes/improve-mobile-pos-closing/tasks.md
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
# Tasks
|
||||||
|
|
||||||
|
## 1. POS móvil y cobro
|
||||||
|
|
||||||
|
- [x] 1.1 Rediseñar POS con carrito visible, selección en diálogo/lista filtrable y tabs responsive
|
||||||
|
- [x] 1.2 Añadir efectivo recibido, devuelta y validación de pago insuficiente
|
||||||
|
- [x] 1.3 Cubrir selección móvil y devuelta con tests unitarios/E2E
|
||||||
|
|
||||||
|
## 2. Cierre de jornada
|
||||||
|
|
||||||
|
- [x] 2.1 Cambiar servicio de cierre para recibir efectivo obligatorio y banco QR opcional en una transacción
|
||||||
|
- [x] 2.2 Rediseñar Jornada con formulario de cierre y diferencias visibles
|
||||||
|
- [x] 2.3 Retirar Cierre de caja del menú y cubrir cierre efectivo/QR/pending con tests
|
||||||
|
|
||||||
|
## 3. Inventario y formularios
|
||||||
|
|
||||||
|
- [x] 3.1 Mover Ajustes al área Inventario con tabs y quitar módulo independiente del menú
|
||||||
|
- [x] 3.2 Convertir Gastos y Ajustes a formularios Vuetify
|
||||||
|
- [x] 3.3 Añadir pruebas de regresión de inventario, gastos y ajustes
|
||||||
|
|
||||||
|
## 4. Verificación y entrega
|
||||||
|
|
||||||
|
- [x] 4.1 Ejecutar tests unitarios, `vue-tsc` y Playwright responsive
|
||||||
|
- [x] 4.2 Generar APK y verificar metadatos/flujo de cierre
|
||||||
@@ -23,8 +23,8 @@
|
|||||||
|
|
||||||
## 4. Testing
|
## 4. Testing
|
||||||
- [x] 4.1 Setup Playwright
|
- [x] 4.1 Setup Playwright
|
||||||
- [ ] 4.2 Add realistic service cases for QR, discrepancies, reports, and export
|
- [x] 4.2 Add realistic service cases for QR, discrepancies, reports, and export
|
||||||
- [ ] 4.3 Test full day browser flow
|
- [x] 4.3 Test full day browser flow
|
||||||
- [x] 4.4 Test responsive navigation and empty setup state
|
- [x] 4.4 Test responsive navigation and empty setup state
|
||||||
|
|
||||||
## 5. Build & Deploy
|
## 5. Build & Deploy
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ La aplicación usa versiones distintas o hardcodeadas entre npm, Android y las e
|
|||||||
|
|
||||||
- Definir `versionName` y `versionCode` en un archivo versionado compartido.
|
- Definir `versionName` y `versionCode` en un archivo versionado compartido.
|
||||||
- Usar esos valores en Gradle, npm y exportaciones JSON.
|
- Usar esos valores en Gradle, npm y exportaciones JSON.
|
||||||
- Publicar la siguiente versión como `0.0.3` con `versionCode 3`.
|
- Publicar la siguiente versión como `0.0.6` con `versionCode 6`.
|
||||||
- Validar el `applicationId` y la versión en el APK generado.
|
- Validar el `applicationId` y la versión en el APK generado.
|
||||||
|
|
||||||
## Capabilities
|
## Capabilities
|
||||||
|
|||||||
@@ -9,10 +9,10 @@ Asegura que cada actualización de ToldoConjunto pueda identificarse y actualiza
|
|||||||
### Requirement: Versionado consistente
|
### Requirement: Versionado consistente
|
||||||
The system SHALL expose the same semantic version in npm metadata, exported data, and Android versionName.
|
The system SHALL expose the same semantic version in npm metadata, exported data, and Android versionName.
|
||||||
|
|
||||||
#### Scenario: Version 0.0.3
|
#### Scenario: Version 0.0.6
|
||||||
- **WHEN** the app is built
|
- **WHEN** the app is built
|
||||||
- **THEN** npm and exported data report `0.0.3`
|
- **THEN** npm and exported data report `0.0.6`
|
||||||
- **AND** Android reports versionName `0.0.3` and versionCode `3`
|
- **AND** Android reports versionName `0.0.6` and versionCode `6`
|
||||||
|
|
||||||
### Requirement: Identidad Android estable
|
### Requirement: Identidad Android estable
|
||||||
The system SHALL preserve the application id `com.ilusion.toldoconjunto` across releases.
|
The system SHALL preserve the application id `com.ilusion.toldoconjunto` across releases.
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
## 1. Versionado
|
## 1. Versionado
|
||||||
|
|
||||||
- [x] 1.1 Crear la fuente compartida con `versionName=0.0.3` y `versionCode=3`, y actualizar npm/exportaciones
|
- [x] 1.1 Crear la fuente compartida con `versionName=0.0.6` y `versionCode=6`, y actualizar npm/exportaciones
|
||||||
- [x] 1.2 Configurar Gradle para leer la fuente compartida y conservar el applicationId
|
- [x] 1.2 Configurar Gradle para leer la fuente compartida y conservar el applicationId
|
||||||
- [x] 1.3 Añadir test que compruebe versionado y compatibilidad de exportación
|
- [x] 1.3 Añadir test que compruebe versionado y compatibilidad de exportación
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
export * from './entities';
|
export * from './entities';
|
||||||
export * from './rules/cuadreRules';
|
export * from './rules/cuadreRules';
|
||||||
export * from './rules/jornadaRules';
|
export * from './rules/jornadaRules';
|
||||||
|
export * from './rules/ventaRules';
|
||||||
export * from './entities/JornadaToldo';
|
export * from './entities/JornadaToldo';
|
||||||
|
|||||||
8
packages/core/src/rules/ventaRules.ts
Normal file
8
packages/core/src/rules/ventaRules.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
export function calcularDevuelta(total: number, recibido: number) {
|
||||||
|
return recibido - total;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validarPagoEfectivo(total: number, recibido: number) {
|
||||||
|
if (!Number.isFinite(recibido) || recibido < total) return 'El efectivo recibido no alcanza para cubrir la venta';
|
||||||
|
return null;
|
||||||
|
}
|
||||||
@@ -52,3 +52,13 @@ export class ToldoDB extends Dexie {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const db = new ToldoDB();
|
export const db = new ToldoDB();
|
||||||
|
|
||||||
|
export async function openDatabase() {
|
||||||
|
await db.open();
|
||||||
|
return db;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function resetDatabase() {
|
||||||
|
await db.delete();
|
||||||
|
await db.open();
|
||||||
|
}
|
||||||
|
|||||||
@@ -60,13 +60,12 @@ export class JornadaService {
|
|||||||
return gastoId;
|
return gastoId;
|
||||||
}
|
}
|
||||||
|
|
||||||
async cerrarJornada(jornadaId: string, cuadreId?: string): Promise<string> {
|
async cerrarJornada(jornadaId: string, saldoRealEfectivo: number, totalQRBanco?: number): Promise<string> {
|
||||||
|
if (!Number.isFinite(saldoRealEfectivo) || saldoRealEfectivo < 0) throw new Error('El efectivo contado es obligatorio y no puede ser negativo');
|
||||||
const jornada: any = await db.jornadas.get(jornadaId);
|
const jornada: any = await db.jornadas.get(jornadaId);
|
||||||
if (!jornada) throw new Error('Jornada no encontrada');
|
if (!jornada) throw new Error('Jornada no encontrada');
|
||||||
if (jornada.estado === 'CERRADA') throw new Error('Jornada ya cerrada');
|
if (jornada.estado === 'CERRADA') throw new Error('Jornada ya cerrada');
|
||||||
|
|
||||||
let cid = cuadreId;
|
|
||||||
if (!cid) {
|
|
||||||
const cuadreParcial = await this.cuadreService.calcularCuadreDelDia(jornada.fecha, jornada.baseInicial);
|
const cuadreParcial = await this.cuadreService.calcularCuadreDelDia(jornada.fecha, jornada.baseInicial);
|
||||||
const cuadre: CuadreCaja = {
|
const cuadre: CuadreCaja = {
|
||||||
id: crypto.randomUUID(),
|
id: crypto.randomUUID(),
|
||||||
@@ -78,17 +77,19 @@ export class JornadaService {
|
|||||||
totalGastosQR: cuadreParcial.totalGastosQR || 0,
|
totalGastosQR: cuadreParcial.totalGastosQR || 0,
|
||||||
saldoEsperadoEfectivo: cuadreParcial.saldoEsperadoEfectivo || 0,
|
saldoEsperadoEfectivo: cuadreParcial.saldoEsperadoEfectivo || 0,
|
||||||
totalVentas: cuadreParcial.totalVentas || 0,
|
totalVentas: cuadreParcial.totalVentas || 0,
|
||||||
|
saldoRealEfectivo,
|
||||||
|
totalQRBanco,
|
||||||
createdAt: new Date()
|
createdAt: new Date()
|
||||||
} as CuadreCaja;
|
} as CuadreCaja;
|
||||||
cid = await this.cuadreService.guardarCuadre(cuadre);
|
cuadre.descuadreEfectivo = saldoRealEfectivo - cuadre.saldoEsperadoEfectivo;
|
||||||
|
if (totalQRBanco !== undefined) {
|
||||||
|
cuadre.totalQRRegistrado = cuadre.totalVentasQR - cuadre.totalGastosQR;
|
||||||
|
cuadre.descuadreQR = totalQRBanco - cuadre.totalQRRegistrado;
|
||||||
}
|
}
|
||||||
|
const cid = cuadre.id;
|
||||||
await db.jornadas.put({
|
await db.transaction('rw', db.jornadas, db.cuadres, async () => {
|
||||||
...jornada,
|
await db.cuadres.add(cuadre);
|
||||||
estado: 'CERRADA',
|
await db.jornadas.put({ ...jornada, estado: 'CERRADA', fechaCierre: new Date(), cuadreId: cid, updatedAt: new Date() });
|
||||||
fechaCierre: new Date(),
|
|
||||||
cuadreId: cid,
|
|
||||||
updatedAt: new Date()
|
|
||||||
});
|
});
|
||||||
return cid;
|
return cid;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ describe('Caso de uso jornada', () => {
|
|||||||
expect(resumen?.totalVentas).toBe(30000);
|
expect(resumen?.totalVentas).toBe(30000);
|
||||||
expect(resumen?.totalGastos).toBe(5000);
|
expect(resumen?.totalGastos).toBe(5000);
|
||||||
|
|
||||||
const cuadreId = await service.cerrarJornada(jid);
|
const cuadreId = await service.cerrarJornada(jid, 125000);
|
||||||
expect(cuadreId).toBeTruthy();
|
expect(cuadreId).toBeTruthy();
|
||||||
|
|
||||||
const abierta = await service.getJornadaAbierta();
|
const abierta = await service.getJornadaAbierta();
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ export interface ExportData {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const CURRENT_SCHEMA_VERSION = 1;
|
export const CURRENT_SCHEMA_VERSION = 1;
|
||||||
export const APP_VERSION = '0.0.3';
|
export const APP_VERSION = '0.0.6';
|
||||||
|
|
||||||
export class ExportImportService {
|
export class ExportImportService {
|
||||||
exportAll(data: ExportData['data']): ExportData {
|
exportAll(data: ExportData['data']): ExportData {
|
||||||
|
|||||||
20
scripts/verify-apk.sh
Executable file
20
scripts/verify-apk.sh
Executable file
@@ -0,0 +1,20 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
apk="${1:?Uso: verify-apk.sh <apk> <versionName> <versionCode>}"
|
||||||
|
version_name="${2:?Falta versionName}"
|
||||||
|
version_code="${3:?Falta versionCode}"
|
||||||
|
aapt_bin="${AAPT:-aapt2}"
|
||||||
|
tmp_dir="$(mktemp -d)"
|
||||||
|
trap 'rm -rf "$tmp_dir"' EXIT
|
||||||
|
|
||||||
|
badging="$($aapt_bin dump badging "$apk")"
|
||||||
|
printf '%s\n' "$badging" | grep -F "package: name='com.ilusion.toldoconjunto' versionCode='$version_code' versionName='$version_name'" >/dev/null
|
||||||
|
|
||||||
|
unzip -q "$apk" -d "$tmp_dir"
|
||||||
|
find "$tmp_dir/assets/public/assets" -type f -name '*.js' -print0 | xargs -0 grep -F -l 'Buscar productos' >/dev/null
|
||||||
|
find "$tmp_dir/assets/public/assets" -type f -name '*.js' -print0 | xargs -0 grep -F -l 'Dinero en banco por QR' >/dev/null
|
||||||
|
find "$tmp_dir/assets/public/assets" -type f -name '*.js' -print0 | xargs -0 grep -F -l 'Cerrar caja y jornada' >/dev/null
|
||||||
|
find "$tmp_dir/assets/public/assets" -type f -name '*.woff2' | grep materialdesignicons >/dev/null
|
||||||
|
|
||||||
|
printf 'APK verificada: applicationId=com.ilusion.toldoconjunto versionName=%s versionCode=%s\n' "$version_name" "$version_code"
|
||||||
Reference in New Issue
Block a user