feat: gestión de proveedores e integración en inventario

- Implementa CRUD completo, rutas y permisos para proveedores.
- Integra datos de proveedor en movimientos (creación, edición y detalles).
- Actualiza navegación principal y agrega traducciones.
This commit is contained in:
Juan Felipe Zapata Moreno 2026-02-10 16:40:30 -06:00
parent 99f190f61b
commit d521f0b2c2
13 changed files with 870 additions and 37 deletions

View File

@ -460,6 +460,7 @@ export default {
sales: 'Ventas',
returns: 'Devoluciones',
clients: 'Clientes',
suppliers: 'Proveedores',
clientTiers: 'Niveles de Clientes',
billingRequests: 'Solicitudes de Facturación',
warehouses: 'Almacenes',
@ -581,5 +582,9 @@ export default {
movements: {
title: 'Movimientos de Inventario',
description: 'Historial de entradas, salidas y traspasos de productos',
}
},
suppliers: {
title: 'Proveedores',
description: 'Gestión de proveedores',
},
}

View File

@ -58,6 +58,11 @@ onMounted(() => {
name="pos.clients"
to="pos.clients.index"
/>
<SubLink
icon="support_agent"
name="pos.suppliers"
to="pos.suppliers.index"
/>
</DropdownMenu>
<Link
v-if="hasPermission('warehouses.index')"

View File

@ -257,7 +257,7 @@ watch(() => form.track_serials, () => {
<!-- Precio de Venta -->
<div>
<label class="block text-xs font-semibold text-gray-700 dark:text-gray-300 uppercase mb-1.5">
PRECIO VENTA
PRECIO VENTA <span class="text-gray-500 dark:text-gray-400">(Subtotal)</span>
</label>
<FormInput
v-model.number="form.retail_price"

View File

@ -300,16 +300,54 @@ watch(() => props.show, (isShown) => {
<span class="text-gray-500 dark:text-gray-400">Fecha:</span>
<p class="font-semibold text-gray-900 dark:text-gray-100">{{ formatDate(movement.created_at) }}</p>
</div>
<div v-if="movement.invoice_reference" class="col-span-2">
<span class="text-gray-500 dark:text-gray-400">Referencia factura:</span>
<p class="font-mono font-semibold text-gray-900 dark:text-gray-100">{{ movement.invoice_reference }}</p>
</div>
<div v-if="movement.notes" class="col-span-2">
<span class="text-gray-500 dark:text-gray-400">Notas:</span>
<p class="text-gray-900 dark:text-gray-100 italic">{{ movement.notes }}</p>
</div>
</div>
</div>
<!-- Referencia de factura -->
<div v-if="movement.invoice_reference" class="bg-gradient-to-br from-gray-50 to-gray-100 dark:from-gray-800 dark:to-gray-900 rounded-xl p-5 border border-gray-200 dark:border-gray-700">
<div class="flex items-center gap-2 mb-2">
<GoogleIcon name="receipt" class="text-lg text-gray-600 dark:text-gray-400" />
<h4 class="text-xs font-bold text-gray-700 dark:text-gray-300 uppercase">Referencia de Factura</h4>
</div>
<p class="text-sm font-semibold text-gray-900 dark:text-gray-100">{{ movement.invoice_reference }}</p>
</div>
<!-- Proveedor -->
<div v-if="movement.supplier" class="bg-gradient-to-br from-indigo-50 to-indigo-100 dark:from-indigo-900/20 dark:to-indigo-800/20 rounded-xl p-5 border border-indigo-200 dark:border-indigo-700">
<div class="flex items-center gap-2 mb-2">
<GoogleIcon name="store" class="text-lg text-indigo-600 dark:text-indigo-400" />
<h4 class="text-xs font-bold text-indigo-700 dark:text-indigo-300 uppercase">Proveedor</h4>
</div>
<p class="text-sm font-semibold text-gray-900 dark:text-gray-100">{{ movement.supplier.business_name }}</p>
<p v-if="movement.supplier.rfc" class="text-xs text-gray-500 dark:text-gray-400 mt-1">RFC: {{ movement.supplier.rfc }}</p>
</div>
<!-- Almacenes -->
<div v-if="movement.movement_type === 'transfer' && (movement.warehouse_from || movement.warehouse_to)" class="grid grid-cols-2 gap-3">
<!-- Origen -->
<div v-if="movement.warehouse_from" class="bg-red-50 dark:bg-red-900/10 rounded-xl p-4 border border-red-200 dark:border-red-800">
<div class="flex items-center gap-2 mb-2">
<GoogleIcon name="logout" class="text-lg text-red-600 dark:text-red-400" />
<h4 class="text-xs font-bold text-red-700 dark:text-red-300 uppercase">Origen</h4>
</div>
<p class="text-sm font-semibold text-gray-900 dark:text-gray-100">{{ movement.warehouse_from.name }}</p>
<p class="text-xs font-mono text-gray-500 dark:text-gray-400">{{ movement.warehouse_from.code }}</p>
</div>
<!-- Destino -->
<div v-if="movement.warehouse_to" class="bg-green-50 dark:bg-green-900/10 rounded-xl p-4 border border-green-200 dark:border-green-800">
<div class="flex items-center gap-2 mb-2">
<GoogleIcon name="login" class="text-lg text-green-600 dark:text-green-400" />
<h4 class="text-xs font-bold text-green-700 dark:text-green-300 uppercase">Destino</h4>
</div>
<p class="text-sm font-semibold text-gray-900 dark:text-gray-100">{{ movement.warehouse_to.name }}</p>
<p class="text-xs font-mono text-gray-500 dark:text-gray-400">{{ movement.warehouse_to.code }}</p>
</div>
</div>
</div>
<!-- Footer -->

View File

@ -19,6 +19,7 @@ const props = defineProps({
/** Estado */
const warehouses = ref([]);
const suppliers = ref([]);
const loading = ref(false);
const api = useApi();
@ -27,6 +28,7 @@ const form = useForm({
quantity: 0,
unit_cost: 0,
warehouse_id: '',
supplier_id: null,
origin_warehouse_id: '',
destination_warehouse_id: '',
invoice_reference: '',
@ -72,13 +74,19 @@ const allowsDecimals = computed(() => {
/** Métodos */
const loadWarehouses = () => {
loading.value = true;
api.get(apiURL('almacenes'), {
onSuccess: (data) => {
warehouses.value = data.warehouses?.data || data.data || [];
},
onFinish: () => {
loading.value = false;
}
Promise.all([
api.get(apiURL('almacenes'), {
onSuccess: (data) => {
warehouses.value = data.warehouses?.data || data.data || [];
}
}),
api.get(apiURL('proveedores'), {
onSuccess: (data) => {
suppliers.value = data.suppliers?.data || data.suppliers || [];
}
})
]).finally(() => {
loading.value = false;
});
};
@ -93,6 +101,7 @@ const updateMovement = () => {
if (props.movement.movement_type === 'entry') {
data.unit_cost = Number(form.unit_cost);
data.warehouse_to_id = form.destination_warehouse_id;
data.supplier_id = form.supplier_id || null;
data.invoice_reference = form.invoice_reference || null;
} else if (props.movement.movement_type === 'exit') {
data.warehouse_from_id = form.origin_warehouse_id;
@ -122,11 +131,6 @@ const closeModal = () => {
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) {
@ -136,6 +140,7 @@ watch(() => props.show, (isShown) => {
// Cargar datos del movimiento
form.quantity = props.movement.quantity || 0;
form.unit_cost = props.movement.unit_cost || 0;
form.supplier_id = props.movement.supplier_id || null;
form.invoice_reference = props.movement.invoice_reference || '';
form.notes = props.movement.notes || '';
@ -323,17 +328,37 @@ watch(() => props.show, (isShown) => {
</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" />
<!-- Proveedor y Referencia (solo para entradas) -->
<div v-if="movement?.movement_type === 'entry'" class="space-y-4">
<!-- Proveedor -->
<div>
<label class="block text-xs font-semibold text-gray-700 dark:text-gray-300 uppercase mb-1.5">
PROVEEDOR <span class="text-gray-400 text-xs normal-case">(opcional)</span>
</label>
<select
v-model="form.supplier_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"
>
<option :value="null">Seleccionar proveedor...</option>
<option v-for="supplier in suppliers" :key="supplier.id" :value="supplier.id">
{{ supplier.business_name }}
</option>
</select>
<FormError :message="form.errors?.supplier_id" />
</div>
<!-- Referencia de factura -->
<div>
<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>
</div>
<!-- Notas -->

View File

@ -19,6 +19,7 @@ const props = defineProps({
/** Estado */
const products = ref([]);
const warehouses = ref([]);
const suppliers = ref([]);
const loading = ref(false);
const selectedProducts = ref([]);
@ -36,6 +37,7 @@ const api = useApi();
/** Formulario */
const form = useForm({
warehouse_id: '',
supplier_id: null,
invoice_reference: '',
notes: '',
products: []
@ -74,6 +76,11 @@ const loadData = () => {
}
}
}),
api.get(apiURL('proveedores'), {
onSuccess: (data) => {
suppliers.value = data.suppliers?.data || data.data || [];
}
}),
api.get(apiURL('inventario'), {
onSuccess: (data) => {
products.value = data.products?.data || data.data || [];
@ -331,6 +338,22 @@ watch(() => form.warehouse_id, (newWarehouseId, oldWarehouseId) => {
<FormError :message="form.errors?.warehouse_id" />
</div>
<div>
<label class="block text-xs font-semibold text-gray-700 dark:text-gray-300 uppercase mb-1.5">
PROVEEDOR <span class="text-gray-400 text-xs normal-case">(opcional)</span>
</label>
<select
v-model="form.supplier_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"
>
<option :value="null">Seleccionar proveedor...</option>
<option v-for="supplier in suppliers" :key="supplier.id" :value="supplier.id">
{{ supplier.business_name }}
</option>
</select>
<FormError :message="form.errors?.supplier_id" />
</div>
<!-- Lista de productos -->
<div>
<div class="flex items-center justify-between mb-2">

View File

@ -100,13 +100,11 @@ const movementTypes = [
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' }, */
entry: { label: 'Entrada', icon: 'add_circle', class: 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300' },
exit: { label: 'Salida', icon: 'remove_circle', class: 'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-300' },
transfer: { label: 'Traspaso', icon: 'swap_horiz', class: 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300' },
};
return badges[type] || { label: type, class: 'bg-gray-100 text-gray-800' };
return badges[type] || { label: type, icon: 'help_outline', class: 'bg-gray-100 text-gray-800' };
};
/** Searcher */
@ -295,6 +293,7 @@ onMounted(() => {
<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">PROVEEDOR</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>
@ -328,10 +327,22 @@ onMounted(() => {
</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]">
<span
:class="[
'inline-flex items-center gap-1.5 px-2 py-1 rounded-full text-xs font-semibold',
getTypeBadge(movement.movement_type).class
]"
>
<GoogleIcon :name="getTypeBadge(movement.movement_type).icon" class="text-sm" />
{{ getTypeBadge(movement.movement_type).label }}
</span>
</td>
<td class="px-6 py-4 text-center">
<p v-if="movement.supplier" class="text-sm text-gray-700 dark:text-gray-300">
{{ movement.supplier.business_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">
<!-- 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">
@ -357,7 +368,7 @@ onMounted(() => {
</tr>
</template>
<template #empty>
<td colspan="7" class="table-cell text-center">
<td colspan="8" class="table-cell text-center">
<div class="flex flex-col items-center justify-center py-8 text-gray-500">
<GoogleIcon
name="swap_horiz"

View File

@ -0,0 +1,191 @@
<script setup>
import { useForm, 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';
/** Eventos */
const emit = defineEmits(['close', 'created']);
/** Propiedades */
const props = defineProps({
show: Boolean
});
/** Formulario */
const form = useForm({
business_name: '',
rfc: '',
email: '',
phone: '',
address: '',
postal_code: '',
notes: ''
});
/** Métodos */
const createSupplier = () => {
form.post(apiURL('proveedores'), {
onSuccess: () => {
window.Notify.success('Proveedor creado exitosamente');
emit('created');
closeModal();
},
onError: () => {
window.Notify.error('Error al crear el proveedor');
}
});
};
const closeModal = () => {
form.reset();
emit('close');
};
</script>
<template>
<Modal :show="show" max-width="md" @close="closeModal">
<div class="p-6">
<!-- Header -->
<div class="flex items-center justify-between mb-6">
<h3 class="text-lg font-bold text-gray-900 dark:text-gray-100">
Crear Proveedor
</h3>
<button
@click="closeModal"
class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
</svg>
</button>
</div>
<!-- Formulario -->
<form @submit.prevent="createSupplier" class="space-y-4">
<div class="grid grid-cols-2 gap-4">
<!-- Nombre -->
<div class="col-span-2">
<label class="block text-xs font-semibold text-gray-700 dark:text-gray-300 uppercase mb-1.5">
NOMBRE (del negocio)
</label>
<FormInput
v-model="form.business_name"
type="text"
placeholder="Nombre del proveedor"
required
/>
<FormError :message="form.errors?.name" />
</div>
<!-- RFC -->
<div>
<label class="block text-xs font-semibold text-gray-700 dark:text-gray-300 uppercase mb-1.5">
RFC
</label>
<FormInput
v-model="form.rfc"
type="text"
minlength="12"
maxlength="13"
placeholder="RFC"
required
/>
<FormError :message="form.errors?.rfc" />
</div>
<!-- Email -->
<div>
<label class="block text-xs font-semibold text-gray-700 dark:text-gray-300 uppercase mb-1.5">
EMAIL
</label>
<FormInput
v-model="form.email"
type="text"
placeholder="Email"
required
/>
<FormError :message="form.errors?.email" />
</div>
<!-- Teléfono -->
<div>
<label class="block text-xs font-semibold text-gray-700 dark:text-gray-300 uppercase mb-1.5">
TELÉFONO
</label>
<FormInput
v-model="form.phone"
type="text"
placeholder="9933428818"
maxlength="10"
/>
<FormError :message="form.errors?.phone" />
</div>
<!-- Dirección -->
<div>
<label class="block text-xs font-semibold text-gray-700 dark:text-gray-300 uppercase mb-1.5">
DIRECCIÓN
</label>
<FormInput
v-model="form.address"
type="text"
placeholder="Dirección"
required
/>
<FormError :message="form.errors?.address" />
</div>
<!-- Código Postal -->
<div>
<label class="block text-xs font-semibold text-gray-700 dark:text-gray-300 uppercase mb-1.5">
Código Postal
</label>
<FormInput
v-model="form.postal_code"
type="text"
maxlength="5"
placeholder="Código Postal"
required
/>
<FormError :message="form.errors?.postal_code" />
</div>
<!-- Notas -->
<div>
<label class="block text-xs font-semibold text-gray-700 dark:text-gray-300 uppercase mb-1.5">
Notas
</label>
<FormInput
v-model="form.notes"
type="text"
placeholder="Notas"
required
/>
<FormError :message="form.errors?.notes" />
</div>
</div>
<!-- Botones -->
<div class="flex items-center justify-end gap-3 mt-6">
<button
type="button"
@click="closeModal"
class="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-lg hover:bg-gray-50 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="px-4 py-2 text-sm font-semibold text-white bg-gray-900 rounded-lg hover:bg-gray-800 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-gray-900 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
<span v-if="form.processing">Guardando...</span>
<span v-else>Guardar</span>
</button>
</div>
</form>
</div>
</Modal>
</template>

View File

@ -0,0 +1,97 @@
<script setup>
import Modal from '@Holos/Modal.vue';
import GoogleIcon from '@Shared/GoogleIcon.vue';
/** Props */
const props = defineProps({
show: {
type: Boolean,
default: false
},
client: {
type: Object,
default: null
}
});
/** Emits */
const emit = defineEmits(['close', 'confirm']);
/** Métodos */
const handleConfirm = () => {
emit('confirm', props.client.id);
};
const handleClose = () => {
emit('close');
};
</script>
<template>
<Modal :show="show" max-width="md" @close="handleClose">
<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-12 h-12 rounded-full bg-red-100 dark:bg-red-900/30">
<GoogleIcon name="delete_forever" class="text-2xl text-red-600 dark:text-red-400" />
</div>
<h3 class="text-lg font-bold text-gray-900 dark:text-gray-100">
Eliminar Cliente
</h3>
</div>
<button
@click="handleClose"
class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
</svg>
</button>
</div>
<!-- Content -->
<div class="space-y-5">
<p class="text-gray-700 dark:text-gray-300 text-base">
¿Estás seguro de que deseas eliminar este cliente?
</p>
<div v-if="client" class="bg-gradient-to-br from-gray-50 to-gray-100 dark:from-gray-800 dark:to-gray-900 border border-gray-200 dark:border-gray-700 rounded-xl p-5 space-y-3">
<div class="flex items-start justify-between">
<div class="flex-1">
<p class="text-base font-bold text-gray-900 dark:text-gray-100 mb-1">
{{ client.name }}
</p>
</div>
</div>
</div>
<div class="flex items-start gap-2 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-4">
<GoogleIcon name="info" class="text-red-600 dark:text-red-400 text-xl flex-shrink-0 mt-0.5" />
<p class="text-sm text-red-800 dark:text-red-300 font-medium">
Esta acción es permanente y no se puede deshacer.
</p>
</div>
</div>
<!-- Footer -->
<div class="flex items-center justify-end gap-3 mt-8 pt-6 border-t border-gray-200 dark:border-gray-700">
<button
type="button"
@click="handleClose"
class="px-5 py-2.5 text-sm font-semibold 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-gray-500 transition-colors"
>
Cancelar
</button>
<button
type="button"
@click="handleConfirm"
class="flex items-center gap-2 px-5 py-2.5 bg-red-600 hover:bg-red-700 text-white text-sm font-semibold rounded-lg focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-600 shadow-lg shadow-red-600/30 transition-all"
>
<GoogleIcon name="delete" class="text-xl" />
Eliminar Cliente
</button>
</div>
</div>
</Modal>
</template>

View File

@ -0,0 +1,201 @@
<script setup>
import { watch } from 'vue';
import { useForm, 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';
/** Eventos */
const emit = defineEmits(['close', 'updated']);
/** Propiedades */
const props = defineProps({
show: Boolean,
supplier: Object
});
/** Formulario */
const form = useForm({
business_name: '',
rfc: '',
email: '',
phone: '',
address: '',
postal_code: '',
notes: ''
});
/** Métodos */
const updateSuppliers = () => {
form.put(apiURL(`proveedores/${props.supplier.id}`), {
onSuccess: () => {
window.Notify.success('Proveedor actualizado exitosamente');
emit('updated');
closeModal();
},
onError: () => {
window.Notify.error('Error al actualizar el proveedor');
}
});
};
const closeModal = () => {
form.reset();
emit('close');
};
/** Observadores */
watch(() => props.supplier, (newSuppliers) => {
if (newSuppliers) {
form.business_name = newSuppliers.business_name || '';
form.rfc = newSuppliers.rfc || '';
form.email = newSuppliers.email || '';
form.phone = newSuppliers.phone || '';
form.address = newSuppliers.address || '';
form.postal_code = newSuppliers.postal_code || '';
form.notes = newSuppliers.notes || '';
}
}, { immediate: true });
</script>
<template>
<Modal :show="show" max-width="md" @close="closeModal">
<div class="p-6">
<!-- Header -->
<div class="flex items-center justify-between mb-6">
<h3 class="text-lg font-bold text-gray-900 dark:text-gray-100">
Editar Proveedor
</h3>
<button
@click="closeModal"
class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
</svg>
</button>
</div>
<!-- Formulario -->
<form @submit.prevent="updateSuppliers" class="space-y-4">
<div class="grid grid-cols-2 gap-4">
<!-- Nombre -->
<div class="col-span-2">
<label class="block text-xs font-semibold text-gray-700 dark:text-gray-300 uppercase mb-1.5">
NOMBRE (del negocio)
</label>
<FormInput
v-model="form.business_name"
type="text"
placeholder="Nombre del proveedor"
/>
<FormError :message="form.errors?.name" />
</div>
<!-- RFC -->
<div>
<label class="block text-xs font-semibold text-gray-700 dark:text-gray-300 uppercase mb-1.5">
RFC
</label>
<FormInput
v-model="form.rfc"
type="text"
minlength="12"
maxlength="13"
placeholder="RFC"
/>
<FormError :message="form.errors?.rfc" />
</div>
<!-- Email -->
<div>
<label class="block text-xs font-semibold text-gray-700 dark:text-gray-300 uppercase mb-1.5">
EMAIL
</label>
<FormInput
v-model="form.email"
type="text"
placeholder="Email"
/>
<FormError :message="form.errors?.email" />
</div>
<!-- Teléfono -->
<div>
<label class="block text-xs font-semibold text-gray-700 dark:text-gray-300 uppercase mb-1.5">
TELÉFONO
</label>
<FormInput
v-model="form.phone"
type="text"
placeholder="9933428818"
maxlength="10"
/>
<FormError :message="form.errors?.phone" />
</div>
<!-- Dirección -->
<div>
<label class="block text-xs font-semibold text-gray-700 dark:text-gray-300 uppercase mb-1.5">
DIRECCIÓN
</label>
<FormInput
v-model="form.address"
type="text"
placeholder="Dirección"
/>
<FormError :message="form.errors?.address" />
</div>
<!-- Código Postal -->
<div>
<label class="block text-xs font-semibold text-gray-700 dark:text-gray-300 uppercase mb-1.5">
Código Postal
</label>
<FormInput
v-model="form.postal_code"
type="text"
maxlength="5"
placeholder="Código Postal"
/>
<FormError :message="form.errors?.postal_code" />
</div>
<!-- Notas -->
<div>
<label class="block text-xs font-semibold text-gray-700 dark:text-gray-300 uppercase mb-1.5">
Notas
</label>
<FormInput
v-model="form.notes"
type="text"
placeholder="Notas"
/>
<FormError :message="form.errors?.notes" />
</div>
</div>
<!-- Botones -->
<div class="flex items-center justify-between mt-6">
<div class="flex items-center gap-3">
<button
type="button"
@click="closeModal"
class="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-lg hover:bg-gray-50 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="px-4 py-2 text-sm font-semibold text-white bg-gray-900 rounded-lg hover:bg-gray-800 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-gray-900 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
<span v-if="form.processing">Actualizando...</span>
<span v-else>Actualizar</span>
</button>
</div>
</div>
</form>
</div>
</Modal>
</template>

View File

@ -0,0 +1,215 @@
<script setup>
import { onMounted, ref } from 'vue';
import { useSearcher, apiURL } from '@Services/Api';
import { can } from './Module.js';
import SearcherHead from '@Holos/Searcher.vue';
import Table from '@Holos/Table.vue';
import GoogleIcon from '@Shared/GoogleIcon.vue';
import CreateModal from './Create.vue';
import EditModal from './Edit.vue';
import DeleteModal from './Delete.vue';
/** Estado */
const suppliers = ref([]);
const showCreateModal = ref(false);
const showEditModal = ref(false);
const showDeleteModal = ref(false);
const editingSupplier = ref(null);
const deletingSupplier = ref(null);
/** Métodos */
const searcher = useSearcher({
url: apiURL('proveedores'),
onSuccess: (r) => {
suppliers.value = r.suppliers;
},
onError: () => suppliers.value = []
});
/** Métodos auxiliares */
const confirmDelete = async (id) => {
try {
const response = await fetch(apiURL(`proveedores/${id}`), {
method: 'DELETE',
headers: {
'Authorization': `Bearer ${sessionStorage.token}`,
'Accept': 'application/json'
}
});
if (response.ok) {
window.Notify.success('Cliente eliminado exitosamente');
closeDeleteModal();
searcher.search();
} else {
window.Notify.error('Error al eliminar el cliente');
}
} catch (error) {
console.error('Error:', error);
window.Notify.error('Error al eliminar el cliente');
}
};
const openCreateModal = () => {
showCreateModal.value = true;
};
const closeCreateModal = () => {
showCreateModal.value = false;
};
const openEditModal = (supplier) => {
editingSupplier.value = supplier;
showEditModal.value = true;
};
const closeEditModal = () => {
showEditModal.value = false;
editingSupplier.value = null;
};
const openDeleteModal = (supplier) => {
deletingSupplier.value = supplier;
showDeleteModal.value = true;
};
const closeDeleteModal = () => {
showDeleteModal.value = false;
deletingSupplier.value = null;
};
const onSupplierSaved = () => {
searcher.search();
};
/** Ciclo de vida */
onMounted(() => {
searcher.search();
});
</script>
<template>
<div>
<SearcherHead
:title="$t('suppliers.title')"
placeholder="Buscar por nombre..."
@search="(x) => searcher.search(x)"
>
<button
v-if="can('create')"
class="flex items-center gap-2 px-3 py-2 bg-indigo-600 hover:bg-indigo-700 text-white text-sm font-semibold rounded-lg transition-colors shadow-sm"
@click="openCreateModal"
>
<GoogleIcon name="add" class="text-xl" />
Nuevo Proveedor
</button>
</SearcherHead>
<div class="pt-2 w-full">
<Table
:items="suppliers"
@send-pagination="(page) => searcher.pagination(page)"
>
<template #head>
<th class="px-6 py-3 text-center text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider">NOMBRE</th>
<th class="px-6 py-3 text-center text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider">RFC</th>
<th class="px-6 py-3 text-center text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider">EMAIL</th>
<th class="px-6 py-3 text-center text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider">TELÉFONO</th>
<th class="px-6 py-3 text-center text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider">DIRECCIÓN</th>
<th class="px-6 py-3 text-center text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider">CÓDIGO POSTAL</th>
<th class="px-6 py-3 text-center text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider">NOTAS</th>
<th class="px-6 py-3 text-center text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider">ACCIONES</th>
</template>
<template #body="{items}">
<tr
v-for="supplier in items"
:key="supplier.id"
class="hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors"
>
<td class="px-6 py-4 text-center">
<p class="text-sm font-semibold text-gray-900 dark:text-gray-100">{{ supplier.business_name }}</p>
</td>
<td class="px-6 py-4 text-center">
<p class="text-sm font-semibold text-gray-900 dark:text-gray-100">{{ supplier.rfc }}</p>
</td>
<td class="px-6 py-4 text-center">
<p class="text-sm text-gray-700 dark:text-gray-300">{{ supplier.email }}</p>
</td>
<td class="px-6 py-4 text-center">
<p class="text-sm text-gray-700 dark:text-gray-300">{{ supplier.phone }}</p>
</td>
<td class="px-6 py-4 text-center">
<p class="text-sm text-gray-700 dark:text-gray-300">{{ supplier.address }}</p>
</td>
<td class="px-6 py-4 text-center">
<p class="text-sm text-gray-700 dark:text-gray-300">{{ supplier.postal_code }}</p>
</td>
<td class="px-6 py-4 text-center">
<p class="text-sm text-gray-700 dark:text-gray-300">{{ supplier.notes }}</p>
</td>
<td class="px-6 py-4 whitespace-nowrap text-center">
<div class="flex items-center justify-center gap-2">
<button
v-if="can('edit')"
@click.stop="openEditModal(supplier)"
class="text-indigo-600 hover:text-indigo-900 dark:text-indigo-400 dark:hover:text-indigo-300 transition-colors"
title="Editar proveedor"
>
<GoogleIcon name="edit" class="text-xl" />
</button>
<button
v-if="can('destroy')"
@click.stop="openDeleteModal(supplier)"
class="text-red-600 hover:text-red-900 dark:text-red-400 dark:hover:text-red-300 transition-colors"
title="Eliminar proveedor"
>
<GoogleIcon name="delete" class="text-xl" />
</button>
</div>
</td>
</tr>
</template>
<template #empty>
<td colspan="11" class="table-cell text-center">
<div class="flex flex-col items-center justify-center py-8 text-gray-500">
<GoogleIcon
name="person"
class="text-6xl mb-2 opacity-50"
/>
<p class="font-semibold">
{{ $t('registers.empty') }}
</p>
</div>
</td>
</template>
</Table>
</div>
<!-- Modal de Crear Cliente -->
<CreateModal
v-if="can('create')"
:show="showCreateModal"
@close="closeCreateModal"
@created="onSupplierSaved"
/>
<!-- Modal de Editar Cliente -->
<EditModal
v-if="can('edit')"
:show="showEditModal"
:supplier="editingSupplier"
@close="closeEditModal"
@updated="onSupplierSaved"
/>
<!-- Modal de Eliminar Proveedor -->
<DeleteModal
v-if="can('destroy')"
:show="showDeleteModal"
:supplier="deletingSupplier"
@close="closeDeleteModal"
@confirm="confirmDelete"
/>
</div>
</template>

View File

@ -0,0 +1,16 @@
import { hasPermission } from '@Plugins/RolePermission.js';
// Ruta API
const apiTo = (name, params = {}) => route(`suppliers.${name}`, params)
// Ruta visual
const viewTo = ({ name = '', params = {}, query = {} }) => ({ name: `pos.suppliers.${name}`, params, query })
// Determina si un usuario puede hacer algo en base a los permisos
const can = (permission) => hasPermission(`suppliers.${permission}`)
export {
can,
viewTo,
apiTo
}

View File

@ -118,6 +118,12 @@ const router = createRouter({
name: 'pos.billingRequests.index',
beforeEnter: (to, from, next) => can(next, 'invoice-requests.index'),
component: () => import('@Pages/POS/Clients/BillingRequests.vue')
},
{
path: 'suppliers',
name: 'pos.suppliers.index',
beforeEnter: (to, from, next) => can(next, 'suppliers.index'),
component: () => import('@Pages/POS/Suppliers/Index.vue')
}
]
},