feat: agregar documentación y componentes para gestión de bundles

This commit is contained in:
Juan Felipe Zapata Moreno 2026-02-16 17:16:13 -06:00
parent 156c915403
commit 1466cd2166
7 changed files with 1108 additions and 0 deletions

815
BUNDLES_API_DOCS.md Normal file
View File

@ -0,0 +1,815 @@
# API de Bundles/Kits - Documentación Frontend
## 📋 Índice
1. [Conceptos Generales](#conceptos-generales)
2. [Estructura de Datos](#estructura-de-datos)
3. [Endpoints - CRUD de Bundles](#endpoints---crud-de-bundles)
4. [Endpoints - Ventas con Bundles](#endpoints---ventas-con-bundles)
5. [Validaciones](#validaciones)
6. [Ejemplos de Uso](#ejemplos-de-uso)
7. [Respuestas de Error](#respuestas-de-error)
---
## Conceptos Generales
### ¿Qué es un Bundle?
Un **bundle** (o kit) es una agrupación virtual de productos que se vende como una unidad con un precio especial. Al vender un bundle:
- Se venden todos los componentes del bundle
- Cada componente genera su propio `sale_detail`
- El stock se decrementa de cada componente individual
- Los seriales se asignan a cada componente que los requiera
### Características Clave
**Stock Virtual**: El bundle NO tiene stock propio, se calcula desde sus componentes
**Precio Especial**: El precio del bundle puede ser menor que la suma de componentes (promoción)
**Componentes Independientes**: Los productos del bundle también pueden venderse por separado
**Devoluciones Flexibles**: Se puede devolver el bundle completo o solo algunos componentes
**Rastreo de Seriales**: Soporta componentes con números de serie
---
## Estructura de Datos
### Bundle (Kit)
```json
{
"id": 1,
"name": "Kit Gamer Pro",
"sku": "KIT-GAMER-001",
"barcode": "123456789",
"available_stock": 5,
"total_cost": 250.00,
"items": [
{
"id": 1,
"bundle_id": 1,
"inventory_id": 10,
"quantity": 1,
"inventory": {
"id": 10,
"name": "Mouse Gaming",
"sku": "MOUSE-001",
"stock": 50,
"price": {
"cost": 80.00,
"retail_price": 150.00,
"tax": 16.00
}
}
},
{
"id": 2,
"bundle_id": 1,
"inventory_id": 15,
"quantity": 1,
"inventory": {
"id": 15,
"name": "Teclado Gaming",
"sku": "TECLADO-001",
"stock": 30,
"price": {
"cost": 120.00,
"retail_price": 300.00,
"tax": 16.00
}
}
},
{
"id": 3,
"bundle_id": 1,
"inventory_id": 20,
"quantity": 2,
"inventory": {
"id": 20,
"name": "Mouse Pad",
"sku": "PAD-001",
"stock": 100,
"price": {
"cost": 25.00,
"retail_price": 50.00,
"tax": 16.00
}
}
}
],
"price": {
"id": 1,
"bundle_id": 1,
"cost": 250.00,
"retail_price": 450.00,
"tax": 72.00
},
"created_at": "2026-02-16T10:00:00.000000Z",
"updated_at": "2026-02-16T10:00:00.000000Z",
"deleted_at": null
}
```
### Campos Calculados
| Campo | Tipo | Descripción |
|-------|------|-------------|
| `available_stock` | int | Stock disponible del bundle = min(stock_componente / cantidad_requerida) |
| `total_cost` | float | Suma de costos de componentes = Σ(costo_componente × cantidad) |
**Ejemplo de cálculo de `available_stock`:**
- Mouse Gaming: stock 50 / cantidad 1 = 50 kits posibles
- Teclado Gaming: stock 30 / cantidad 1 = 30 kits posibles
- Mouse Pad: stock 100 / cantidad 2 = 50 kits posibles
**Stock disponible = min(50, 30, 50) = 30 kits**
---
## Endpoints - CRUD de Bundles
### 1. Listar Bundles
```http
GET /bundles?q={busqueda}&page={pagina}
Authorization: Bearer {token}
```
**Query Parameters:**
- `q` (opcional): Búsqueda por nombre, SKU o código de barras
- `page` (opcional): Número de página para paginación
**Respuesta Exitosa:**
```json
{
"bundles": {
"data": [
{
"id": 1,
"name": "Kit Gamer Pro",
"sku": "KIT-GAMER-001",
"available_stock": 30,
"items": [...],
"price": {...}
}
],
"current_page": 1,
"last_page": 3,
"per_page": 15,
"total": 42
}
}
```
---
### 2. Ver Detalle de Bundle
```http
GET /bundles/{id}
Authorization: Bearer {token}
```
**Respuesta Exitosa:**
```json
{
"model": {
"id": 1,
"name": "Kit Gamer Pro",
"sku": "KIT-GAMER-001",
"barcode": "123456789",
"available_stock": 30,
"total_cost": 250.00,
"items": [...],
"price": {...}
}
}
```
---
### 3. Crear Bundle
```http
POST /bundles
Authorization: Bearer {token}
Content-Type: application/json
```
**Request Body:**
```json
{
"name": "Kit Gamer Pro",
"sku": "KIT-GAMER-001",
"barcode": "123456789",
"items": [
{
"inventory_id": 10,
"quantity": 1
},
{
"inventory_id": 15,
"quantity": 1
},
{
"inventory_id": 20,
"quantity": 2
}
],
"retail_price": 450.00,
"tax": 72.00
}
```
**Campos:**
| Campo | Tipo | Requerido | Descripción |
|-------|------|-----------|-------------|
| `name` | string | ✅ | Nombre del bundle (max 255 caracteres) |
| `sku` | string | ✅ | SKU único del bundle (max 50 caracteres) |
| `barcode` | string | ❌ | Código de barras (max 50 caracteres) |
| `items` | array | ✅ | Componentes del bundle (mínimo 2) |
| `items[].inventory_id` | int | ✅ | ID del producto componente |
| `items[].quantity` | int | ✅ | Cantidad de este componente (min: 1) |
| `retail_price` | float | ❌ | Precio de venta (si no se envía, se calcula automáticamente) |
| `tax` | float | ❌ | Impuesto (si no se envía, se calcula como 16% del retail_price) |
**Respuesta Exitosa (201 Created):**
```json
{
"model": {
"id": 1,
"name": "Kit Gamer Pro",
"sku": "KIT-GAMER-001",
...
},
"message": "Bundle creado exitosamente"
}
```
---
### 4. Actualizar Bundle
```http
PUT /bundles/{id}
Authorization: Bearer {token}
Content-Type: application/json
```
**Request Body:**
```json
{
"name": "Kit Gamer Pro V2",
"sku": "KIT-GAMER-002",
"items": [
{
"inventory_id": 10,
"quantity": 2
},
{
"inventory_id": 15,
"quantity": 1
}
],
"retail_price": 500.00,
"recalculate_price": false
}
```
**Campos:**
| Campo | Tipo | Requerido | Descripción |
|-------|------|-----------|-------------|
| `name` | string | ❌ | Nuevo nombre del bundle |
| `sku` | string | ❌ | Nuevo SKU (debe ser único) |
| `items` | array | ❌ | Nuevos componentes (si se envía, reemplaza todos) |
| `retail_price` | float | ❌ | Nuevo precio de venta |
| `recalculate_price` | bool | ❌ | Si es `true`, recalcula precio desde componentes |
**Respuesta Exitosa:**
```json
{
"model": {...},
"message": "Bundle actualizado exitosamente"
}
```
---
### 5. Eliminar Bundle
```http
DELETE /bundles/{id}
Authorization: Bearer {token}
```
**Respuesta Exitosa:**
```json
{
"message": "Bundle eliminado exitosamente"
}
```
**Nota:** El bundle se elimina con soft delete (puede restaurarse).
---
### 6. Verificar Stock de Bundle
```http
GET /bundles/{id}/check-stock?quantity={cantidad}&warehouse_id={almacen}
Authorization: Bearer {token}
```
**Query Parameters:**
- `quantity` (opcional): Cantidad de kits a verificar (default: 1)
- `warehouse_id` (opcional): ID del almacén específico
**Respuesta Exitosa:**
```json
{
"bundle_id": 1,
"bundle_name": "Kit Gamer Pro",
"quantity_requested": 2,
"available_stock": 30,
"has_stock": true,
"components_stock": [
{
"inventory_id": 10,
"product_name": "Mouse Gaming",
"required_quantity": 1,
"available_stock": 50
},
{
"inventory_id": 15,
"product_name": "Teclado Gaming",
"required_quantity": 1,
"available_stock": 30
},
{
"inventory_id": 20,
"product_name": "Mouse Pad",
"required_quantity": 2,
"available_stock": 100
}
]
}
```
---
## Endpoints - Ventas con Bundles
### Vender Bundle
```http
POST /sales
Authorization: Bearer {token}
Content-Type: application/json
```
**Request Body:**
```json
{
"user_id": 1,
"client_id": 5,
"subtotal": 450.00,
"tax": 72.00,
"total": 522.00,
"payment_method": "cash",
"cash_received": 600.00,
"items": [
{
"type": "bundle",
"bundle_id": 1,
"quantity": 1,
"warehouse_id": 1,
"serial_numbers": {
"15": ["SN-TECLADO-001"]
}
}
]
}
```
**Campos del Item (Bundle):**
| Campo | Tipo | Requerido | Descripción |
|-------|------|-----------|-------------|
| `type` | string | ✅ | Debe ser `"bundle"` |
| `bundle_id` | int | ✅ | ID del bundle a vender |
| `quantity` | int | ✅ | Cantidad de kits a vender |
| `warehouse_id` | int | ❌ | Almacén de donde salen los componentes |
| `serial_numbers` | object | ❌ | Seriales por componente (key = inventory_id, value = array de seriales) |
**Ejemplo de `serial_numbers` para múltiples componentes:**
```json
{
"serial_numbers": {
"10": ["SN-MOUSE-001"],
"15": ["SN-TECLADO-001"],
"20": ["SN-PAD-001", "SN-PAD-002"]
}
}
```
**Respuesta Exitosa:**
La venta se crea con múltiples `sale_details` (uno por componente):
```json
{
"id": 100,
"invoice_number": "INV-20260216-0001",
"total": 522.00,
"details": [
{
"id": 1,
"sale_id": 100,
"inventory_id": 10,
"bundle_id": 1,
"bundle_sale_group": "uuid-aaa-bbb-ccc",
"product_name": "Mouse Gaming",
"quantity": 1,
"unit_price": 135.00,
"subtotal": 135.00
},
{
"id": 2,
"sale_id": 100,
"inventory_id": 15,
"bundle_id": 1,
"bundle_sale_group": "uuid-aaa-bbb-ccc",
"product_name": "Teclado Gaming",
"quantity": 1,
"unit_price": 270.00,
"subtotal": 270.00,
"serials": [
{
"serial_number": "SN-TECLADO-001",
"status": "vendido"
}
]
},
{
"id": 3,
"sale_id": 100,
"inventory_id": 20,
"bundle_id": 1,
"bundle_sale_group": "uuid-aaa-bbb-ccc",
"product_name": "Mouse Pad",
"quantity": 2,
"unit_price": 45.00,
"subtotal": 90.00
}
]
}
```
**Notas importantes:**
1. **`bundle_sale_group`**: Todos los componentes del mismo kit vendido comparten el mismo UUID
2. **Precios Proporcionales**: Los precios se distribuyen proporcionalmente del precio total del bundle
3. **Stock**: Se decrementa automáticamente de cada componente
4. **Seriales**: Se asignan automáticamente a los componentes que usan `track_serials=true`
---
### Venta Mixta (Productos + Bundles)
Puedes vender productos normales y bundles en la misma venta:
```json
{
"items": [
{
"type": "bundle",
"bundle_id": 1,
"quantity": 1
},
{
"type": "product",
"inventory_id": 50,
"product_name": "Cable HDMI",
"quantity": 2,
"unit_price": 25.00,
"subtotal": 50.00
}
]
}
```
**Si no se especifica `type`, se asume que es `"product"`.**
---
## Validaciones
### Validaciones de Bundle (Creación)
| Campo | Validaciones |
|-------|--------------|
| `name` | Requerido, string, máx 255 caracteres |
| `sku` | Requerido, string, máx 50 caracteres, único en tabla `bundles` |
| `barcode` | Opcional, string, máx 50 caracteres |
| `items` | Requerido, array, mínimo 2 productos |
| `items[].inventory_id` | Requerido, debe existir en tabla `inventories` |
| `items[].quantity` | Requerido, entero, mínimo 1 |
| `retail_price` | Opcional, numérico, mínimo 0 |
| `tax` | Opcional, numérico, mínimo 0 |
**Validaciones adicionales:**
- ❌ No se pueden agregar productos duplicados al mismo bundle
- ❌ Cada producto solo puede aparecer UNA vez por bundle
**Ejemplo de error:**
```json
{
"message": "Error de validación",
"errors": {
"items": ["No se pueden agregar productos duplicados al bundle."],
"sku": ["Este SKU ya está en uso."]
}
}
```
---
### Validaciones de Venta con Bundle
| Campo | Validaciones |
|-------|--------------|
| `type` | Opcional, debe ser `"product"` o `"bundle"` |
| `bundle_id` | Requerido si `type="bundle"`, debe existir en tabla `bundles` |
| `quantity` | Requerido, entero, mínimo 1 |
| `warehouse_id` | Opcional, debe existir en tabla `warehouses` |
| `serial_numbers` | Opcional, objeto con arrays de seriales |
**Validaciones de stock:**
- ✅ Se valida que TODOS los componentes tengan stock suficiente
- ✅ Se valida que los seriales existan y estén disponibles
- ✅ Si falta stock de UN componente, la venta completa falla
**Ejemplo de error de stock:**
```json
{
"message": "Stock insuficiente del kit 'Kit Gamer Pro'. Disponibles: 5, Requeridos: 10"
}
```
**Ejemplo de error de serial:**
```json
{
"message": "Serial SN-TECLADO-999 no disponible para Teclado Gaming"
}
```
---
## Ejemplos de Uso
### Ejemplo 1: Flujo Completo - Crear y Vender Bundle
**Paso 1: Crear Bundle**
```javascript
const response = await fetch('/bundles', {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
name: "Kit Gamer Pro",
sku: "KIT-GAMER-001",
items: [
{ inventory_id: 10, quantity: 1 }, // Mouse
{ inventory_id: 15, quantity: 1 }, // Teclado
{ inventory_id: 20, quantity: 2 } // Mouse Pads
],
retail_price: 450.00,
tax: 72.00
})
});
const bundle = await response.json();
console.log("Bundle creado:", bundle.model.id);
```
**Paso 2: Verificar Stock**
```javascript
const stockResponse = await fetch(`/bundles/${bundle.model.id}/check-stock?quantity=5`, {
headers: { 'Authorization': `Bearer ${token}` }
});
const stockData = await stockResponse.json();
if (stockData.has_stock) {
console.log(`Stock disponible: ${stockData.available_stock} kits`);
} else {
console.log("Stock insuficiente");
console.table(stockData.components_stock);
}
```
**Paso 3: Vender Bundle**
```javascript
const saleResponse = await fetch('/sales', {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
user_id: 1,
subtotal: 450.00,
tax: 72.00,
total: 522.00,
payment_method: "cash",
cash_received: 600.00,
items: [
{
type: "bundle",
bundle_id: bundle.model.id,
quantity: 1,
warehouse_id: 1
}
]
})
});
const sale = await saleResponse.json();
console.log(`Venta creada: ${sale.invoice_number}`);
console.log(`Total de componentes vendidos: ${sale.details.length}`);
```
---
### Ejemplo 2: Buscar Bundle por SKU
```javascript
const searchResponse = await fetch('/bundles?q=GAMER', {
headers: { 'Authorization': `Bearer ${token}` }
});
const { bundles } = await searchResponse.json();
bundles.data.forEach(bundle => {
console.log(`${bundle.name} - Stock: ${bundle.available_stock}`);
});
```
---
### Ejemplo 3: Actualizar Precio de Bundle
```javascript
await fetch(`/bundles/${bundleId}`, {
method: 'PUT',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
retail_price: 399.99, // Nuevo precio promocional
tax: 64.00
})
});
```
---
## Respuestas de Error
### Errores Comunes
#### 404 Not Found
```json
{
"message": "No query results for model [App\\Models\\Bundle] 1"
}
```
#### 422 Validation Error
```json
{
"message": "Error de validación",
"errors": {
"items": ["Un bundle debe tener al menos 2 productos."],
"sku": ["Este SKU ya está en uso."]
}
}
```
#### 500 Internal Server Error
```json
{
"message": "Error al crear el bundle: Stock insuficiente del kit 'Kit Gamer Pro'. Disponibles: 5, Requeridos: 10"
}
```
---
## Notas Importantes para Frontend
### 1. **Cálculo de Totales**
Cuando vendes un bundle, el frontend debe:
- Usar el `retail_price` del bundle (NO sumar componentes)
- Calcular el tax basado en el `retail_price` del bundle
- El backend distribuirá los precios proporcionalmente a los componentes
### 2. **Visualización de Stock**
El `available_stock` ya está calculado por el backend. No necesitas:
- ❌ Sumar el stock de componentes
- ❌ Hacer cálculos manuales
Solo muestra el valor de `available_stock` directamente.
### 3. **Seriales en Bundles**
Si un componente del bundle usa seriales (`track_serials=true`):
- ✅ Puedes especificarlos en `serial_numbers` por `inventory_id`
- ✅ Si no los especificas, se asignan automáticamente
- ✅ Solo necesitas seriales para componentes que los requieran
### 4. **Devoluciones**
Las devoluciones de bundles funcionan igual que productos normales:
- Puedes devolver componentes individuales
- Cada `sale_detail` se puede devolver independientemente
- El `bundle_sale_group` te ayuda a identificar qué componentes pertenecen al mismo kit vendido
### 5. **Cancelación de Ventas**
La cancelación funciona automáticamente:
- El stock de TODOS los componentes se restaura
- Los seriales vuelven a estado `disponible`
- No necesitas lógica especial en el frontend
---
## Cambios Respecto a Ventas Normales
| Aspecto | Venta Normal | Venta de Bundle |
|---------|--------------|-----------------|
| **Items** | `type: "product"` o sin type | `type: "bundle"` |
| **ID del producto** | `inventory_id` | `bundle_id` |
| **Sale Details** | 1 por item | N por bundle (N = componentes) |
| **Stock** | Decrementa 1 producto | Decrementa N componentes |
| **Seriales** | Por producto | Por componente (si requiere) |
| **Devolución** | Por producto | Por componente |
---
## Diagrama de Relaciones
```
Bundle (Kit)
├── BundlePrice (1:1)
│ └── cost, retail_price, tax
└── BundleItems (1:N)
└── Inventory (N:1)
├── Stock (en inventory_warehouse)
├── Price
└── Serials (si track_serials=true)
Sale
└── SaleDetails (1:N)
├── bundle_id (FK, nullable)
├── bundle_sale_group (UUID, nullable)
└── Inventory (N:1)
```
---
## Soporte
Si encuentras algún problema o tienes dudas sobre la implementación, contacta al equipo de backend o consulta el código fuente en:
- **Controlador**: `app/Http/Controllers/App/BundleController.php`
- **Servicio**: `app/Services/BundleService.php`
- **Modelos**: `app/Models/Bundle.php`, `app/Models/BundleItem.php`, `app/Models/BundlePrice.php`
- **Plan completo**: `~/.claude/plans/fancy-roaming-shamir.md`

View File

View File

@ -0,0 +1,88 @@
<script setup>
import Modal from '@Holos/Modal.vue';
import GoogleIcon from '@Shared/GoogleIcon.vue';
const props = defineProps({
show: Boolean,
bundle: Object
});
const emit = defineEmits(['close', 'confirm']);
const handleConfirm = () => {
emit('confirm', props.bundle.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 Paquete
</h3>
</div>
<button
@click="handleClose"
class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
>
<GoogleIcon name="close" class="text-xl" />
</button>
</div>
<!-- Contenido -->
<div class="space-y-4">
<p class="text-gray-700 dark:text-gray-300">
¿Estás seguro de que deseas eliminar este paquete?
</p>
<!-- Información del bundle -->
<div v-if="bundle" class="bg-gray-50 dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg p-4">
<p class="font-bold text-gray-900 dark:text-gray-100 mb-1">
{{ bundle.name }}
</p>
<p class="text-sm text-gray-600 dark:text-gray-400">
SKU: {{ bundle.sku }}
</p>
<p class="text-sm text-gray-600 dark:text-gray-400">
{{ bundle.items?.length || 0 }} componentes
</p>
</div>
<!-- Advertencia -->
<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-3">
<GoogleIcon name="info" class="text-red-600 dark:text-red-400 text-xl flex-shrink-0" />
<p class="text-sm text-red-800 dark:text-red-300">
Esta acción no afectará los productos individuales del inventario.
</p>
</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
@click="handleClose"
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 transition-colors"
>
Cancelar
</button>
<button
@click="handleConfirm"
class="flex items-center gap-2 px-4 py-2 bg-red-600 hover:bg-red-700 text-white text-sm font-semibold rounded-lg transition-colors"
>
<GoogleIcon name="delete" class="text-xl" />
Eliminar Paquete
</button>
</div>
</div>
</Modal>
</template>

View File

View File

View File

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

View File

@ -0,0 +1,189 @@
<script setup>
import { ref, watch } from 'vue';
import { useApi, apiURL } from '@Services/Api';
import { formatCurrency } from '@/utils/formatters';
import GoogleIcon from '@Shared/GoogleIcon.vue';
const props = defineProps({
modelValue: {
type: String,
default: ''
},
excludeIds: {
type: Array,
default: () => []
},
placeholder: {
type: String,
default: 'Buscar producto por nombre o SKU...'
},
disabled: {
type: Boolean,
default: false
}
});
const emit = defineEmits(['update:modelValue', 'select']);
const api = useApi();
const productSuggestions = ref([]);
const showSuggestions = ref(false);
const searchingProduct = ref(false);
const productNotFound = ref(false);
let debounceTimer = null;
const localValue = ref(props.modelValue);
watch(() => props.modelValue, (newVal) => {
localValue.value = newVal;
});
watch(localValue, (newVal) => {
emit('update:modelValue', newVal);
onProductInput();
});
const onProductInput = () => {
productNotFound.value = false;
const searchValue = localValue.value?.trim();
if (!searchValue || searchValue.length < 2) {
productSuggestions.value = [];
showSuggestions.value = false;
return;
}
clearTimeout(debounceTimer);
debounceTimer = setTimeout(() => {
showSuggestions.value = true;
searchProduct();
}, 300);
};
const searchProduct = () => {
const searchValue = localValue.value?.trim();
if (!searchValue) {
productSuggestions.value = [];
showSuggestions.value = false;
return;
}
searchingProduct.value = true;
productNotFound.value = false;
api.get(apiURL(`inventario?q=${encodeURIComponent(searchValue)}`), {
onSuccess: (data) => {
const foundProducts = data.products?.data || data.data || [];
// Filtrar productos ya agregados
const filteredProducts = foundProducts.filter(
p => !props.excludeIds.includes(p.id)
);
if (filteredProducts.length > 0) {
productSuggestions.value = filteredProducts;
showSuggestions.value = true;
} else {
productSuggestions.value = [];
showSuggestions.value = false;
productNotFound.value = true;
}
},
onFail: () => {
productSuggestions.value = [];
showSuggestions.value = false;
productNotFound.value = true;
},
onError: () => {
productSuggestions.value = [];
showSuggestions.value = false;
productNotFound.value = true;
},
onFinish: () => {
searchingProduct.value = false;
}
});
};
const selectProduct = (product) => {
emit('select', product);
localValue.value = '';
productSuggestions.value = [];
showSuggestions.value = false;
productNotFound.value = false;
};
const closeSuggestions = () => {
setTimeout(() => {
showSuggestions.value = false;
}, 200);
};
</script>
<template>
<div class="relative">
<div class="relative">
<input
v-model="localValue"
type="text"
:placeholder="placeholder"
:disabled="disabled"
@blur="closeSuggestions"
class="w-full px-3 py-2 pr-10 text-sm border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 dark:bg-gray-800 dark:text-gray-100 disabled:opacity-50 disabled:cursor-not-allowed"
/>
<div class="absolute inset-y-0 right-0 flex items-center pr-3 pointer-events-none">
<GoogleIcon
v-if="searchingProduct"
name="hourglass_empty"
class="text-gray-400 text-lg animate-spin"
/>
<GoogleIcon
v-else
name="search"
class="text-gray-400 text-lg"
/>
</div>
</div>
<!-- Sugerencias -->
<div
v-if="showSuggestions && productSuggestions.length > 0"
class="absolute z-10 w-full mt-1 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg shadow-lg max-h-60 overflow-y-auto"
>
<button
v-for="product in productSuggestions"
:key="product.id"
@click="selectProduct(product)"
class="w-full px-4 py-3 text-left hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors border-b border-gray-200 dark:border-gray-700 last:border-b-0"
>
<div class="flex items-center justify-between">
<div>
<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">
SKU: {{ product.sku }} | Stock: {{ product.stock }}
</p>
</div>
<div class="text-right">
<p class="text-xs text-gray-500 dark:text-gray-400">Precio</p>
<p class="text-sm font-semibold text-gray-900 dark:text-gray-100">
{{ formatCurrency(product.price?.retail_price) }}
</p>
</div>
</div>
</button>
</div>
<!-- Mensaje de no encontrado -->
<div
v-if="productNotFound && !searchingProduct"
class="absolute z-10 w-full mt-1 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg shadow-lg p-4"
>
<div class="flex items-center gap-2 text-gray-500 dark:text-gray-400">
<GoogleIcon name="search_off" class="text-xl" />
<p class="text-sm">No se encontraron productos</p>
</div>
</div>
</div>
</template>