- Implementa KardexModal para generar reportes de inventario. - Optimiza búsqueda en modales con debounce y visualización de SKU. - Agrega cálculo de costos totales y stock disponible por almacén.
393 lines
18 KiB
Vue
393 lines
18 KiB
Vue
<script setup>
|
|
import { onMounted, ref, computed } from 'vue';
|
|
import { useSearcher, apiURL } from '@Services/Api';
|
|
import { can } from './Module.js';
|
|
import { formatDate } from '@/utils/formatters';
|
|
|
|
import SearcherHead from '@Holos/Searcher.vue';
|
|
import Table from '@Holos/Table.vue';
|
|
import GoogleIcon from '@Shared/GoogleIcon.vue';
|
|
import EntryModal from './EntryModal.vue';
|
|
import ExitModal from './ExitModal.vue';
|
|
import TransferModal from './TransferModal.vue';
|
|
import DetailModal from './DetailModal.vue';
|
|
import KardexModal from './KardexModal.vue';
|
|
|
|
/** Estado */
|
|
const movements = ref({});
|
|
const selectedType = ref('');
|
|
const selectedWarehouse = ref('');
|
|
const fromDate = ref('');
|
|
const toDate = ref('');
|
|
const warehouses = ref([]);
|
|
|
|
const showEntryModal = ref(false);
|
|
const showExitModal = ref(false);
|
|
const showTransferModal = ref(false);
|
|
const showDetailModal = ref(false);
|
|
const selectedMovementId = ref(null);
|
|
const selectedMovement = ref(null);
|
|
|
|
/** Kardex */
|
|
const showKardexModal = ref(false);
|
|
|
|
/** Filtros computados */
|
|
const filters = computed(() => {
|
|
const f = {};
|
|
if (selectedType.value) f.movement_type = selectedType.value;
|
|
if (selectedWarehouse.value) f.warehouse_id = selectedWarehouse.value;
|
|
if (fromDate.value) f.from_date = fromDate.value;
|
|
if (toDate.value) f.to_date = toDate.value;
|
|
return f;
|
|
});
|
|
|
|
/** Agrupar movimientos por invoice_reference para entradas múltiples */
|
|
const groupedMovements = computed(() => {
|
|
const data = movements.value?.data || [];
|
|
const grouped = [];
|
|
const processedRefs = new Set();
|
|
|
|
data.forEach(movement => {
|
|
// Si es una entrada con invoice_reference y no ha sido procesada
|
|
if (movement.movement_type === 'entry' && movement.invoice_reference && !processedRefs.has(movement.invoice_reference)) {
|
|
// Buscar todos los movimientos con el mismo invoice_reference
|
|
const relatedMovements = data.filter(m =>
|
|
m.movement_type === 'entry' &&
|
|
m.invoice_reference === movement.invoice_reference
|
|
);
|
|
|
|
if (relatedMovements.length > 1) {
|
|
// Crear un movimiento agrupado
|
|
grouped.push({
|
|
...movement,
|
|
is_grouped: true,
|
|
grouped_count: relatedMovements.length,
|
|
products: relatedMovements.map(m => ({
|
|
inventory: m.inventory,
|
|
quantity: m.quantity,
|
|
unit_cost: m.unit_cost || 0,
|
|
movement_id: m.id
|
|
}))
|
|
});
|
|
processedRefs.add(movement.invoice_reference);
|
|
} else {
|
|
// Es una entrada individual
|
|
grouped.push(movement);
|
|
}
|
|
} else if (movement.movement_type !== 'entry' || !movement.invoice_reference) {
|
|
// Otros tipos de movimientos o entradas sin invoice_reference
|
|
if (!processedRefs.has(movement.invoice_reference)) {
|
|
grouped.push(movement);
|
|
}
|
|
}
|
|
});
|
|
|
|
return {
|
|
...movements.value,
|
|
data: grouped
|
|
};
|
|
});
|
|
|
|
/** Tipos de movimiento */
|
|
const movementTypes = [
|
|
{ value: '', label: 'Todos', icon: 'list', active: 'bg-gray-600 text-white shadow-sm', inactive: 'bg-gray-100 text-gray-700 dark:bg-gray-900/30 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-900/50' },
|
|
{ value: 'entry', label: 'Entradas', icon: 'add_circle', active: 'bg-green-600 text-white shadow-sm', inactive: 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-300 hover:bg-green-200 dark:hover:bg-green-900/50' },
|
|
{ value: 'exit', label: 'Salidas', icon: 'remove_circle', active: 'bg-red-600 text-white shadow-sm', inactive: 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-300 hover:bg-red-200 dark:hover:bg-red-900/50' },
|
|
{ value: 'transfer', label: 'Traspasos', icon: 'swap_horiz', active: 'bg-blue-600 text-white shadow-sm', inactive: 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-300 hover:bg-blue-200 dark:hover:bg-blue-900/50' },
|
|
/* { value: 'sale', label: 'Ventas', icon: 'point_of_sale', active: 'bg-purple-600 text-white shadow-sm', inactive: 'bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-300 hover:bg-purple-200 dark:hover:bg-purple-900/50' },
|
|
{ value: 'return', label: 'Devoluciones', icon: 'undo', active: 'bg-amber-600 text-white shadow-sm', inactive: 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-300 hover:bg-amber-200 dark:hover:bg-amber-900/50' },
|
|
*/];
|
|
|
|
const getTypeBadge = (type) => {
|
|
const badges = {
|
|
entry: { label: 'Entrada', class: 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300' },
|
|
exit: { label: 'Salida', class: 'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-300' },
|
|
transfer: { label: 'Traspaso', class: 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300' },
|
|
/* sale: { label: 'Venta', class: 'bg-purple-100 text-purple-800 dark:bg-purple-900/30 dark:text-purple-300' },
|
|
return: { label: 'Devolución', class: 'bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-300' }, */
|
|
};
|
|
return badges[type] || { label: type, class: 'bg-gray-100 text-gray-800' };
|
|
};
|
|
|
|
/** Searcher */
|
|
const searcher = useSearcher({
|
|
url: apiURL('movimientos'),
|
|
onSuccess: (r) => {
|
|
movements.value = r.movements || r;
|
|
},
|
|
onError: () => movements.value = {}
|
|
});
|
|
|
|
/** Métodos */
|
|
const loadWarehouses = async () => {
|
|
try {
|
|
const response = await fetch(apiURL('almacenes'), {
|
|
headers: {
|
|
'Authorization': `Bearer ${sessionStorage.token}`,
|
|
'Accept': 'application/json'
|
|
}
|
|
});
|
|
const result = await response.json();
|
|
if (result.data) {
|
|
warehouses.value = result.data.warehouses?.data || result.data.data || [];
|
|
}
|
|
} catch (error) {
|
|
console.error('Error loading warehouses:', error);
|
|
}
|
|
};
|
|
|
|
const applyFilters = () => {
|
|
searcher.search('', filters.value);
|
|
};
|
|
|
|
const selectType = (type) => {
|
|
selectedType.value = type;
|
|
applyFilters();
|
|
};
|
|
|
|
const openDetail = (movement) => {
|
|
selectedMovementId.value = movement.id;
|
|
selectedMovement.value = movement;
|
|
showDetailModal.value = true;
|
|
};
|
|
|
|
const closeDetailModal = () => {
|
|
showDetailModal.value = false;
|
|
selectedMovementId.value = null;
|
|
selectedMovement.value = null;
|
|
};
|
|
|
|
const onMovementCreated = () => {
|
|
applyFilters();
|
|
};
|
|
|
|
/** Ciclo de vida */
|
|
onMounted(() => {
|
|
searcher.search();
|
|
loadWarehouses();
|
|
});
|
|
</script>
|
|
|
|
<template>
|
|
<div>
|
|
<SearcherHead
|
|
:title="$t('movements.title')"
|
|
placeholder="Buscar movimientos..."
|
|
@search="(x) => searcher.search(x, filters)"
|
|
>
|
|
<div class="flex items-center gap-2">
|
|
<button
|
|
class="flex items-center gap-1.5 px-3 py-2 bg-emerald-600 hover:bg-emerald-700 text-white text-sm font-semibold rounded-lg transition-colors shadow-sm"
|
|
@click="showKardexModal = true"
|
|
>
|
|
<GoogleIcon name="download" class="text-lg" />
|
|
Kardex
|
|
</button>
|
|
<button
|
|
v-if="can('create')"
|
|
class="flex items-center gap-1.5 px-3 py-2 bg-green-600 hover:bg-green-700 text-white text-sm font-semibold rounded-lg transition-colors shadow-sm"
|
|
@click="showEntryModal = true"
|
|
>
|
|
<GoogleIcon name="add_circle" class="text-lg" />
|
|
Entrada
|
|
</button>
|
|
<button
|
|
v-if="can('create')"
|
|
class="flex items-center gap-1.5 px-3 py-2 bg-red-600 hover:bg-red-700 text-white text-sm font-semibold rounded-lg transition-colors shadow-sm"
|
|
@click="showExitModal = true"
|
|
>
|
|
<GoogleIcon name="remove_circle" class="text-lg" />
|
|
Salida
|
|
</button>
|
|
<button
|
|
v-if="can('create')"
|
|
class="flex items-center gap-1.5 px-3 py-2 bg-blue-600 hover:bg-blue-700 text-white text-sm font-semibold rounded-lg transition-colors shadow-sm"
|
|
@click="showTransferModal = true"
|
|
>
|
|
<GoogleIcon name="swap_horiz" class="text-lg" />
|
|
Traspaso
|
|
</button>
|
|
</div>
|
|
</SearcherHead>
|
|
|
|
<!-- Filtros -->
|
|
<div class="mb-4 space-y-3">
|
|
<!-- Chips de tipo -->
|
|
<div class="flex flex-wrap gap-2">
|
|
<button
|
|
v-for="type in movementTypes"
|
|
:key="type.value"
|
|
@click="selectType(type.value)"
|
|
:class="[
|
|
'inline-flex items-center gap-1.5 px-3 py-1.5 rounded-full text-xs font-semibold transition-all',
|
|
selectedType === type.value ? type.active : type.inactive
|
|
]"
|
|
>
|
|
<GoogleIcon :name="type.icon" class="text-sm" />
|
|
{{ type.label }}
|
|
</button>
|
|
</div>
|
|
|
|
<!-- Filtros adicionales -->
|
|
<div class="flex flex-wrap items-end gap-3">
|
|
<div>
|
|
<label class="block text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase mb-1">Almacén</label>
|
|
<select
|
|
v-model="selectedWarehouse"
|
|
@change="applyFilters"
|
|
class="px-3 py-1.5 text-sm border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-indigo-500"
|
|
>
|
|
<option value="">Todos</option>
|
|
<option v-for="wh in warehouses" :key="wh.id" :value="wh.id">
|
|
{{ wh.name }}
|
|
</option>
|
|
</select>
|
|
</div>
|
|
<div>
|
|
<label class="block text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase mb-1">Desde</label>
|
|
<input
|
|
v-model="fromDate"
|
|
@change="applyFilters"
|
|
type="date"
|
|
class="px-3 py-1.5 text-sm border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-indigo-500"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label class="block text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase mb-1">Hasta</label>
|
|
<input
|
|
v-model="toDate"
|
|
@change="applyFilters"
|
|
type="date"
|
|
class="px-3 py-1.5 text-sm border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-indigo-500"
|
|
/>
|
|
</div>
|
|
<button
|
|
v-if="selectedWarehouse || fromDate || toDate"
|
|
@click="selectedWarehouse = ''; fromDate = ''; toDate = ''; selectedType = ''; applyFilters();"
|
|
class="px-3 py-1.5 text-sm text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100 transition-colors"
|
|
>
|
|
Limpiar filtros
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Tabla -->
|
|
<div class="pt-2 w-full">
|
|
<Table
|
|
:items="groupedMovements"
|
|
@send-pagination="(page) => searcher.pagination(page, filters)"
|
|
>
|
|
<template #head>
|
|
<th class="px-6 py-3 text-left text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider">PRODUCTO</th>
|
|
<th class="px-6 py-3 text-center text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider">TIPO</th>
|
|
<th class="px-6 py-3 text-center text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider">CANTIDAD</th>
|
|
<th class="px-6 py-3 text-center text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider">ORIGEN</th>
|
|
<th class="px-6 py-3 text-center text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider">DESTINO</th>
|
|
<th class="px-6 py-3 text-center text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider">USUARIO</th>
|
|
<th class="px-6 py-3 text-center text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider">FECHA</th>
|
|
</template>
|
|
<template #body="{items}">
|
|
<tr
|
|
v-for="movement in items"
|
|
:key="movement.id"
|
|
class="hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors cursor-pointer"
|
|
@click="openDetail(movement)"
|
|
>
|
|
<td class="px-6 py-4 text-left">
|
|
<!-- Múltiples productos -->
|
|
<div v-if="movement.products && movement.products.length > 0">
|
|
<div class="flex items-center gap-2">
|
|
<GoogleIcon name="inventory" class="text-indigo-600 dark:text-indigo-400 text-lg" />
|
|
<p class="text-sm font-semibold text-indigo-900 dark:text-indigo-100">
|
|
Productos
|
|
</p>
|
|
</div>
|
|
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
|
{{ movement.products.length }} producto(s)
|
|
</p>
|
|
</div>
|
|
<!-- Producto individual -->
|
|
<div v-else>
|
|
<p class="text-sm font-semibold text-gray-900 dark:text-gray-100">{{ movement.inventory?.name || 'N/A' }}</p>
|
|
<p class="text-xs text-gray-500 dark:text-gray-400 font-mono">{{ movement.inventory?.sku || '' }}</p>
|
|
</div>
|
|
</td>
|
|
<td class="px-6 py-4 text-center">
|
|
<span :class="['inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium', getTypeBadge(movement.movement_type).class]">
|
|
{{ getTypeBadge(movement.movement_type).label }}
|
|
</span>
|
|
</td>
|
|
<td class="px-6 py-4 text-center">
|
|
<!-- Cantidad total para múltiples productos -->
|
|
<p v-if="movement.products && movement.products.length > 0" class="text-sm font-bold text-gray-900 dark:text-gray-100">
|
|
{{ movement.products.reduce((sum, p) => sum + Number(p.quantity), 0) }}
|
|
</p>
|
|
<!-- Cantidad individual -->
|
|
<p v-else class="text-sm font-bold text-gray-900 dark:text-gray-100">{{ movement.quantity }}</p>
|
|
</td>
|
|
<td class="px-6 py-4 text-center">
|
|
<p v-if="movement.warehouse_from" class="text-sm text-gray-700 dark:text-gray-300">{{ movement.warehouse_from.name }}</p>
|
|
<span v-else class="text-sm text-gray-400 dark:text-gray-500">—</span>
|
|
</td>
|
|
<td class="px-6 py-4 text-center">
|
|
<p v-if="movement.warehouse_to" class="text-sm text-gray-700 dark:text-gray-300">{{ movement.warehouse_to.name }}</p>
|
|
<span v-else class="text-sm text-gray-400 dark:text-gray-500">—</span>
|
|
</td>
|
|
<td class="px-6 py-4 text-center">
|
|
<p class="text-sm text-gray-700 dark:text-gray-300">{{ movement.user?.name || 'N/A' }}</p>
|
|
</td>
|
|
<td class="px-6 py-4 text-center">
|
|
<p class="text-sm text-gray-700 dark:text-gray-300">{{ formatDate(movement.created_at) }}</p>
|
|
</td>
|
|
</tr>
|
|
</template>
|
|
<template #empty>
|
|
<td colspan="7" class="table-cell text-center">
|
|
<div class="flex flex-col items-center justify-center py-8 text-gray-500">
|
|
<GoogleIcon
|
|
name="swap_horiz"
|
|
class="text-6xl mb-2 opacity-50"
|
|
/>
|
|
<p class="font-semibold">
|
|
{{ $t('registers.empty') }}
|
|
</p>
|
|
</div>
|
|
</td>
|
|
</template>
|
|
</Table>
|
|
</div>
|
|
|
|
<!-- Modales -->
|
|
<EntryModal
|
|
v-if="can('create')"
|
|
:show="showEntryModal"
|
|
@close="showEntryModal = false"
|
|
@created="onMovementCreated"
|
|
/>
|
|
<ExitModal
|
|
v-if="can('create')"
|
|
:show="showExitModal"
|
|
@close="showExitModal = false"
|
|
@created="onMovementCreated"
|
|
/>
|
|
<TransferModal
|
|
v-if="can('create')"
|
|
:show="showTransferModal"
|
|
@close="showTransferModal = false"
|
|
@created="onMovementCreated"
|
|
/>
|
|
<DetailModal
|
|
:show="showDetailModal"
|
|
:movement-id="selectedMovementId"
|
|
:movement-data="selectedMovement"
|
|
@close="closeDetailModal"
|
|
/>
|
|
|
|
<!-- Modal Kardex -->
|
|
<KardexModal
|
|
:show="showKardexModal"
|
|
@close="showKardexModal = false"
|
|
/>
|
|
</div>
|
|
</template>
|