add: implementar servicio de reportes para productos más vendidos y sin movimiento
This commit is contained in:
parent
708cc31496
commit
0c98dc0bb1
@ -1,9 +1,233 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import PageHeader from '@Holos/PageHeader.vue';
|
import { ref, onMounted, computed } from 'vue';
|
||||||
|
import { useRouter } from 'vue-router';
|
||||||
|
import reportService from '@Services/reportService';
|
||||||
|
import useCashRegister from '@Stores/cashRegister';
|
||||||
|
import GoogleIcon from '@Shared/GoogleIcon.vue';
|
||||||
|
import { formatCurrency } from '@/utils/formatters';
|
||||||
|
import OpenModal from '@/pages/POS/CashRegister/OpenModal.vue';
|
||||||
|
|
||||||
|
// State
|
||||||
|
const router = useRouter();
|
||||||
|
const topProduct = ref(null);
|
||||||
|
const stagnantProducts = ref([]);
|
||||||
|
const loadingTopProduct = ref(true);
|
||||||
|
const loadingStagnantProducts = ref(true);
|
||||||
|
const daysThreshold = ref(30);
|
||||||
|
|
||||||
|
// Methods
|
||||||
|
const cashRegisterStore = useCashRegister();
|
||||||
|
const showOpenModal = ref(false);
|
||||||
|
|
||||||
|
// Computed
|
||||||
|
const isCashRegisterOpen = computed(() => cashRegisterStore.hasOpenRegister);
|
||||||
|
|
||||||
|
const fetchTopProduct = async () => {
|
||||||
|
loadingTopProduct.value = true;
|
||||||
|
try {
|
||||||
|
const data = await reportService.getTopSellingProduct();
|
||||||
|
topProduct.value = data.product;
|
||||||
|
} catch (error) {
|
||||||
|
window.Notify.error('Error al cargar el producto más vendido.');
|
||||||
|
} finally {
|
||||||
|
loadingTopProduct.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchStagnantProducts = async () => {
|
||||||
|
loadingStagnantProducts.value = true;
|
||||||
|
try {
|
||||||
|
const data = await reportService.getProductsWithoutMovement(daysThreshold.value);
|
||||||
|
stagnantProducts.value = data.products;
|
||||||
|
} catch (error) {
|
||||||
|
window.Notify.error('Error al cargar productos sin movimiento.');
|
||||||
|
} finally {
|
||||||
|
loadingStagnantProducts.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleGoToPOS = () => {
|
||||||
|
if (isCashRegisterOpen.value) {
|
||||||
|
router.push({ name: 'pos.point' });
|
||||||
|
} else {
|
||||||
|
showOpenModal.value = true;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleOpenCashRegister = async (initialCash) => {
|
||||||
|
const result = await cashRegisterStore.openRegister(initialCash);
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
Notify.success('Caja abierta exitosamente. Redirigiendo al punto de venta...');
|
||||||
|
showOpenModal.value = false;
|
||||||
|
router.push({ name: 'pos.point' });
|
||||||
|
} else {
|
||||||
|
Notify.error(result.error || 'Error al abrir la caja');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Lifecycle
|
||||||
|
onMounted(() => {
|
||||||
|
fetchTopProduct();
|
||||||
|
fetchStagnantProducts();
|
||||||
|
cashRegisterStore.loadCurrentRegister();
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<PageHeader title="Dashboard" />
|
<div class="p-6 bg-gray-50 dark:bg-gray-900 min-h-full">
|
||||||
<p><b>{{ $t('welcome') }}</b>, {{ $page.user.name }}.</p>
|
<!-- Header -->
|
||||||
|
<div class="mb-8">
|
||||||
|
<h1 class="text-3xl font-bold text-gray-900 dark:text-gray-100 flex items-center gap-3">
|
||||||
|
<GoogleIcon name="dashboard" class="text-4xl text-indigo-600" />
|
||||||
|
Dashboard
|
||||||
|
</h1>
|
||||||
|
<p class="text-sm text-gray-600 dark:text-gray-400 mt-1">
|
||||||
|
Resumen de ventas y rendimiento de productos.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 lg:grid-cols-3 gap-8">
|
||||||
|
<!-- Columna Principal (2/3) -->
|
||||||
|
<div class="lg:col-span-2 space-y-8">
|
||||||
|
<!-- Producto más vendido -->
|
||||||
|
<div class="bg-white dark:bg-gray-800 rounded-xl shadow-md border border-gray-200 dark:border-gray-700">
|
||||||
|
<div class="p-6 border-b border-gray-200 dark:border-gray-700">
|
||||||
|
<h2 class="text-xl font-bold text-gray-900 dark:text-gray-100 flex items-center gap-3">
|
||||||
|
<GoogleIcon name="star" class="text-2xl text-yellow-500" />
|
||||||
|
Producto Estrella
|
||||||
|
</h2>
|
||||||
|
<p class="text-sm text-gray-500 dark:text-gray-400">El producto más vendido de todo el histórico.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Loading State -->
|
||||||
|
<div v-if="loadingTopProduct" class="p-12 flex flex-col items-center justify-center text-gray-500">
|
||||||
|
<GoogleIcon name="sync" class="text-5xl animate-spin mb-4" />
|
||||||
|
<p>Cargando producto...</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Empty State -->
|
||||||
|
<div v-else-if="!topProduct" class="p-12 flex flex-col items-center justify-center text-center">
|
||||||
|
<GoogleIcon name="sentiment_dissatisfied" class="text-6xl text-gray-300 dark:text-gray-600 mb-4" />
|
||||||
|
<h3 class="font-semibold text-gray-700 dark:text-gray-300">No hay datos de ventas</h3>
|
||||||
|
<p class="text-sm text-gray-500 dark:text-gray-400 mt-1">Aún no se han registrado ventas para determinar un producto estrella.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Content -->
|
||||||
|
<div v-else class="p-6 grid grid-cols-1 md:grid-cols-2 gap-6 items-center">
|
||||||
|
<div>
|
||||||
|
<p class="text-sm text-gray-500 dark:text-gray-400">{{ topProduct.category_name }}</p>
|
||||||
|
<h3 class="text-2xl font-bold text-indigo-600 dark:text-indigo-400 mt-1">
|
||||||
|
{{ topProduct.name }}
|
||||||
|
</h3>
|
||||||
|
<p class="text-sm font-mono text-gray-500 dark:text-gray-400 mt-1">SKU: {{ topProduct.sku }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="grid grid-cols-2 gap-4 text-center">
|
||||||
|
<div>
|
||||||
|
<p class="text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wider">Unidades Vendidas</p>
|
||||||
|
<p class="text-3xl font-bold text-gray-900 dark:text-gray-100 mt-1">{{ topProduct.total_quantity_sold }}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wider">Ingresos Totales</p>
|
||||||
|
<p class="text-3xl font-bold text-gray-900 dark:text-gray-100 mt-1">{{ formatCurrency(topProduct.total_revenue) }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Productos sin movimiento -->
|
||||||
|
<div class="bg-white dark:bg-gray-800 rounded-xl shadow-md border border-gray-200 dark:border-gray-700">
|
||||||
|
<div class="p-6 border-b border-gray-200 dark:border-gray-700">
|
||||||
|
<h2 class="text-xl font-bold text-gray-900 dark:text-gray-100 flex items-center gap-3">
|
||||||
|
<GoogleIcon name="inventory_2" class="text-2xl text-orange-500" />
|
||||||
|
Inventario sin Rotación
|
||||||
|
</h2>
|
||||||
|
<p class="text-sm text-gray-500 dark:text-gray-400">Productos que no se han vendido en los últimos {{ daysThreshold }} días.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Loading State -->
|
||||||
|
<div v-if="loadingStagnantProducts" class="p-12 flex flex-col items-center justify-center text-gray-500">
|
||||||
|
<GoogleIcon name="sync" class="text-5xl animate-spin mb-4" />
|
||||||
|
<p>Cargando inventario...</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Empty State -->
|
||||||
|
<div v-else-if="stagnantProducts.length === 0" class="p-12 flex flex-col items-center justify-center text-center">
|
||||||
|
<GoogleIcon name="celebration" class="text-6xl text-green-400 mb-4" />
|
||||||
|
<h3 class="font-semibold text-gray-700 dark:text-gray-300">¡Excelente rotación!</h3>
|
||||||
|
<p class="text-sm text-gray-500 dark:text-gray-400 mt-1">Todos los productos se han vendido en el período reciente.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Table -->
|
||||||
|
<div v-else class="overflow-x-auto">
|
||||||
|
<table class="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
|
||||||
|
<thead class="bg-gray-50 dark:bg-gray-900/50">
|
||||||
|
<tr>
|
||||||
|
<th class="px-6 py-3 text-left text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase">Producto</th>
|
||||||
|
<th class="px-6 py-3 text-center text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase">Stock</th>
|
||||||
|
<th class="px-6 py-3 text-center text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase">Días sin Venta</th>
|
||||||
|
<th class="px-6 py-3 text-right text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase">Valor Inventario</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="bg-white dark:bg-gray-800 divide-y divide-gray-200 dark:divide-gray-700">
|
||||||
|
<tr v-for="product in stagnantProducts" :key="product.id">
|
||||||
|
<td class="px-6 py-4 whitespace-nowrap">
|
||||||
|
<p class="text-sm font-semibold text-gray-900 dark:text-gray-100">{{ product.name }}</p>
|
||||||
|
<p class="text-xs text-gray-500 dark:text-gray-400 font-mono">{{ product.sku }}</p>
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4 whitespace-nowrap text-center">
|
||||||
|
<span class="text-sm font-bold" :class="product.stock > 0 ? 'text-orange-600 dark:text-orange-400' : 'text-gray-500'">
|
||||||
|
{{ product.stock }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4 whitespace-nowrap text-center">
|
||||||
|
<span class="text-sm font-semibold text-red-600 dark:text-red-400">
|
||||||
|
{{ product.days_without_movement }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4 whitespace-nowrap text-right">
|
||||||
|
<span class="text-sm font-semibold text-gray-700 dark:text-gray-300">
|
||||||
|
{{ formatCurrency(product.inventory_value) }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Columna Lateral (1/3) -->
|
||||||
|
<div class="lg:col-span-1 space-y-8">
|
||||||
|
<!-- Quick Actions -->
|
||||||
|
<div class="bg-white dark:bg-gray-800 rounded-xl shadow-md border border-gray-200 dark:border-gray-700 p-6">
|
||||||
|
<h2 class="text-xl font-bold text-gray-900 dark:text-gray-100 flex items-center gap-3 mb-4">
|
||||||
|
<GoogleIcon name="bolt" class="text-2xl text-indigo-500" />
|
||||||
|
Acciones Rápidas
|
||||||
|
</h2>
|
||||||
|
<div class="space-y-3">
|
||||||
|
<button @click="handleGoToPOS" class="w-full flex items-center justify-center gap-3 px-4 py-3 bg-indigo-600 hover:bg-indigo-700 text-white font-semibold rounded-lg transition-colors shadow-lg shadow-indigo-500/30">
|
||||||
|
<GoogleIcon name="point_of_sale" class="text-xl" />
|
||||||
|
<span>Ir al Punto de Venta</span>
|
||||||
|
</button>
|
||||||
|
<router-link :to="{ name: 'pos.inventory.index' }" class="w-full flex items-center justify-center gap-3 px-4 py-3 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 text-gray-800 dark:text-gray-200 font-semibold rounded-lg hover:bg-gray-50 dark:hover:bg-gray-600 transition-colors">
|
||||||
|
<GoogleIcon name="inventory" class="text-xl" />
|
||||||
|
<span>Gestionar Inventario</span>
|
||||||
|
</router-link>
|
||||||
|
<router-link :to="{ name: 'pos.sales.index' }" class="w-full flex items-center justify-center gap-3 px-4 py-3 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 text-gray-800 dark:text-gray-200 font-semibold rounded-lg hover:bg-gray-50 dark:hover:bg-gray-600 transition-colors">
|
||||||
|
<GoogleIcon name="receipt_long" class="text-xl" />
|
||||||
|
<span>Ver Historial de Ventas</span>
|
||||||
|
</router-link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Modal para abrir caja -->
|
||||||
|
<OpenModal
|
||||||
|
:show="showOpenModal"
|
||||||
|
@close="showOpenModal = false"
|
||||||
|
@confirm="handleOpenCashRegister"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
57
src/services/reportService.js
Normal file
57
src/services/reportService.js
Normal file
@ -0,0 +1,57 @@
|
|||||||
|
import { api, apiURL } from '@Services/Api';
|
||||||
|
|
||||||
|
const reportService = {
|
||||||
|
/**
|
||||||
|
* Fetches the top selling product.
|
||||||
|
* @param {string|null} fromDate - Start date in YYYY-MM-DD format.
|
||||||
|
* @param {string|null} toDate - End date in YYYY-MM-DD format.
|
||||||
|
* @returns {Promise<Object>}
|
||||||
|
*/
|
||||||
|
async getTopSellingProduct(fromDate = null, toDate = null) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const params = {};
|
||||||
|
if (fromDate) params.from_date = fromDate;
|
||||||
|
if (toDate) params.to_date = toDate;
|
||||||
|
|
||||||
|
api.get(apiURL('reports/top-selling-product'), {
|
||||||
|
params,
|
||||||
|
onSuccess: (response) => {
|
||||||
|
resolve(response);
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
console.error('Error fetching top selling product:', error);
|
||||||
|
reject(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetches products without movement.
|
||||||
|
* @param {number} daysThreshold - The threshold in days.
|
||||||
|
* @param {boolean} includeStockValue - Flag to include the inventory value in the response.
|
||||||
|
* @returns {Promise<Object>}
|
||||||
|
*/
|
||||||
|
async getProductsWithoutMovement(daysThreshold = 30, includeStockValue = true) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const params = {
|
||||||
|
days_threshold: daysThreshold,
|
||||||
|
// Convert boolean to 1 or 0 for Laravel validation
|
||||||
|
include_stock_value: includeStockValue ? 1 : 0
|
||||||
|
};
|
||||||
|
|
||||||
|
api.get(apiURL('reports/products-without-movement'), {
|
||||||
|
params,
|
||||||
|
onSuccess: (response) => {
|
||||||
|
resolve(response);
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
console.error('Error fetching products without movement:', error);
|
||||||
|
reject(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export default reportService;
|
||||||
Loading…
x
Reference in New Issue
Block a user