feat: importar csv y auditar ediciones
This commit is contained in:
14
apps/toldo-app/src/__tests__/csv-import.test.ts
Normal file
14
apps/toldo-app/src/__tests__/csv-import.test.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { normalizarNombre, validarEdicion } from '@toldo/services';
|
||||
|
||||
describe('importación de inventario CSV', () => {
|
||||
it('compara nombres sin tildes, mayúsculas ni espacios extra', () => {
|
||||
expect(normalizarNombre(' La Familia González ')).toBe('la familia gonzalez');
|
||||
expect(normalizarNombre('CAFÉ 250 GR')).toBe('cafe 250 gr');
|
||||
});
|
||||
|
||||
it('exige actor y motivo para editar', () => {
|
||||
expect(() => validarEdicion({ modificadoPor: '', motivo: 'Corrección' })).toThrow('quién');
|
||||
expect(() => validarEdicion({ modificadoPor: 'Ana', motivo: '' })).toThrow('motivo');
|
||||
});
|
||||
});
|
||||
@@ -15,7 +15,16 @@
|
||||
</v-navigation-drawer>
|
||||
|
||||
<v-main>
|
||||
<v-container fluid>
|
||||
<v-container fluid class="pa-4 pa-md-6">
|
||||
<div class="d-flex flex-wrap align-center justify-space-between mb-3 ga-2">
|
||||
<v-breadcrumbs :items="breadcrumbs" class="pa-0" aria-label="Migas de pan">
|
||||
<template #prepend><v-icon icon="mdi-home-outline" size="small" /></template>
|
||||
</v-breadcrumbs>
|
||||
<div class="d-flex ga-2">
|
||||
<v-btn variant="text" size="small" prepend-icon="mdi-arrow-left" data-testid="back-button" @click="goBack">Atrás</v-btn>
|
||||
<v-btn to="/" variant="text" size="small" prepend-icon="mdi-home">Inicio</v-btn>
|
||||
</div>
|
||||
</div>
|
||||
<router-view />
|
||||
</v-container>
|
||||
</v-main>
|
||||
@@ -23,13 +32,16 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, onUnmounted, ref } from 'vue';
|
||||
import { computed, onMounted, onUnmounted, ref } from 'vue';
|
||||
import { JornadaService } from '@toldo/services';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
|
||||
const drawer = ref(true);
|
||||
const isMobile = ref(false);
|
||||
const jornada = ref<any>(null);
|
||||
const jornadaService = new JornadaService();
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const navItems = [
|
||||
{ to: '/jornada', icon: 'mdi-calendar-check', title: 'Jornada' },
|
||||
{ to: '/inventario', icon: 'mdi-package-variant', title: 'Inventario' },
|
||||
@@ -40,6 +52,8 @@ const navItems = [
|
||||
{ to: '/reportes', icon: 'mdi-chart-bar', title: 'Reportes' },
|
||||
{ to: '/exportar', icon: 'mdi-export', title: 'Exportar / importar' },
|
||||
];
|
||||
const breadcrumbs = computed(() => [{ title: 'Inicio', disabled: false, to: '/' }, ...(route.path === '/' ? [] : [{ title: String(route.meta.title ?? 'Sección'), disabled: true }])]);
|
||||
function goBack() { if (window.history.state?.back) router.back(); else router.push('/'); }
|
||||
function updateViewport() { isMobile.value = window.innerWidth < 960; if (isMobile.value) drawer.value = false; }
|
||||
function closeOnMobile() { if (isMobile.value) drawer.value = false; }
|
||||
onMounted(async () => { updateViewport(); window.addEventListener('resize', updateViewport); jornada.value = await jornadaService.getJornadaAbierta(); });
|
||||
|
||||
@@ -17,15 +17,15 @@ const router = createRouter({
|
||||
path: '/',
|
||||
component: Layout,
|
||||
children: [
|
||||
{ path: '', component: Home },
|
||||
{ path: 'jornada', component: Jornada },
|
||||
{ path: 'inventario', component: Inventario },
|
||||
{ path: 'ventas', component: Ventas },
|
||||
{ path: 'gastos', component: Gastos },
|
||||
{ path: 'cuadre', component: Cuadre },
|
||||
{ path: 'reportes', component: Reportes },
|
||||
{ path: 'ajustes', component: Ajustes },
|
||||
{ path: 'exportar', component: Exportar },
|
||||
{ path: '', component: Home, meta: { title: 'Inicio' } },
|
||||
{ path: 'jornada', component: Jornada, meta: { title: 'Jornada' } },
|
||||
{ path: 'inventario', component: Inventario, meta: { title: 'Inventario' } },
|
||||
{ path: 'ventas', component: Ventas, meta: { title: 'Ventas POS' } },
|
||||
{ path: 'gastos', component: Gastos, meta: { title: 'Gastos' } },
|
||||
{ path: 'cuadre', component: Cuadre, meta: { title: 'Cierre de caja' } },
|
||||
{ path: 'reportes', component: Reportes, meta: { title: 'Reportes' } },
|
||||
{ path: 'ajustes', component: Ajustes, meta: { title: 'Ajustes' } },
|
||||
{ path: 'exportar', component: Exportar, meta: { title: 'Exportar / importar' } },
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
<input v-model="motivo" placeholder="Motivo del ajuste (OBLIGATORIO)" />
|
||||
<button @click="ajustar">Realizar Ajuste</button>
|
||||
</section>
|
||||
<v-card class="mt-4"><v-card-title>Historial de cambios</v-card-title><v-card-text><v-list v-if="auditorias.length"><v-list-item v-for="a in auditorias" :key="a.id" :title="`${a.entidad} · ${a.operacion}`" :subtitle="`${a.motivo} · ${a.modificadoPor} · ${new Date(a.fecha).toLocaleString()}`" /></v-list><div v-else class="text-body-2 text-medium-emphasis">Aún no hay cambios auditados.</div></v-card-text></v-card>
|
||||
<section>
|
||||
<h3>Ajustes del día</h3>
|
||||
<ul>
|
||||
@@ -24,7 +25,7 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue';
|
||||
import { db } from '@toldo/data';
|
||||
import { AjustesService, InventarioService } from '@toldo/services';
|
||||
import { AjustesService, InventarioService, AuditoriaService } from '@toldo/services';
|
||||
import { validateAjusteMotivo } from '@toldo/core';
|
||||
|
||||
const ajustesService = new AjustesService();
|
||||
@@ -34,12 +35,15 @@ const productoSel = ref('');
|
||||
const cantidadNueva = ref(0);
|
||||
const motivo = ref('');
|
||||
const ajustes = ref<any[]>([]);
|
||||
const auditorias = ref<any[]>([]);
|
||||
const auditoriaService = new AuditoriaService();
|
||||
const mensaje = ref('');
|
||||
const tipoMensaje = ref<'success' | 'error'>('success');
|
||||
|
||||
async function cargar() {
|
||||
productos.value = await db.productos.toArray();
|
||||
ajustes.value = await ajustesService.getAjustesDelDia(new Date());
|
||||
auditorias.value = await auditoriaService.listar();
|
||||
}
|
||||
|
||||
async function ajustar() {
|
||||
|
||||
@@ -37,6 +37,7 @@ async function exportar() {
|
||||
ajustes: await db.ajustes.toArray(),
|
||||
cuadres: await db.cuadres.toArray(),
|
||||
jornadas: await db.jornadas.toArray(),
|
||||
auditorias: await db.auditorias.toArray(),
|
||||
};
|
||||
const exp = exportService.exportAll(data);
|
||||
const blob = new Blob([JSON.stringify(exp, null, 2)], { type: 'application/json' });
|
||||
@@ -63,6 +64,7 @@ async function importar() {
|
||||
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.jornadas?.length) await db.jornadas.bulkPut(imp.data.jornadas);
|
||||
if (imp.data.auditorias?.length) await db.auditorias.bulkPut(imp.data.auditorias);
|
||||
tipoMensaje.value = 'success'; mensaje.value = 'Datos importados correctamente.';
|
||||
} catch (e: any) {
|
||||
tipoMensaje.value = 'error'; mensaje.value = e.message;
|
||||
|
||||
@@ -2,6 +2,10 @@
|
||||
<div>
|
||||
<h1 class="text-h4 mb-4">Inventario y Productos</h1>
|
||||
<v-alert v-if="mensaje" :type="tipoMensaje" variant="tonal" class="mb-4">{{ mensaje }}</v-alert>
|
||||
<v-card class="mb-4" variant="tonal" color="info">
|
||||
<v-card-title><v-icon icon="mdi-file-delimited" class="mr-2" />Importar inventario inicial</v-card-title>
|
||||
<v-card-text><div class="text-body-2 mb-3">CSV con: Nombre de producto, Nombre Productor, precio unitario y cantidad.</div><v-file-input v-model="csvFile" accept=".csv,text/csv" label="Seleccionar CSV" density="compact" prepend-icon="mdi-paperclip" @update:model-value="previsualizarCsv" /><v-alert v-if="previewRows.length" variant="outlined" density="compact" class="mb-3"><div class="text-caption font-weight-bold mb-1">Previsualización</div><div v-for="(row, index) in previewRows" :key="index" class="text-caption text-truncate">{{ row }}</div></v-alert><v-btn :disabled="!csvFile || !previewRows.length" color="info" @click="importarCsv" prepend-icon="mdi-upload">Confirmar importación</v-btn></v-card-text>
|
||||
</v-card>
|
||||
|
||||
<v-row>
|
||||
<v-col cols="12" md="6">
|
||||
@@ -17,7 +21,7 @@
|
||||
</v-col>
|
||||
</v-row>
|
||||
<v-list>
|
||||
<v-list-item v-for="p in productores" :key="p.id" :title="p.nombre"></v-list-item>
|
||||
<v-list-item v-for="p in productores" :key="p.id" :title="p.nombre"><template #append><v-btn icon="mdi-pencil" variant="text" size="small" :aria-label="`Editar ${p.nombre}`" @click="editarProductor(p)" /></template></v-list-item>
|
||||
</v-list>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
@@ -32,7 +36,7 @@
|
||||
<v-text-field v-model.number="precioProd" label="Precio unitario" type="number" density="compact" class="mb-2"></v-text-field>
|
||||
<v-btn @click="crearProducto" color="primary" block>Agregar Producto</v-btn>
|
||||
<v-list class="mt-3">
|
||||
<v-list-item v-for="prod in productos" :key="prod.id" :title="prod.nombrePresentacion" :subtitle="'$' + prod.precioUnitario"></v-list-item>
|
||||
<v-list-item v-for="prod in productos" :key="prod.id" :title="prod.nombrePresentacion" :subtitle="'$' + prod.precioUnitario"><template #append><v-btn icon="mdi-pencil" variant="text" size="small" :aria-label="`Editar ${prod.nombrePresentacion}`" @click="editarProducto(prod)" /></template></v-list-item>
|
||||
</v-list>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
@@ -63,13 +67,14 @@
|
||||
</v-card>
|
||||
</v-col>
|
||||
</v-row>
|
||||
<v-dialog v-model="dialogoEdicion" max-width="520"><v-card><v-card-title>Editar {{ entidadEdicion === 'PRODUCTOR' ? 'productor' : 'producto' }}</v-card-title><v-card-text><v-text-field v-if="entidadEdicion === 'PRODUCTOR'" v-model="edicionProductor.nombre" label="Nombre" /><template v-else><v-text-field v-model="edicionProducto.nombrePresentacion" label="Nombre / presentación" /><v-text-field v-model.number="edicionProducto.precioUnitario" type="number" label="Precio unitario" /></template><v-divider class="my-3" /><v-text-field v-model="actor" label="Quién realiza el cambio" /><v-textarea v-model="motivo" label="Motivo del cambio" rows="2" /></v-card-text><v-card-actions><v-spacer /><v-btn variant="text" @click="dialogoEdicion = false">Cancelar</v-btn><v-btn color="primary" @click="guardarEdicion">Guardar cambio</v-btn></v-card-actions></v-card></v-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue';
|
||||
import { db } from '@toldo/data';
|
||||
import { InventarioService } from '@toldo/services';
|
||||
import { InventarioService, ImportInventarioCsvService, EdicionService } from '@toldo/services';
|
||||
|
||||
const invService = new InventarioService();
|
||||
const productores = ref<any[]>([]);
|
||||
@@ -85,7 +90,37 @@ const cantInv2 = ref(0);
|
||||
const recibidoPor = ref('');
|
||||
const mensaje = ref('');
|
||||
const tipoMensaje = ref<'success' | 'error'>('success');
|
||||
const csvFile = ref<File | File[] | null>(null);
|
||||
const previewRows = ref<string[]>([]);
|
||||
const csvService = new ImportInventarioCsvService();
|
||||
const edicionService = new EdicionService();
|
||||
const dialogoEdicion = ref(false);
|
||||
const entidadEdicion = ref<'PRODUCTOR' | 'PRODUCTO'>('PRODUCTOR');
|
||||
const edicionProductor = ref<any>({});
|
||||
const edicionProducto = ref<any>({});
|
||||
const originalEdicion = ref<any>({});
|
||||
const actor = ref('');
|
||||
const motivo = ref('');
|
||||
function avisar(texto: string, tipo: 'success' | 'error' = 'error') { mensaje.value = texto; tipoMensaje.value = tipo; }
|
||||
function editarProductor(productor: any) { entidadEdicion.value = 'PRODUCTOR'; originalEdicion.value = { ...productor }; edicionProductor.value = { ...productor }; actor.value = ''; motivo.value = ''; dialogoEdicion.value = true; }
|
||||
function editarProducto(producto: any) { entidadEdicion.value = 'PRODUCTO'; originalEdicion.value = { ...producto }; edicionProducto.value = { ...producto }; actor.value = ''; motivo.value = ''; dialogoEdicion.value = true; }
|
||||
async function guardarEdicion() {
|
||||
try {
|
||||
if (entidadEdicion.value === 'PRODUCTOR') await edicionService.editarProductor(edicionProductor.value, originalEdicion.value, { modificadoPor: actor.value, motivo: motivo.value });
|
||||
else await edicionService.editarProducto(edicionProducto.value, originalEdicion.value, { modificadoPor: actor.value, motivo: motivo.value });
|
||||
dialogoEdicion.value = false; await cargar(); avisar('Cambio guardado y auditado.', 'success');
|
||||
} catch (error: any) { avisar(error.message); }
|
||||
}
|
||||
async function importarCsv() {
|
||||
const archivo = Array.isArray(csvFile.value) ? csvFile.value[0] : csvFile.value;
|
||||
if (!archivo) return;
|
||||
try { const resultado = await csvService.importar(await archivo.text()); csvFile.value = null; await cargar(); avisar(`Importación finalizada: ${resultado.creados} creados, ${resultado.movimientos} movimientos, ${resultado.errores.length} errores.`, resultado.errores.length ? 'error' : 'success'); }
|
||||
catch (error: any) { avisar(error.message); }
|
||||
}
|
||||
async function previsualizarCsv() {
|
||||
const archivo = Array.isArray(csvFile.value) ? csvFile.value[0] : csvFile.value;
|
||||
previewRows.value = archivo ? (await archivo.text()).split(/\r?\n/).filter((linea) => linea.trim()).slice(0, 6) : [];
|
||||
}
|
||||
|
||||
async function cargar() {
|
||||
productores.value = await db.productores.toArray();
|
||||
|
||||
@@ -52,13 +52,15 @@
|
||||
</v-card>
|
||||
</v-col>
|
||||
</v-row>
|
||||
<v-card class="mt-4"><v-card-title>Ventas recientes</v-card-title><v-card-text><v-list v-if="ventas.length"><v-list-item v-for="venta in ventas" :key="venta.id" :title="`Venta $${venta.total.toLocaleString()}`" :subtitle="`${new Date(venta.fecha).toLocaleString()} · ${venta.lineas.length} productos`"><template #append><v-btn variant="tonal" size="small" prepend-icon="mdi-pencil" @click="editarVenta(venta)">Editar</v-btn></template></v-list-item></v-list><v-alert v-else type="info" variant="tonal">Las ventas registradas durante el día aparecerán aquí.</v-alert></v-card-text></v-card>
|
||||
<v-dialog v-model="dialogoVenta" max-width="560"><v-card><v-card-title>Editar venta</v-card-title><v-card-text><v-list><v-list-item v-for="linea in ventaEditada.lineas" :key="linea.id" :title="getProdNombre(linea.productoId)"><template #append><v-text-field v-model.number="linea.cantidad" type="number" label="Cantidad" density="compact" hide-details style="max-width: 130px" /></template></v-list-item></v-list><v-divider class="my-3" /><v-text-field v-model="actorVenta" label="Quién realiza el cambio" /><v-textarea v-model="motivoVenta" label="Motivo del cambio" rows="2" /></v-card-text><v-card-actions><v-spacer /><v-btn variant="text" @click="dialogoVenta = false">Cancelar</v-btn><v-btn color="primary" @click="guardarVenta">Guardar cambio</v-btn></v-card-actions></v-card></v-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue';
|
||||
import { db } from '@toldo/data';
|
||||
import { VentasService, JornadaService } from '@toldo/services';
|
||||
import { VentasService, JornadaService, EdicionService } from '@toldo/services';
|
||||
|
||||
const ventasService = new VentasService();
|
||||
const jornadaService = new JornadaService();
|
||||
@@ -68,10 +70,20 @@ const formaPago = ref('EFECTIVO');
|
||||
const jornadaAbierta = ref<any>(null);
|
||||
const error = ref('');
|
||||
const exito = ref('');
|
||||
const ventas = ref<any[]>([]);
|
||||
const dialogoVenta = ref(false);
|
||||
const ventaEditada = ref<any>({ lineas: [] });
|
||||
const ventaOriginal = ref<any>(null);
|
||||
const lineasOriginales = ref<any[]>([]);
|
||||
const actorVenta = ref('');
|
||||
const motivoVenta = ref('');
|
||||
const edicionService = new EdicionService();
|
||||
|
||||
async function cargar() {
|
||||
productos.value = await db.productos.toArray();
|
||||
jornadaAbierta.value = await jornadaService.getJornadaAbierta();
|
||||
const ventasDelDia = await ventasService.getVentasDelDia(new Date());
|
||||
ventas.value = await Promise.all(ventasDelDia.map(async (venta) => ({ ...venta, lineas: await ventasService.getLineasPorVenta(venta.id) })));
|
||||
}
|
||||
|
||||
function getProdNombre(id: string) {
|
||||
@@ -105,6 +117,17 @@ function eliminarLinea(i: number) {
|
||||
lineas.value.splice(i, 1);
|
||||
}
|
||||
|
||||
function editarVenta(venta: any) { ventaOriginal.value = JSON.parse(JSON.stringify(venta)); lineasOriginales.value = JSON.parse(JSON.stringify(venta.lineas)); ventaEditada.value = JSON.parse(JSON.stringify(venta)); actorVenta.value = ''; motivoVenta.value = ''; dialogoVenta.value = true; }
|
||||
async function guardarVenta() {
|
||||
try {
|
||||
const lineas = ventaEditada.value.lineas.map((linea: any) => ({ ...linea, cantidad: Number(linea.cantidad), subtotal: Number(linea.cantidad) * linea.precioUnitario }));
|
||||
if (lineas.some((linea: any) => linea.cantidad <= 0)) throw new Error('Las cantidades deben ser mayores que cero.');
|
||||
const venta = { ...ventaEditada.value, lineas, subtotal: lineas.reduce((total: number, linea: any) => total + linea.subtotal, 0), total: lineas.reduce((total: number, linea: any) => total + linea.subtotal, 0) };
|
||||
await edicionService.editarVenta(venta, ventaOriginal.value, lineasOriginales.value, { modificadoPor: actorVenta.value, motivo: motivoVenta.value });
|
||||
dialogoVenta.value = false; await cargar(); exito.value = 'Venta modificada y auditada.';
|
||||
} catch (e: any) { error.value = e.message; }
|
||||
}
|
||||
|
||||
const total = computed(() => lineas.value.reduce((acc, l) => acc + l.subtotal, 0));
|
||||
|
||||
async function registrarVenta() {
|
||||
|
||||
@@ -14,3 +14,18 @@ test('guía la configuración cuando el catálogo está vacío', async ({ page }
|
||||
await page.getByRole('link', { name: 'Ir a inventario' }).click();
|
||||
await expect(page).toHaveURL(/inventario/);
|
||||
});
|
||||
|
||||
test('permite volver a inicio desde Jornada e Inventario', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
if ((await page.viewportSize())!.width < 960) await page.getByTestId('navigation-toggle').click();
|
||||
await page.getByRole('link', { name: 'Jornada', exact: true }).click();
|
||||
await expect(page.getByText('Jornada del Toldo')).toBeVisible();
|
||||
await expect(page.getByLabel('Migas de pan')).toContainText('Inicio');
|
||||
await page.getByRole('link', { name: 'Inicio' }).last().click();
|
||||
await expect(page.getByText('Panel de control')).toBeVisible();
|
||||
if ((await page.viewportSize())!.width < 960) await page.getByTestId('navigation-toggle').click();
|
||||
await page.getByRole('link', { name: 'Inventario', exact: true }).click();
|
||||
await expect(page.getByText('Inventario y Productos')).toBeVisible();
|
||||
await page.getByTestId('back-button').click();
|
||||
await expect(page.getByText('Panel de control')).toBeVisible();
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user