feat: agregar modal de edición para movimientos y mejorar la gestión de permisos
This commit is contained in:
parent
4307d97639
commit
6c70d1ba4f
@ -2,6 +2,7 @@
|
|||||||
import { ref, watch, computed } from 'vue';
|
import { ref, watch, computed } from 'vue';
|
||||||
import { useApi, apiURL } from '@Services/Api';
|
import { useApi, apiURL } from '@Services/Api';
|
||||||
import { formatDate } from '@/utils/formatters';
|
import { formatDate } from '@/utils/formatters';
|
||||||
|
import { hasPermission } from '@Plugins/RolePermission';
|
||||||
|
|
||||||
import Modal from '@Holos/Modal.vue';
|
import Modal from '@Holos/Modal.vue';
|
||||||
import GoogleIcon from '@Shared/GoogleIcon.vue';
|
import GoogleIcon from '@Shared/GoogleIcon.vue';
|
||||||
@ -15,7 +16,7 @@ const props = defineProps({
|
|||||||
});
|
});
|
||||||
|
|
||||||
/** Eventos */
|
/** Eventos */
|
||||||
const emit = defineEmits(['close']);
|
const emit = defineEmits(['close', 'edit']);
|
||||||
|
|
||||||
/** Estado */
|
/** Estado */
|
||||||
const movement = ref(null);
|
const movement = ref(null);
|
||||||
@ -38,6 +39,14 @@ const totalCost = computed(() => {
|
|||||||
return movement.value.products.reduce((sum, p) => sum + (Number(p.quantity) * Number(p.unit_cost || 0)), 0);
|
return movement.value.products.reduce((sum, p) => sum + (Number(p.quantity) * Number(p.unit_cost || 0)), 0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const canEdit = computed(() => {
|
||||||
|
return hasPermission('movements.edit');
|
||||||
|
});
|
||||||
|
|
||||||
|
const canEditSingle = computed(() => {
|
||||||
|
return hasPermission('movements.edit') && !isMultiProduct.value;
|
||||||
|
});
|
||||||
|
|
||||||
/** Métodos */
|
/** Métodos */
|
||||||
const fetchDetail = () => {
|
const fetchDetail = () => {
|
||||||
if (!props.movementId) return;
|
if (!props.movementId) return;
|
||||||
@ -67,6 +76,29 @@ const handleClose = () => {
|
|||||||
emit('close');
|
emit('close');
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleEdit = () => {
|
||||||
|
emit('edit', movement.value);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEditProduct = (product) => {
|
||||||
|
// Crear un objeto de movimiento individual a partir del producto
|
||||||
|
const individualMovement = {
|
||||||
|
id: product.movement_id,
|
||||||
|
movement_type: movement.value.movement_type,
|
||||||
|
quantity: product.quantity,
|
||||||
|
unit_cost: product.unit_cost,
|
||||||
|
warehouse_id: movement.value.warehouse_to?.id,
|
||||||
|
invoice_reference: movement.value.invoice_reference,
|
||||||
|
notes: movement.value.notes,
|
||||||
|
inventory: product.inventory,
|
||||||
|
warehouse_to: movement.value.warehouse_to,
|
||||||
|
warehouse_from: movement.value.warehouse_from,
|
||||||
|
user: movement.value.user,
|
||||||
|
created_at: movement.value.created_at
|
||||||
|
};
|
||||||
|
emit('edit', individualMovement);
|
||||||
|
};
|
||||||
|
|
||||||
const getTypeBadge = (type) => {
|
const getTypeBadge = (type) => {
|
||||||
const badges = {
|
const badges = {
|
||||||
entry: { label: 'Entrada', class: 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300', icon: 'add_circle' },
|
entry: { label: 'Entrada', class: 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300', icon: 'add_circle' },
|
||||||
@ -136,7 +168,8 @@ watch(() => props.show, (isShown) => {
|
|||||||
:key="index"
|
:key="index"
|
||||||
class="p-3 bg-white dark:bg-gray-800/50 rounded-lg border border-gray-200 dark:border-gray-700"
|
class="p-3 bg-white dark:bg-gray-800/50 rounded-lg border border-gray-200 dark:border-gray-700"
|
||||||
>
|
>
|
||||||
<div class="grid grid-cols-12 gap-3 items-center text-sm">
|
<div class="flex items-center gap-3">
|
||||||
|
<div class="flex-1 grid grid-cols-12 gap-3 items-center text-sm">
|
||||||
<div class="col-span-12 sm:col-span-5">
|
<div class="col-span-12 sm:col-span-5">
|
||||||
<p class="font-semibold text-gray-900 dark:text-gray-100">
|
<p class="font-semibold text-gray-900 dark:text-gray-100">
|
||||||
{{ product.inventory?.name || 'N/A' }}
|
{{ product.inventory?.name || 'N/A' }}
|
||||||
@ -162,6 +195,17 @@ watch(() => props.show, (isShown) => {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<!-- Botón editar -->
|
||||||
|
<button
|
||||||
|
v-if="canEdit"
|
||||||
|
type="button"
|
||||||
|
@click="handleEditProduct(product)"
|
||||||
|
class="flex items-center justify-center w-8 h-8 text-indigo-600 dark:text-indigo-400 hover:bg-indigo-50 dark:hover:bg-indigo-900/30 rounded-lg transition-colors shrink-0"
|
||||||
|
title="Editar producto"
|
||||||
|
>
|
||||||
|
<GoogleIcon name="edit" class="text-lg" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -252,7 +296,7 @@ watch(() => props.show, (isShown) => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Footer -->
|
<!-- Footer -->
|
||||||
<div class="flex items-center justify-end mt-6 pt-4 border-t border-gray-200 dark:border-gray-700">
|
<div class="flex items-center justify-end gap-3 mt-6 pt-4 border-t border-gray-200 dark:border-gray-700">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@click="handleClose"
|
@click="handleClose"
|
||||||
@ -260,6 +304,15 @@ watch(() => props.show, (isShown) => {
|
|||||||
>
|
>
|
||||||
Cerrar
|
Cerrar
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
v-if="canEditSingle"
|
||||||
|
type="button"
|
||||||
|
@click="handleEdit"
|
||||||
|
class="flex items-center gap-2 px-5 py-2.5 text-sm font-semibold text-white bg-indigo-600 hover:bg-indigo-700 rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
<GoogleIcon name="edit" class="text-lg" />
|
||||||
|
Editar
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|||||||
377
src/pages/POS/Movements/Edit.vue
Normal file
377
src/pages/POS/Movements/Edit.vue
Normal file
@ -0,0 +1,377 @@
|
|||||||
|
<script setup>
|
||||||
|
import { ref, watch, computed } from 'vue';
|
||||||
|
import { formatCurrency } from '@/utils/formatters';
|
||||||
|
import { useForm, useApi, apiURL } from '@Services/Api';
|
||||||
|
|
||||||
|
import Modal from '@Holos/Modal.vue';
|
||||||
|
import FormInput from '@Holos/Form/Input.vue';
|
||||||
|
import FormError from '@Holos/Form/Elements/Error.vue';
|
||||||
|
import GoogleIcon from '@Shared/GoogleIcon.vue';
|
||||||
|
|
||||||
|
/** Eventos */
|
||||||
|
const emit = defineEmits(['close', 'updated']);
|
||||||
|
|
||||||
|
/** Propiedades */
|
||||||
|
const props = defineProps({
|
||||||
|
show: Boolean,
|
||||||
|
movement: Object
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Estado */
|
||||||
|
const warehouses = ref([]);
|
||||||
|
const loading = ref(false);
|
||||||
|
const api = useApi();
|
||||||
|
|
||||||
|
/** Formulario */
|
||||||
|
const form = useForm({
|
||||||
|
quantity: 0,
|
||||||
|
unit_cost: 0,
|
||||||
|
warehouse_id: '',
|
||||||
|
origin_warehouse_id: '',
|
||||||
|
destination_warehouse_id: '',
|
||||||
|
invoice_reference: '',
|
||||||
|
notes: ''
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Computed */
|
||||||
|
const movementTypeInfo = computed(() => {
|
||||||
|
const types = {
|
||||||
|
entry: {
|
||||||
|
label: 'Entrada',
|
||||||
|
icon: 'add_circle',
|
||||||
|
color: 'green',
|
||||||
|
bgClass: 'bg-green-100 dark:bg-green-900/30',
|
||||||
|
textClass: 'text-green-800 dark:text-green-300'
|
||||||
|
},
|
||||||
|
exit: {
|
||||||
|
label: 'Salida',
|
||||||
|
icon: 'remove_circle',
|
||||||
|
color: 'red',
|
||||||
|
bgClass: 'bg-red-100 dark:bg-red-900/30',
|
||||||
|
textClass: 'text-red-800 dark:text-red-300'
|
||||||
|
},
|
||||||
|
transfer: {
|
||||||
|
label: 'Traspaso',
|
||||||
|
icon: 'swap_horiz',
|
||||||
|
color: 'blue',
|
||||||
|
bgClass: 'bg-blue-100 dark:bg-blue-900/30',
|
||||||
|
textClass: 'text-blue-800 dark:text-blue-300'
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return types[props.movement?.movement_type] || types.entry;
|
||||||
|
});
|
||||||
|
|
||||||
|
const totalCost = computed(() => {
|
||||||
|
return form.quantity * form.unit_cost;
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Métodos */
|
||||||
|
const loadWarehouses = () => {
|
||||||
|
loading.value = true;
|
||||||
|
api.get(apiURL('almacenes'), {
|
||||||
|
onSuccess: (data) => {
|
||||||
|
warehouses.value = data.warehouses?.data || data.data || [];
|
||||||
|
},
|
||||||
|
onFinish: () => {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateMovement = () => {
|
||||||
|
// Preparar datos según el tipo de movimiento
|
||||||
|
const data = {
|
||||||
|
quantity: Number(form.quantity), // Común para todos los tipos
|
||||||
|
notes: form.notes || null
|
||||||
|
};
|
||||||
|
|
||||||
|
// Campos específicos por tipo
|
||||||
|
if (props.movement.movement_type === 'entry') {
|
||||||
|
data.unit_cost = Number(form.unit_cost);
|
||||||
|
data.warehouse_to_id = form.destination_warehouse_id;
|
||||||
|
data.invoice_reference = form.invoice_reference || null;
|
||||||
|
} else if (props.movement.movement_type === 'exit') {
|
||||||
|
data.warehouse_from_id = form.origin_warehouse_id;
|
||||||
|
} else if (props.movement.movement_type === 'transfer') {
|
||||||
|
data.warehouse_from_id = form.origin_warehouse_id;
|
||||||
|
data.warehouse_to_id = form.destination_warehouse_id;
|
||||||
|
}
|
||||||
|
|
||||||
|
api.put(apiURL(`movimientos/${props.movement.id}`), {
|
||||||
|
data,
|
||||||
|
onSuccess: () => {
|
||||||
|
window.Notify.success('Movimiento actualizado correctamente');
|
||||||
|
emit('updated');
|
||||||
|
closeModal();
|
||||||
|
},
|
||||||
|
onFail: (response) => {
|
||||||
|
window.Notify.error(response.message || 'Error al actualizar el movimiento');
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
window.Notify.error('Error al actualizar el movimiento');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeModal = () => {
|
||||||
|
form.reset();
|
||||||
|
emit('close');
|
||||||
|
};
|
||||||
|
|
||||||
|
const getWarehouseName = (warehouseId) => {
|
||||||
|
const warehouse = warehouses.value.find(w => w.id === warehouseId);
|
||||||
|
return warehouse ? `${warehouse.name} (${warehouse.code})` : 'N/A';
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Observadores */
|
||||||
|
watch(() => props.show, (isShown) => {
|
||||||
|
if (isShown) {
|
||||||
|
loadWarehouses();
|
||||||
|
|
||||||
|
if (props.movement) {
|
||||||
|
// Cargar datos del movimiento
|
||||||
|
form.quantity = props.movement.quantity || 0;
|
||||||
|
form.unit_cost = props.movement.unit_cost || 0;
|
||||||
|
form.invoice_reference = props.movement.invoice_reference || '';
|
||||||
|
form.notes = props.movement.notes || '';
|
||||||
|
|
||||||
|
// Almacenes según tipo
|
||||||
|
if (props.movement.movement_type === 'entry') {
|
||||||
|
form.destination_warehouse_id = props.movement.warehouse_id || '';
|
||||||
|
} else if (props.movement.movement_type === 'exit') {
|
||||||
|
form.origin_warehouse_id = props.movement.warehouse_id || '';
|
||||||
|
} else if (props.movement.movement_type === 'transfer') {
|
||||||
|
form.origin_warehouse_id = props.movement.origin_warehouse_id || '';
|
||||||
|
form.destination_warehouse_id = props.movement.destination_warehouse_id || '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, { immediate: true });
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Modal :show="show" max-width="lg" @close="closeModal">
|
||||||
|
<div class="p-6">
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="flex items-center justify-between mb-6">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<div
|
||||||
|
:class="[
|
||||||
|
'flex items-center justify-center w-10 h-10 rounded-full',
|
||||||
|
movementTypeInfo.bgClass
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
<GoogleIcon :name="movementTypeInfo.icon" :class="['text-xl', movementTypeInfo.textClass]" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 class="text-lg font-bold text-gray-900 dark:text-gray-100">
|
||||||
|
Editar {{ movementTypeInfo.label }}
|
||||||
|
</h3>
|
||||||
|
<p class="text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
Movimiento #{{ movement?.id }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
@click="closeModal"
|
||||||
|
class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
|
||||||
|
>
|
||||||
|
<GoogleIcon name="close" class="text-xl" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Información del producto -->
|
||||||
|
<div class="mb-4 p-3 bg-gray-50 dark:bg-gray-800/50 border border-gray-200 dark:border-gray-700 rounded-lg">
|
||||||
|
<div class="flex items-center gap-2 mb-1">
|
||||||
|
<GoogleIcon name="inventory_2" class="text-gray-500 dark:text-gray-400 text-sm" />
|
||||||
|
<p class="text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase">Producto</p>
|
||||||
|
</div>
|
||||||
|
<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">
|
||||||
|
SKU: {{ movement?.inventory?.sku || 'N/A' }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Formulario -->
|
||||||
|
<form @submit.prevent="updateMovement" class="space-y-4">
|
||||||
|
<div class="space-y-4">
|
||||||
|
<!-- Cantidad -->
|
||||||
|
<div>
|
||||||
|
<label class="block text-xs font-semibold text-gray-700 dark:text-gray-300 uppercase mb-1.5">
|
||||||
|
CANTIDAD
|
||||||
|
</label>
|
||||||
|
<FormInput
|
||||||
|
v-model="form.quantity"
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
step="1"
|
||||||
|
placeholder="0"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<FormError :message="form.errors?.quantity" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Costo unitario (solo para entradas) -->
|
||||||
|
<div v-if="movement?.movement_type === 'entry'">
|
||||||
|
<label class="block text-xs font-semibold text-gray-700 dark:text-gray-300 uppercase mb-1.5">
|
||||||
|
COSTO UNITARIO
|
||||||
|
</label>
|
||||||
|
<FormInput
|
||||||
|
v-model="form.unit_cost"
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
step="0.01"
|
||||||
|
placeholder="0.00"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<FormError :message="form.errors?.unit_cost" />
|
||||||
|
|
||||||
|
<!-- Mostrar costo total -->
|
||||||
|
<p v-if="form.quantity && form.unit_cost" class="mt-1.5 text-xs text-gray-600 dark:text-gray-400">
|
||||||
|
Costo total:
|
||||||
|
<span class="font-semibold text-gray-900 dark:text-gray-100">
|
||||||
|
{{ formatCurrency(totalCost) }}
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Almacén destino (para entradas) -->
|
||||||
|
<div v-if="movement?.movement_type === 'entry'">
|
||||||
|
<label class="block text-xs font-semibold text-gray-700 dark:text-gray-300 uppercase mb-1.5">
|
||||||
|
ALMACÉN DESTINO
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
v-model="form.destination_warehouse_id"
|
||||||
|
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 text-sm focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
required
|
||||||
|
>
|
||||||
|
<option value="">Seleccionar almacén...</option>
|
||||||
|
<option v-for="wh in warehouses" :key="wh.id" :value="wh.id">
|
||||||
|
{{ wh.name }} ({{ wh.code }})
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
<FormError :message="form.errors?.warehouse_id" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Almacén origen (para salidas) -->
|
||||||
|
<div v-if="movement?.movement_type === 'exit'">
|
||||||
|
<label class="block text-xs font-semibold text-gray-700 dark:text-gray-300 uppercase mb-1.5">
|
||||||
|
ALMACÉN ORIGEN
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
v-model="form.origin_warehouse_id"
|
||||||
|
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 text-sm focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
required
|
||||||
|
>
|
||||||
|
<option value="">Seleccionar almacén...</option>
|
||||||
|
<option v-for="wh in warehouses" :key="wh.id" :value="wh.id">
|
||||||
|
{{ wh.name }} ({{ wh.code }})
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
<FormError :message="form.errors?.warehouse_id" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Almacenes origen y destino (para traspasos) -->
|
||||||
|
<div v-if="movement?.movement_type === 'transfer'" class="grid grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<label class="block text-xs font-semibold text-gray-700 dark:text-gray-300 uppercase mb-1.5">
|
||||||
|
ALMACÉN ORIGEN
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
v-model="form.origin_warehouse_id"
|
||||||
|
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 text-sm focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
required
|
||||||
|
>
|
||||||
|
<option value="">Seleccionar...</option>
|
||||||
|
<option
|
||||||
|
v-for="wh in warehouses"
|
||||||
|
:key="wh.id"
|
||||||
|
:value="wh.id"
|
||||||
|
:disabled="wh.id === form.destination_warehouse_id"
|
||||||
|
>
|
||||||
|
{{ wh.name }} ({{ wh.code }})
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
<FormError :message="form.errors?.origin_warehouse_id" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-xs font-semibold text-gray-700 dark:text-gray-300 uppercase mb-1.5">
|
||||||
|
ALMACÉN DESTINO
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
v-model="form.destination_warehouse_id"
|
||||||
|
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 text-sm focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
required
|
||||||
|
>
|
||||||
|
<option value="">Seleccionar...</option>
|
||||||
|
<option
|
||||||
|
v-for="wh in warehouses"
|
||||||
|
:key="wh.id"
|
||||||
|
:value="wh.id"
|
||||||
|
:disabled="wh.id === form.origin_warehouse_id"
|
||||||
|
>
|
||||||
|
{{ wh.name }} ({{ wh.code }})
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
<FormError :message="form.errors?.destination_warehouse_id" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Referencia de factura (solo para entradas) -->
|
||||||
|
<div v-if="movement?.movement_type === 'entry'">
|
||||||
|
<label class="block text-xs font-semibold text-gray-700 dark:text-gray-300 uppercase mb-1.5">
|
||||||
|
REFERENCIA DE FACTURA
|
||||||
|
</label>
|
||||||
|
<FormInput
|
||||||
|
v-model="form.invoice_reference"
|
||||||
|
type="text"
|
||||||
|
placeholder="Ej: FAC-2026-001"
|
||||||
|
/>
|
||||||
|
<FormError :message="form.errors?.invoice_reference" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Notas -->
|
||||||
|
<div>
|
||||||
|
<label class="block text-xs font-semibold text-gray-700 dark:text-gray-300 uppercase mb-1.5">
|
||||||
|
NOTAS <span class="text-gray-400 text-xs normal-case">(opcional)</span>
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
v-model="form.notes"
|
||||||
|
rows="2"
|
||||||
|
placeholder="Notas adicionales..."
|
||||||
|
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 text-sm focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 resize-none disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
></textarea>
|
||||||
|
<FormError :message="form.errors?.notes" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Botones -->
|
||||||
|
<div class="flex items-center justify-end gap-3 mt-6 pt-4 border-t border-gray-200 dark:border-gray-700">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
@click="closeModal"
|
||||||
|
class="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 transition-colors"
|
||||||
|
>
|
||||||
|
Cancelar
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
:disabled="form.processing"
|
||||||
|
class="flex items-center gap-2 px-4 py-2 text-sm font-semibold text-white rounded-lg focus:outline-none focus:ring-2 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||||
|
:class="{
|
||||||
|
'bg-green-600 hover:bg-green-700 focus:ring-green-600': movement?.movement_type === 'entry',
|
||||||
|
'bg-red-600 hover:bg-red-700 focus:ring-red-600': movement?.movement_type === 'exit',
|
||||||
|
'bg-blue-600 hover:bg-blue-700 focus:ring-blue-600': movement?.movement_type === 'transfer'
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<GoogleIcon name="check_circle" class="text-lg" />
|
||||||
|
<span v-if="form.processing">Actualizando...</span>
|
||||||
|
<span v-else>Actualizar Movimiento</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
</template>
|
||||||
@ -407,21 +407,6 @@ watch(() => form.warehouse_id, (newWarehouseId, oldWarehouseId) => {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Costo unitario -->
|
|
||||||
<div class="col-span-5 sm:col-span-3">
|
|
||||||
<label class="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">
|
|
||||||
Costo unit.
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
v-model="item.unit_cost"
|
|
||||||
type="number"
|
|
||||||
min="0"
|
|
||||||
step="0.01"
|
|
||||||
placeholder="0.00"
|
|
||||||
class="w-full px-2 py-1.5 text-sm border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Botón eliminar -->
|
<!-- Botón eliminar -->
|
||||||
<div class="col-span-2 sm:col-span-1">
|
<div class="col-span-2 sm:col-span-1">
|
||||||
<label class="block text-xs font-medium text-transparent mb-1">.</label>
|
<label class="block text-xs font-medium text-transparent mb-1">.</label>
|
||||||
@ -436,50 +421,12 @@ watch(() => form.warehouse_id, (newWarehouseId, oldWarehouseId) => {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Subtotal del producto -->
|
|
||||||
<div v-if="item.quantity && item.unit_cost" class="mt-2 pt-2 border-t border-gray-200 dark:border-gray-700">
|
|
||||||
<p class="text-xs text-gray-600 dark:text-gray-400">
|
|
||||||
Subtotal:
|
|
||||||
<span class="font-semibold text-gray-900 dark:text-gray-100">
|
|
||||||
{{ formatCurrency(item.quantity * item.unit_cost) }}
|
|
||||||
</span>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Resumen total -->
|
|
||||||
<div v-if="selectedProducts.length > 0" class="mt-3 p-3 bg-red-50 dark:bg-red-900/20 rounded-lg border border-red-200 dark:border-red-800">
|
|
||||||
<div class="flex items-center justify-between text-sm">
|
|
||||||
<span class="font-medium text-gray-700 dark:text-gray-300">
|
|
||||||
Total de productos: {{ selectedProducts.length }}
|
|
||||||
</span>
|
|
||||||
<span class="font-medium text-gray-700 dark:text-gray-300">
|
|
||||||
Cantidad total: {{ totalQuantity }}
|
|
||||||
</span>
|
|
||||||
<span class="font-bold text-red-900 dark:text-red-100">
|
|
||||||
Costo total: {{ formatCurrency(totalCost) }}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<FormError :message="form.errors?.products" />
|
<FormError :message="form.errors?.products" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Referencia -->
|
|
||||||
<div>
|
|
||||||
<label class="block text-xs font-semibold text-gray-700 dark:text-gray-300 uppercase mb-1.5">
|
|
||||||
REFERENCIA
|
|
||||||
</label>
|
|
||||||
<FormInput
|
|
||||||
v-model="form.reference"
|
|
||||||
type="text"
|
|
||||||
placeholder="Ej: SAL-2026-001"
|
|
||||||
/>
|
|
||||||
<FormError :message="form.errors?.reference" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Notas -->
|
<!-- Notas -->
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-xs font-semibold text-gray-700 dark:text-gray-300 uppercase mb-1.5">
|
<label class="block text-xs font-semibold text-gray-700 dark:text-gray-300 uppercase mb-1.5">
|
||||||
|
|||||||
@ -12,6 +12,7 @@ import ExitModal from './ExitModal.vue';
|
|||||||
import TransferModal from './TransferModal.vue';
|
import TransferModal from './TransferModal.vue';
|
||||||
import DetailModal from './DetailModal.vue';
|
import DetailModal from './DetailModal.vue';
|
||||||
import KardexModal from './KardexModal.vue';
|
import KardexModal from './KardexModal.vue';
|
||||||
|
import EditModal from './Edit.vue';
|
||||||
|
|
||||||
/** Estado */
|
/** Estado */
|
||||||
const movements = ref({});
|
const movements = ref({});
|
||||||
@ -25,6 +26,7 @@ const showEntryModal = ref(false);
|
|||||||
const showExitModal = ref(false);
|
const showExitModal = ref(false);
|
||||||
const showTransferModal = ref(false);
|
const showTransferModal = ref(false);
|
||||||
const showDetailModal = ref(false);
|
const showDetailModal = ref(false);
|
||||||
|
const showEditModal = ref(false);
|
||||||
const selectedMovementId = ref(null);
|
const selectedMovementId = ref(null);
|
||||||
const selectedMovement = ref(null);
|
const selectedMovement = ref(null);
|
||||||
|
|
||||||
@ -94,9 +96,7 @@ const movementTypes = [
|
|||||||
{ 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: '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: '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: '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 getTypeBadge = (type) => {
|
||||||
const badges = {
|
const badges = {
|
||||||
@ -157,10 +157,25 @@ const closeDetailModal = () => {
|
|||||||
selectedMovement.value = null;
|
selectedMovement.value = null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const openEdit = (movement) => {
|
||||||
|
selectedMovement.value = movement;
|
||||||
|
showDetailModal.value = false;
|
||||||
|
showEditModal.value = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeEditModal = () => {
|
||||||
|
showEditModal.value = false;
|
||||||
|
selectedMovement.value = null;
|
||||||
|
};
|
||||||
|
|
||||||
const onMovementCreated = () => {
|
const onMovementCreated = () => {
|
||||||
applyFilters();
|
applyFilters();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const onMovementUpdated = () => {
|
||||||
|
applyFilters();
|
||||||
|
};
|
||||||
|
|
||||||
/** Ciclo de vida */
|
/** Ciclo de vida */
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
searcher.search();
|
searcher.search();
|
||||||
@ -381,6 +396,16 @@ onMounted(() => {
|
|||||||
:movement-id="selectedMovementId"
|
:movement-id="selectedMovementId"
|
||||||
:movement-data="selectedMovement"
|
:movement-data="selectedMovement"
|
||||||
@close="closeDetailModal"
|
@close="closeDetailModal"
|
||||||
|
@edit="openEdit"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Modal de Edición -->
|
||||||
|
<EditModal
|
||||||
|
v-if="can('edit')"
|
||||||
|
:show="showEditModal"
|
||||||
|
:movement="selectedMovement"
|
||||||
|
@close="closeEditModal"
|
||||||
|
@updated="onMovementUpdated"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<!-- Modal Kardex -->
|
<!-- Modal Kardex -->
|
||||||
|
|||||||
@ -90,11 +90,14 @@ const exportKardex = async () => {
|
|||||||
isExporting.value = true;
|
isExporting.value = true;
|
||||||
|
|
||||||
const filters = {
|
const filters = {
|
||||||
inventory_id: form.value.inventory_id,
|
|
||||||
fecha_inicio: form.value.fecha_inicio,
|
fecha_inicio: form.value.fecha_inicio,
|
||||||
fecha_fin: form.value.fecha_fin,
|
fecha_fin: form.value.fecha_fin,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (form.value.inventory_id) {
|
||||||
|
filters.inventory_id = form.value.inventory_id;
|
||||||
|
}
|
||||||
|
|
||||||
if (form.value.warehouse_id) {
|
if (form.value.warehouse_id) {
|
||||||
filters.warehouse_id = form.value.warehouse_id;
|
filters.warehouse_id = form.value.warehouse_id;
|
||||||
}
|
}
|
||||||
@ -149,9 +152,9 @@ watch(() => props.show, (val) => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
<!-- Producto (requerido) -->
|
<!-- Producto -->
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Producto *</label>
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Producto <span class="text-gray-400 text-xs normal-nums">(Opcional)</span></label>
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
<input
|
<input
|
||||||
v-model="productSearch"
|
v-model="productSearch"
|
||||||
@ -244,7 +247,7 @@ watch(() => props.show, (val) => {
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
@click="exportKardex"
|
@click="exportKardex"
|
||||||
:disabled="!form.inventory_id || !form.fecha_inicio || !form.fecha_fin || isExporting"
|
:disabled="!form.fecha_inicio || !form.fecha_fin || isExporting"
|
||||||
class="flex items-center gap-2 px-4 py-2 text-sm font-semibold text-white bg-emerald-600 hover:bg-emerald-700 rounded-lg transition-colors shadow-sm disabled:opacity-50 disabled:cursor-not-allowed"
|
class="flex items-center gap-2 px-4 py-2 text-sm font-semibold text-white bg-emerald-600 hover:bg-emerald-700 rounded-lg transition-colors shadow-sm disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
>
|
>
|
||||||
<GoogleIcon :name="isExporting ? 'hourglass_empty' : 'download'" class="text-lg" />
|
<GoogleIcon :name="isExporting ? 'hourglass_empty' : 'download'" class="text-lg" />
|
||||||
|
|||||||
@ -452,21 +452,6 @@ watch(() => form.warehouse_to_id, (newWarehouseId, oldWarehouseId) => {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Costo unitario -->
|
|
||||||
<div class="col-span-5 sm:col-span-3">
|
|
||||||
<label class="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">
|
|
||||||
Costo unit.
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
v-model="item.unit_cost"
|
|
||||||
type="number"
|
|
||||||
min="0"
|
|
||||||
step="0.01"
|
|
||||||
placeholder="0.00"
|
|
||||||
class="w-full px-2 py-1.5 text-sm border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Botón eliminar -->
|
<!-- Botón eliminar -->
|
||||||
<div class="col-span-2 sm:col-span-1">
|
<div class="col-span-2 sm:col-span-1">
|
||||||
<label class="block text-xs font-medium text-transparent mb-1">.</label>
|
<label class="block text-xs font-medium text-transparent mb-1">.</label>
|
||||||
@ -481,50 +466,12 @@ watch(() => form.warehouse_to_id, (newWarehouseId, oldWarehouseId) => {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Subtotal del producto -->
|
|
||||||
<div v-if="item.quantity && item.unit_cost" class="mt-2 pt-2 border-t border-gray-200 dark:border-gray-700">
|
|
||||||
<p class="text-xs text-gray-600 dark:text-gray-400">
|
|
||||||
Subtotal:
|
|
||||||
<span class="font-semibold text-gray-900 dark:text-gray-100">
|
|
||||||
{{ formatCurrency(item.quantity * item.unit_cost) }}
|
|
||||||
</span>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Resumen total -->
|
|
||||||
<div v-if="selectedProducts.length > 0" class="mt-3 p-3 bg-indigo-50 dark:bg-indigo-900/20 rounded-lg border border-indigo-200 dark:border-indigo-800">
|
|
||||||
<div class="flex items-center justify-between text-sm">
|
|
||||||
<span class="font-medium text-gray-700 dark:text-gray-300">
|
|
||||||
Total de productos: {{ selectedProducts.length }}
|
|
||||||
</span>
|
|
||||||
<span class="font-medium text-gray-700 dark:text-gray-300">
|
|
||||||
Cantidad total: {{ totalQuantity }}
|
|
||||||
</span>
|
|
||||||
<span class="font-bold text-indigo-900 dark:text-indigo-100">
|
|
||||||
Costo total: {{ formatCurrency(totalCost) }}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<FormError :message="form.errors?.products" />
|
<FormError :message="form.errors?.products" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Referencia -->
|
|
||||||
<div>
|
|
||||||
<label class="block text-xs font-semibold text-gray-700 dark:text-gray-300 uppercase mb-1.5">
|
|
||||||
REFERENCIA
|
|
||||||
</label>
|
|
||||||
<FormInput
|
|
||||||
v-model="form.reference"
|
|
||||||
type="text"
|
|
||||||
placeholder="Ej: TRA-2026-001"
|
|
||||||
/>
|
|
||||||
<FormError :message="form.errors?.reference" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Notas -->
|
<!-- Notas -->
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-xs font-semibold text-gray-700 dark:text-gray-300 uppercase mb-1.5">
|
<label class="block text-xs font-semibold text-gray-700 dark:text-gray-300 uppercase mb-1.5">
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user