feat: búsqueda, tickets y gestión UI de paquetes- Integra búsqueda y visualización de paquetes en el POS.

- Desglosa detalles de paquetes en la generación de tickets.
- Mejora modales de edición y eliminación.
This commit is contained in:
Juan Felipe Zapata Moreno 2026-02-16 23:34:22 -06:00
parent 1466cd2166
commit 32949fe13a
13 changed files with 1250 additions and 898 deletions

View File

@ -1,815 +0,0 @@
# 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

@ -64,7 +64,7 @@ const increment = () => {
if (canIncrement.value) {
const incrementValue = props.item.allows_decimals ? 1 : 1;
const newQuantity = props.item.quantity + incrementValue;
emit('update-quantity', props.item.inventory_id, newQuantity);
emit('update-quantity', props.item.item_key, newQuantity);
}
};
@ -74,9 +74,9 @@ const decrement = () => {
if (props.item.quantity > minValue) {
const newQuantity = Math.max(minValue, props.item.quantity - decrementValue);
emit('update-quantity', props.item.inventory_id, newQuantity);
emit('update-quantity', props.item.item_key, newQuantity);
} else {
emit('remove', props.item.inventory_id);
emit('remove', props.item.item_key);
}
};
@ -85,12 +85,12 @@ const handleQuantityInput = (event) => {
const numValue = parseFloat(value);
if (!isNaN(numValue) && numValue > 0) {
emit('update-quantity', props.item.inventory_id, numValue);
emit('update-quantity', props.item.item_key, numValue);
}
};
const remove = () => {
emit('remove', props.item.inventory_id);
emit('remove', props.item.item_key);
};
</script>
@ -98,9 +98,17 @@ const remove = () => {
<div class="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 hover:border-indigo-300 dark:hover:border-indigo-600 transition-colors p-3">
<!-- Fila principal: Nombre y botón eliminar -->
<div class="flex items-start justify-between mb-2">
<h4 class="text-sm font-semibold text-gray-800 dark:text-gray-200 line-clamp-2 pr-2">
<div class="flex items-center gap-1.5 pr-2 min-w-0">
<span
v-if="item.is_bundle"
class="shrink-0 px-1.5 py-0.5 text-xs font-bold rounded bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-400"
>
KIT
</span>
<h4 class="text-sm font-semibold text-gray-800 dark:text-gray-200 line-clamp-2">
{{ item.product_name }}
</h4>
</div>
<button
type="button"
class="shrink-0 w-6 h-6 flex items-center justify-center rounded-full bg-red-50 hover:bg-red-100 dark:bg-red-900/20 dark:hover:bg-red-900/30 text-red-600 dark:text-red-400 transition-colors"

View File

@ -453,6 +453,7 @@ export default {
title: 'Punto de Venta',
subtitle: 'Gestión de ventas y caja',
category: 'Categorías',
bundles: 'Paquetes',
inventory: 'Productos',
prices: 'Precios',
cashRegister: 'Caja',

View File

@ -53,6 +53,12 @@ onMounted(() => {
name="pos.inventory"
to="pos.inventory.index"
/>
<SubLink
v-if="hasPermission('inventario.index')"
icon="stack"
name="pos.bundles"
to="pos.bundles.index"
/>
<SubLink
icon="accessibility"
name="pos.clients"

View File

@ -0,0 +1,267 @@
<script setup>
import { ref, computed, watch } from 'vue';
import { useForm } from '@Services/Api';
import { formatCurrency } from '@/utils/formatters';
import { apiTo } from './Module.js';
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';
import ProductSelector from './ProductSelector.vue';
/** Eventos */
const emit = defineEmits(['close', 'created']);
/** Propiedades */
const props = defineProps({
show: Boolean
});
/** Formulario */
const form = useForm({
name: '',
sku: '',
barcode: '',
items: [],
retail_price: '',
tax: ''
});
const selectedProducts = ref([]);
/** Computed */
const totalCost = computed(() => {
return selectedProducts.value.reduce((sum, item) => {
return sum + ((item.product.price?.cost || 0) * item.quantity);
}, 0);
});
const suggestedPrice = computed(() => {
return selectedProducts.value.reduce((sum, item) => {
return sum + ((item.product.price?.retail_price || 0) * item.quantity);
}, 0);
});
/** Métodos */
const calculateTax = () => {
if (form.retail_price && !form.tax) {
form.tax = (parseFloat(form.retail_price) * 0.16).toFixed(2);
}
};
const handleProductSelect = (product) => {
if (selectedProducts.value.find(item => item.product.id === product.id)) {
Notify.warning('Este producto ya está agregado');
return;
}
selectedProducts.value.push({ product, quantity: 1 });
};
const removeProduct = (index) => {
selectedProducts.value.splice(index, 1);
};
const updateQuantity = (index, quantity) => {
if (quantity >= 1) {
selectedProducts.value[index].quantity = parseInt(quantity);
}
};
const useSuggestedPrice = () => {
form.retail_price = suggestedPrice.value.toFixed(2);
calculateTax();
};
const createBundle = () => {
if (selectedProducts.value.length < 2) {
Notify.error('Debes agregar al menos 2 productos al paquete');
return;
}
form.items = selectedProducts.value.map(item => ({
inventory_id: item.product.id,
quantity: item.quantity
}));
form.post(apiTo('store'), {
onSuccess: () => {
Notify.success('Paquete creado exitosamente');
emit('created');
closeModal();
},
onError: () => {
Notify.error('Error al crear el paquete');
}
});
};
const closeModal = () => {
form.reset();
selectedProducts.value = [];
emit('close');
};
/** Observadores */
watch(() => props.show, (val) => {
if (val) {
form.reset();
selectedProducts.value = [];
}
});
</script>
<template>
<Modal :show="show" max-width="2xl" @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 Paquete
</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="createBundle" 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
</label>
<FormInput
v-model="form.name"
type="text"
placeholder="Ej: Kit Gamer Pro"
required
/>
<FormError :message="form.errors?.name" />
</div>
<!-- SKU -->
<div>
<label class="block text-xs font-semibold text-gray-700 dark:text-gray-300 uppercase mb-1.5">
SKU
</label>
<FormInput
v-model="form.sku"
type="text"
placeholder="KIT-001"
required
/>
<FormError :message="form.errors?.sku" />
</div>
<!-- Código de barras -->
<div>
<label class="block text-xs font-semibold text-gray-700 dark:text-gray-300 uppercase mb-1.5">
CÓDIGO DE BARRAS
</label>
<FormInput
v-model="form.barcode"
type="text"
placeholder="Opcional"
/>
<FormError :message="form.errors?.barcode" />
</div>
<!-- Componentes -->
<div class="col-span-2">
<label class="block text-xs font-semibold text-gray-700 dark:text-gray-300 uppercase mb-1.5">
COMPONENTES <span class="text-gray-400 normal-case font-normal">(mínimo 2)</span>
</label>
<ProductSelector
:exclude-ids="selectedProducts.map(item => item.product.id)"
@select="handleProductSelect"
/>
<div v-if="selectedProducts.length > 0" class="mt-2 space-y-2">
<div
v-for="(item, index) in selectedProducts"
:key="item.product.id"
class="flex items-center gap-3 p-3 bg-gray-50 dark:bg-gray-700 rounded-lg"
>
<div class="flex-1 min-w-0">
<p class="text-sm font-medium text-gray-900 dark:text-gray-100 truncate">{{ item.product.name }}</p>
<p class="text-xs text-gray-500 dark:text-gray-400">SKU: {{ item.product.sku }}</p>
</div>
<div class="flex items-center gap-1 shrink-0">
<span class="text-xs text-gray-500">Cant:</span>
<input
:value="item.quantity"
@input="updateQuantity(index, $event.target.value)"
type="number"
min="1"
class="w-16 px-2 py-1 text-center text-sm border border-gray-300 dark:border-gray-600 rounded focus:ring-1 focus:ring-indigo-500 dark:bg-gray-800 dark:text-gray-100"
/>
</div>
<span class="text-sm font-semibold text-gray-700 dark:text-gray-300 min-w-20 text-right shrink-0">
{{ formatCurrency(item.product.price?.retail_price) }}
</span>
<button type="button" @click="removeProduct(index)" class="text-red-500 hover:text-red-700 shrink-0">
<GoogleIcon name="close" class="text-lg" />
</button>
</div>
</div>
<FormError :message="form.errors?.items" />
</div>
<!-- Precio de Venta -->
<div>
<label class="block text-xs font-semibold text-gray-700 dark:text-gray-300 uppercase mb-1.5">
PRECIO VENTA
</label>
<FormInput
v-model.number="form.retail_price"
@blur="calculateTax"
type="number"
step="0.01"
min="0"
placeholder="0.00"
/>
<FormError :message="form.errors?.retail_price" />
</div>
<!-- Impuesto -->
<div>
<label class="block text-xs font-semibold text-gray-700 dark:text-gray-300 uppercase mb-1.5">
IMPUESTO (%)
</label>
<FormInput
v-model.number="form.tax"
type="number"
step="0.01"
min="0"
placeholder="16.00"
/>
<FormError :message="form.errors?.tax" />
</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,292 @@
<script setup>
import { ref, computed, watch } from 'vue';
import { useForm } from '@Services/Api';
import { formatCurrency } from '@/utils/formatters';
import { apiTo } from './Module.js';
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';
import ProductSelector from './ProductSelector.vue';
/** Eventos */
const emit = defineEmits(['close', 'updated']);
/** Propiedades */
const props = defineProps({
show: Boolean,
bundle: Object
});
/** Formulario */
const form = useForm({
name: '',
sku: '',
barcode: '',
items: [],
retail_price: '',
tax: '',
recalculate_price: false
});
const selectedProducts = ref([]);
/** Computed */
const totalCost = computed(() => {
return selectedProducts.value.reduce((sum, item) => {
return sum + ((item.product.price?.cost || 0) * item.quantity);
}, 0);
});
const suggestedPrice = computed(() => {
return selectedProducts.value.reduce((sum, item) => {
return sum + ((item.product.price?.retail_price || 0) * item.quantity);
}, 0);
});
/** Métodos */
const calculateTax = () => {
if (form.retail_price && !form.tax) {
form.tax = (parseFloat(form.retail_price) * 0.16).toFixed(2);
}
};
const handleProductSelect = (product) => {
if (selectedProducts.value.find(item => item.product.id === product.id)) {
Notify.warning('Este producto ya está agregado');
return;
}
selectedProducts.value.push({ product, quantity: 1 });
};
const removeProduct = (index) => {
selectedProducts.value.splice(index, 1);
};
const updateQuantity = (index, quantity) => {
if (quantity >= 1) {
selectedProducts.value[index].quantity = parseInt(quantity);
}
};
const useSuggestedPrice = () => {
form.retail_price = suggestedPrice.value.toFixed(2);
calculateTax();
};
const updateBundle = () => {
if (selectedProducts.value.length < 2) {
Notify.error('Debes agregar al menos 2 productos al paquete');
return;
}
form.items = selectedProducts.value.map(item => ({
inventory_id: item.product.id,
quantity: item.quantity
}));
form.put(apiTo('update', { bundle: props.bundle.id }), {
onSuccess: () => {
Notify.success('Paquete actualizado exitosamente');
emit('updated');
closeModal();
},
onError: () => {
Notify.error('Error al actualizar el paquete');
}
});
};
const closeModal = () => {
form.reset();
selectedProducts.value = [];
emit('close');
};
/** Observadores */
watch(() => props.bundle, (bundle) => {
if (bundle) {
form.name = bundle.name || '';
form.sku = bundle.sku || '';
form.barcode = bundle.barcode || '';
form.retail_price = bundle.price?.retail_price || '';
form.tax = bundle.price?.tax || '';
form.recalculate_price = false;
selectedProducts.value = (bundle.items || []).map(item => ({
product: item.inventory,
quantity: item.quantity
}));
}
}, { immediate: true });
</script>
<template>
<Modal :show="show" max-width="2xl" @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 Paquete
</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="updateBundle" 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
</label>
<FormInput
v-model="form.name"
type="text"
placeholder="Ej: Kit Gamer Pro"
required
/>
<FormError :message="form.errors?.name" />
</div>
<!-- SKU -->
<div>
<label class="block text-xs font-semibold text-gray-700 dark:text-gray-300 uppercase mb-1.5">
SKU
</label>
<FormInput
v-model="form.sku"
type="text"
placeholder="KIT-001"
required
/>
<FormError :message="form.errors?.sku" />
</div>
<!-- Código de barras -->
<div>
<label class="block text-xs font-semibold text-gray-700 dark:text-gray-300 uppercase mb-1.5">
CÓDIGO DE BARRAS
</label>
<FormInput
v-model="form.barcode"
type="text"
placeholder="Opcional"
/>
<FormError :message="form.errors?.barcode" />
</div>
<!-- Componentes -->
<div class="col-span-2">
<label class="block text-xs font-semibold text-gray-700 dark:text-gray-300 uppercase mb-1.5">
COMPONENTES <span class="text-gray-400 normal-case font-normal">(mínimo 2)</span>
</label>
<ProductSelector
:exclude-ids="selectedProducts.map(item => item.product.id)"
@select="handleProductSelect"
/>
<div v-if="selectedProducts.length > 0" class="mt-2 space-y-2">
<div
v-for="(item, index) in selectedProducts"
:key="item.product.id"
class="flex items-center gap-3 p-3 bg-gray-50 dark:bg-gray-700 rounded-lg"
>
<div class="flex-1 min-w-0">
<p class="text-sm font-medium text-gray-900 dark:text-gray-100 truncate">{{ item.product.name }}</p>
<p class="text-xs text-gray-500 dark:text-gray-400">SKU: {{ item.product.sku }}</p>
</div>
<div class="flex items-center gap-1 shrink-0">
<span class="text-xs text-gray-500">Cant:</span>
<input
:value="item.quantity"
@input="updateQuantity(index, $event.target.value)"
type="number"
min="1"
class="w-16 px-2 py-1 text-center text-sm border border-gray-300 dark:border-gray-600 rounded focus:ring-1 focus:ring-indigo-500 dark:bg-gray-800 dark:text-gray-100"
/>
</div>
<span class="text-sm font-semibold text-gray-700 dark:text-gray-300 min-w-20 text-right shrink-0">
{{ formatCurrency(item.product.price?.retail_price) }}
</span>
<button type="button" @click="removeProduct(index)" class="text-red-500 hover:text-red-700 shrink-0">
<GoogleIcon name="close" class="text-lg" />
</button>
</div>
</div>
<FormError :message="form.errors?.items" />
</div>
<!-- Precio de Venta -->
<div>
<label class="block text-xs font-semibold text-gray-700 dark:text-gray-300 uppercase mb-1.5">
PRECIO VENTA
</label>
<FormInput
v-model.number="form.retail_price"
@blur="calculateTax"
type="number"
step="0.01"
min="0"
placeholder="0.00"
/>
<FormError :message="form.errors?.retail_price" />
</div>
<!-- Impuesto -->
<div>
<label class="block text-xs font-semibold text-gray-700 dark:text-gray-300 uppercase mb-1.5">
IMPUESTO (%)
</label>
<FormInput
v-model.number="form.tax"
type="number"
step="0.01"
min="0"
placeholder="16.00"
/>
<FormError :message="form.errors?.tax" />
</div>
<!-- Recalcular precio -->
<div class="col-span-2">
<label class="flex items-center cursor-pointer">
<input
v-model="form.recalculate_price"
type="checkbox"
class="w-4 h-4 text-indigo-600 border-gray-300 rounded focus:ring-indigo-500 dark:border-gray-600 dark:bg-gray-800"
/>
<span class="ml-2 text-sm text-gray-700 dark:text-gray-300">
Recalcular precio desde componentes
</span>
</label>
</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">Actualizando...</span>
<span v-else>Actualizar</span>
</button>
</div>
</form>
</div>
</Modal>
</template>

View File

@ -0,0 +1,177 @@
<script setup>
import { ref, onMounted } from 'vue';
import { useSearcher } from '@Services/Api';
import { formatCurrency } from '@/utils/formatters';
import { can, apiTo } from './Module.js';
import SearcherHead from '@Holos/Searcher.vue';
import Table from '@Holos/Table.vue';
import GoogleIcon from '@Shared/GoogleIcon.vue';
import Create from './Create.vue';
import Edit from './Edit.vue';
import Delete from './Delete.vue';
/** Estado */
const bundles = ref({ data: [] });
const showCreateModal = ref(false);
const showEditModal = ref(false);
const showDeleteModal = ref(false);
const bundleToEdit = ref(null);
const bundleToDelete = ref(null);
/** Buscador */
const searcher = useSearcher({
url: apiTo('index'),
onSuccess: (data) => {
bundles.value = data.bundles || { data: [] };
},
onError: () => {
Notify.error('Error al cargar los paquetes');
}
});
/** Métodos */
const openEditModal = (bundle) => {
bundleToEdit.value = bundle;
showEditModal.value = true;
};
const openDeleteModal = (bundle) => {
bundleToDelete.value = bundle;
showDeleteModal.value = true;
};
const closeEditModal = () => {
showEditModal.value = false;
bundleToEdit.value = null;
};
const closeDeleteModal = () => {
showDeleteModal.value = false;
bundleToDelete.value = null;
};
const handleDelete = (bundleId) => {
window.axios.delete(apiTo('destroy', { bundle: bundleId })).then(() => {
Notify.success('Paquete eliminado exitosamente');
closeDeleteModal();
searcher.refresh();
}).catch(() => {
Notify.error('Error al eliminar el paquete');
});
};
/** Ciclos */
onMounted(() => {
searcher.search('');
});
</script>
<template>
<div>
<SearcherHead
title="Paquetes / Kits"
placeholder="Buscar por nombre, SKU o código de barras..."
@search="(q) => searcher.search(q)"
>
<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="showCreateModal = true"
>
<GoogleIcon name="add" class="text-xl" />
Nuevo Paquete
</button>
</SearcherHead>
<div class="pt-2 w-full">
<Table
:items="bundles"
:processing="searcher.processing"
@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">SKU</th>
<th class="px-6 py-3 text-center text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider">COMPONENTES</th>
<th class="px-6 py-3 text-center text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider">STOCK</th>
<th class="px-6 py-3 text-center text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider">PRECIO</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="bundle in items"
:key="bundle.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">{{ bundle.name }}</p>
<p v-if="bundle.barcode" class="text-xs text-gray-500 dark:text-gray-400">{{ bundle.barcode }}</p>
</td>
<td class="px-6 py-4 whitespace-nowrap text-center">
<span class="text-sm font-mono text-gray-600 dark:text-gray-400">{{ bundle.sku }}</span>
</td>
<td class="px-6 py-4 whitespace-nowrap text-center">
<span class="text-sm text-gray-700 dark:text-gray-300">{{ bundle.items?.length || 0 }} productos</span>
</td>
<td class="px-6 py-4 whitespace-nowrap text-center">
<span
class="px-2 py-1 text-xs font-semibold rounded-full"
:class="bundle.available_stock > 0
? 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400'
: 'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400'"
>
{{ bundle.available_stock }} kits
</span>
</td>
<td class="px-6 py-4 whitespace-nowrap text-center">
<p class="text-sm font-semibold text-gray-900 dark:text-gray-100">{{ formatCurrency(bundle.price?.retail_price) }}</p>
<p class="text-xs text-gray-500 dark:text-gray-400">Costo: {{ formatCurrency(bundle.total_cost) }}</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="openEditModal(bundle)"
class="text-indigo-600 hover:text-indigo-900 dark:text-indigo-400 dark:hover:text-indigo-300 transition-colors"
title="Editar"
>
<GoogleIcon name="edit" class="text-xl" />
</button>
<button
v-if="can('destroy')"
@click="openDeleteModal(bundle)"
class="text-red-600 hover:text-red-900 dark:text-red-400 dark:hover:text-red-300 transition-colors"
title="Eliminar"
>
<GoogleIcon name="delete" class="text-xl" />
</button>
</div>
</td>
</tr>
</template>
</Table>
</div>
<!-- Modales -->
<Create
:show="showCreateModal"
@close="showCreateModal = false"
@created="showCreateModal = false; searcher.refresh()"
/>
<Edit
:show="showEditModal"
:bundle="bundleToEdit"
@close="closeEditModal"
@updated="closeEditModal(); searcher.refresh()"
/>
<Delete
:show="showDeleteModal"
:bundle="bundleToDelete"
@close="closeDeleteModal"
@confirm="handleDelete"
/>
</div>
</template>

View File

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

View File

@ -119,6 +119,13 @@ const loadWarehouses = () => {
api.get(apiURL('almacenes'), {
onSuccess: (data) => {
warehouses.value = data.warehouses?.data || data.data || [];
if (props.movement?.movement_type === 'entry' && !form.destination_warehouse_id) {
const mainWarehouse = warehouses.value.find(w => w.is_main || w.is_principal);
if (mainWarehouse) {
form.destination_warehouse_id = mainWarehouse.id;
}
}
}
}),
api.get(apiURL('proveedores'), {
@ -398,6 +405,7 @@ watch(() => props.show, (isShown) => {
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
disabled
>
<option value="">Seleccionar almacén...</option>
<option v-for="wh in warehouses" :key="wh.id" :value="wh.id">

View File

@ -1,5 +1,5 @@
<script setup>
import { onMounted, onUnmounted, ref, computed } from 'vue';
import { onMounted, onUnmounted, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { useSearcher, apiURL } from '@Services/Api';
import { page } from '@Services/Page';
@ -26,6 +26,10 @@ const cart = useCart();
/** Estado */
const products = ref([]);
const productsMeta = ref(null);
const bundles = ref([]);
const bundlesMeta = ref(null);
const activeTab = ref('products'); // 'products' | 'bundles'
const showCheckoutModal = ref(false);
const searchQuery = ref('');
const processingPayment = ref(false);
@ -42,29 +46,79 @@ const searcher = useSearcher({
url: apiURL('inventario'),
onSuccess: (r) => {
products.value = r.products?.data || [];
productsMeta.value = r.products || null;
},
onError: () => {
products.value = [];
productsMeta.value = null;
window.Notify.error(t('error.loading'));
}
});
/** Computados */
const filteredProducts = computed(() => {
if (!searchQuery.value) return products.value;
const query = searchQuery.value.toLowerCase();
return products.value.filter(product => {
return (
product.name?.toLowerCase().includes(query) ||
product.sku?.toLowerCase().includes(query) ||
product.barcode?.toLowerCase().includes(query) ||
product.description?.toLowerCase().includes(query)
);
});
/** Buscador de bundles */
const bundleSearcher = useSearcher({
url: apiURL('bundles'),
onSuccess: (r) => {
bundles.value = r.bundles?.data || [];
bundlesMeta.value = r.bundles || null;
},
onError: () => {
bundles.value = [];
bundlesMeta.value = null;
window.Notify.error(t('error.loading'));
}
});
/** Métodos de búsqueda */
const doSearch = () => searcher.search(searchQuery.value);
const doBundleSearch = () => bundleSearcher.search(searchQuery.value);
/** Métodos */
const addBundleToCart = async (bundle) => {
try {
// Verificar stock actual del bundle antes de agregar
const response = await fetch(
apiURL(`bundles/${bundle.id}/check-stock?quantity=1`),
{
headers: {
'Authorization': `Bearer ${sessionStorage.token}`,
'Accept': 'application/json'
}
}
);
if (!response.ok) {
window.Notify.error('Error al verificar stock del paquete');
return;
}
const stockData = await response.json();
const data = stockData.data || stockData;
if (!data.has_stock || data.available_stock <= 0) {
window.Notify.error(`El paquete "${bundle.name}" no tiene stock disponible`);
return;
}
// Actualizar stock real del bundle antes de agregar al carrito
bundle.available_stock = data.available_stock;
// Verificar si ya está en el carrito y si alcanza el stock
const existingItem = cart.items.find(i => i.item_key === 'b:' + bundle.id);
if (existingItem && existingItem.quantity >= data.available_stock) {
window.Notify.warning(`Solo hay ${data.available_stock} kit(s) disponible(s)`);
return;
}
// Agregar bundle/kit al carrito
cart.addBundle(bundle);
window.Notify.success(`${bundle.name} agregado al carrito`);
} catch (error) {
console.error('Error verificando stock del bundle:', error);
window.Notify.error('Error al verificar disponibilidad del paquete');
}
};
const addToCart = async (product) => {
try {
const response = await serialService.getAvailableSerials(product.id);
@ -217,9 +271,51 @@ const handleCodeDetected = async (barcode) => {
};
const handleConfirmSale = async (paymentData) => {
if (processingPayment.value) return;
processingPayment.value = true;
try {
// Validar stock de bundles en el carrito antes de procesar
const bundleItems = cart.items.filter(item => item.is_bundle);
for (const item of bundleItems) {
const response = await fetch(
apiURL(`bundles/${item.bundle_id}/check-stock?quantity=${item.quantity}`),
{
headers: {
'Authorization': `Bearer ${sessionStorage.token}`,
'Accept': 'application/json'
}
}
);
if (!response.ok) {
window.Notify.error(`Error al verificar stock del paquete "${item.product_name}"`);
processingPayment.value = false;
return;
}
const stockData = await response.json();
const data = stockData.data || stockData;
if (!data.has_stock) {
// Encontrar qué componente falta
const insufficientComponents = (data.components_stock || [])
.filter(c => c.available_stock < c.required_quantity * item.quantity)
.map(c => c.product_name)
.join(', ');
window.Notify.error(
`Stock insuficiente para "${item.product_name}". ` +
`Disponibles: ${data.available_stock} kit(s), Solicitados: ${item.quantity}. ` +
(insufficientComponents ? `Componentes sin stock: ${insufficientComponents}` : '')
);
processingPayment.value = false;
return;
}
}
// Continuar con la venta normalmente...
// Establecer método de pago
cart.setPaymentMethod(paymentData.paymentMethod);
@ -230,14 +326,24 @@ const handleConfirmSale = async (paymentData) => {
tax: parseFloat(cart.tax.toFixed(2)),
total: parseFloat(cart.total.toFixed(2)), // El backend recalculará con descuento
payment_method: paymentData.paymentMethod,
items: cart.items.map(item => ({
items: cart.items.map(item => {
if (item.is_bundle) {
return {
type: 'bundle',
bundle_id: item.bundle_id,
quantity: item.quantity,
};
}
return {
type: 'product',
inventory_id: item.inventory_id,
product_name: item.product_name,
quantity: item.quantity,
unit_price: item.unit_price,
subtotal: parseFloat((item.quantity * item.unit_price).toFixed(2)),
serial_numbers: item.track_serials ? (item.serial_numbers || []) : undefined
}))
};
})
};
// Agregar client_number si se seleccionó un cliente
@ -293,14 +399,24 @@ const handleConfirmSale = async (paymentData) => {
// Mostrar notificación de que se está generando el ticket
window.Notify.info('Generando ticket...');
// Construir bundleMap desde los items del carrito (antes de limpiarlo)
const bundleMap = {};
cart.items.filter(i => i.is_bundle).forEach(item => {
bundleMap[item.bundle_id] = {
name: item.product_name,
sku: item.sku,
unit_price: item.unit_price
};
});
// Generar ticket PDF con descarga automática e impresión
await ticketService.generateSaleTicket(response, {
businessName: 'HIKVISION DISTRIBUIDOR',
autoDownload: true,
autoPrint: true // Abre diálogo de impresión automáticamente
autoPrint: false,
bundleMap
});
// Notificación de éxito
window.Notify.success('Ticket generado e impreso correctamente');
} catch (ticketError) {
console.error('Error generando ticket:', ticketError);
@ -314,7 +430,7 @@ const handleConfirmSale = async (paymentData) => {
payment_method: saleData.payment_method
};
// Limpiar carrito
// Limpiar carrito DESPUÉS de generar el ticket
cart.clear();
// Cerrar modal
@ -370,6 +486,7 @@ useBarcodeScanner({
/** Ciclo de vida */
onMounted(() => {
searcher.search();
bundleSearcher.search();
// Escuchar evento personalizado para abrir selector de seriales
window.addEventListener('open-serial-selector', handleOpenSerialSelector);
});
@ -377,6 +494,12 @@ onMounted(() => {
onUnmounted(() => {
window.removeEventListener('open-serial-selector', handleOpenSerialSelector);
});
watch(activeTab, (newTab) => {
if (newTab === 'bundles') {
doBundleSearch();
}
});
</script>
<template>
@ -437,25 +560,50 @@ onUnmounted(() => {
</div>
</div>
</div>
<!-- Buscador -->
<div class="mb-4">
<div class="relative">
<!-- Buscador + Tabs -->
<div class="mb-3">
<div class="relative mb-3">
<input
v-model="searchQuery"
type="text"
placeholder="Buscar productos por nombre, SKU o código..."
class="w-full px-4 py-3 pl-12 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 dark:text-gray-100"
placeholder="Buscar por nombre, SKU o código..."
class="w-full px-4 py-3 pl-12 pr-4 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 dark:text-gray-100"
@keyup.enter="activeTab === 'products' ? doSearch() : doBundleSearch()"
/>
<GoogleIcon
name="search"
class="absolute left-4 top-1/2 -translate-y-1/2 text-gray-400 text-xl"
class="absolute left-4 top-1/2 -translate-y-1/2 text-gray-400 text-xl cursor-pointer hover:text-gray-600"
@click="activeTab === 'products' ? doSearch() : doBundleSearch()"
/>
</div>
<!-- Tabs -->
<div class="flex gap-1">
<button
type="button"
@click="activeTab = 'products'"
class="flex-1 px-4 py-2 text-sm font-semibold rounded-lg transition-colors"
:class="activeTab === 'products'
? 'bg-indigo-600 text-white'
: 'bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-600'"
>
Productos
</button>
<button
type="button"
@click="activeTab = 'bundles'"
class="flex-1 px-4 py-2 text-sm font-semibold rounded-lg transition-colors"
:class="activeTab === 'bundles'
? 'bg-purple-600 text-white'
: 'bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-600'"
>
Paquetes / Kits
</button>
</div>
</div>
<!-- Grid de Productos -->
<div class="flex-1 overflow-y-auto">
<div v-if="filteredProducts.length === 0" class="flex items-center justify-center h-full">
<div v-if="activeTab === 'products'" class="flex-1 overflow-y-auto">
<div v-if="products.length === 0 && !searcher.processing" class="flex items-center justify-center h-full">
<div class="text-center">
<GoogleIcon name="inventory_2" class="text-6xl text-gray-300 dark:text-gray-600 mx-auto mb-4" />
<p class="text-gray-500 dark:text-gray-400">
@ -463,15 +611,84 @@ onUnmounted(() => {
</p>
</div>
</div>
<div v-else class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4 pb-4">
<ProductCard
v-for="product in filteredProducts"
v-for="product in products"
:key="product.id"
:product="product"
@add-to-cart="addToCart"
/>
</div>
<!-- Paginación productos -->
<div
v-if="productsMeta && productsMeta.last_page > 1"
class="flex items-center justify-between px-2 py-3 border-t border-gray-200 dark:border-gray-700 mt-2"
>
<button
:disabled="!productsMeta.prev_page_url || searcher.processing"
@click="searcher.pagination(productsMeta.prev_page_url)"
class="flex items-center gap-1 px-3 py-1.5 text-sm font-medium rounded-lg transition-colors disabled:opacity-40 disabled:cursor-not-allowed text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700"
>
<GoogleIcon name="chevron_left" class="text-lg" />
Anterior
</button>
<span class="text-xs text-gray-500 dark:text-gray-400">
Página {{ productsMeta.current_page }} de {{ productsMeta.last_page }}
</span>
<button
:disabled="!productsMeta.next_page_url || searcher.processing"
@click="searcher.pagination(productsMeta.next_page_url)"
class="flex items-center gap-1 px-3 py-1.5 text-sm font-medium rounded-lg transition-colors disabled:opacity-40 disabled:cursor-not-allowed text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700"
>
Siguiente
<GoogleIcon name="chevron_right" class="text-lg" />
</button>
</div>
</div>
<!-- Grid de Bundles -->
<div v-else class="flex-1 overflow-y-auto">
<div v-if="bundles.length === 0 && !bundleSearcher.processing" class="flex items-center justify-center h-full">
<div class="text-center">
<GoogleIcon name="inventory_2" class="text-6xl text-gray-300 dark:text-gray-600 mx-auto mb-4" />
<p class="text-gray-500 dark:text-gray-400">
{{ searchQuery ? 'No se encontraron paquetes' : 'No hay paquetes disponibles' }}
</p>
</div>
</div>
<div v-else class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4 pb-4">
<ProductCard
v-for="bundle in bundles"
:key="bundle.id"
:product="{ ...bundle, stock: bundle.available_stock, category: { name: 'Paquete/Kit' } }"
@add-to-cart="() => addBundleToCart(bundle)"
/>
</div>
<!-- Paginación bundles -->
<div
v-if="bundlesMeta && bundlesMeta.last_page > 1"
class="flex items-center justify-between px-2 py-3 border-t border-gray-200 dark:border-gray-700 mt-2"
>
<button
:disabled="!bundlesMeta.prev_page_url || bundleSearcher.processing"
@click="bundleSearcher.pagination(bundlesMeta.prev_page_url)"
class="flex items-center gap-1 px-3 py-1.5 text-sm font-medium rounded-lg transition-colors disabled:opacity-40 disabled:cursor-not-allowed text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700"
>
<GoogleIcon name="chevron_left" class="text-lg" />
Anterior
</button>
<span class="text-xs text-gray-500 dark:text-gray-400">
Página {{ bundlesMeta.current_page }} de {{ bundlesMeta.last_page }}
</span>
<button
:disabled="!bundlesMeta.next_page_url || bundleSearcher.processing"
@click="bundleSearcher.pagination(bundlesMeta.next_page_url)"
class="flex items-center gap-1 px-3 py-1.5 text-sm font-medium rounded-lg transition-colors disabled:opacity-40 disabled:cursor-not-allowed text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700"
>
Siguiente
<GoogleIcon name="chevron_right" class="text-lg" />
</button>
</div>
</div>
</div>
@ -504,7 +721,7 @@ onUnmounted(() => {
<CartItem
v-for="item in cart.items"
:key="item.inventory_id"
:key="item.item_key"
:item="item"
@update-quantity="cart.updateQuantity"
@remove="cart.removeProduct"
@ -517,11 +734,11 @@ onUnmounted(() => {
<div class="space-y-2">
<div class="flex justify-between text-sm text-gray-600 dark:text-gray-400">
<span>{{ $t('cart.subtotal') }}</span>
<span class="font-semibold">${{ formatCurrency(cart.subtotal) }}</span>
<span class="font-semibold">{{ formatCurrency(cart.subtotal) }}</span>
</div>
<div class="flex justify-between text-sm text-gray-600 dark:text-gray-400">
<span>IVA (16%)</span>
<span class="font-semibold">${{ formatCurrency(cart.tax) }}</span>
<span class="font-semibold">{{ formatCurrency(cart.tax) }}</span>
</div>
<div class="pt-2 border-t border-gray-200 dark:border-gray-700">
<div class="flex justify-between items-center">

View File

@ -37,6 +37,11 @@ const router = createRouter({
name: 'pos.category.index',
component: () => import('@Pages/POS/Category/Index.vue')
},
{
path: 'bundles',
name: 'pos.bundles.index',
component: () => import('@Pages/POS/Bundles/Index.vue')
},
{
path: 'inventory',
name: 'pos.inventory.index',

View File

@ -1,6 +1,7 @@
import jsPDF from 'jspdf';
import QRCode from 'qrcode';
import { formatMoney, PAYMENT_METHODS } from '@/utils/formatters';
import { apiURL } from '@Services/Api';
import printService from '@Services/printService';
/**
@ -20,6 +21,53 @@ const ticketService = {
};
},
/**
* Obtener información de un bundle desde la API
* @param {Number} bundleId - ID del bundle
* @returns {Object|null}
*/
async fetchBundleInfo(bundleId) {
try {
const { data: result } = await window.axios.get(
apiURL(`bundles/${bundleId}`),
{
headers: {
'Authorization': `Bearer ${sessionStorage.token}`,
'Accept': 'application/json'
}
}
);
const bundle = result?.data?.model
|| result?.model
|| result?.data
|| result;
if (!bundle || !bundle.name) {
console.warn(`fetchBundleInfo: No se pudo extraer nombre del bundle ${bundleId}`);
return null;
}
// Intentar obtener el precio de múltiples campos posibles
const price = parseFloat(bundle.retail_price)
|| parseFloat(bundle.price?.retail_price)
|| parseFloat(bundle.prices?.retail_price)
|| parseFloat(bundle.bundle_price?.retail_price)
|| parseFloat(bundle.price)
|| 0;
return {
name: bundle.name,
sku: bundle.sku || null,
unit_price: price
};
} catch (e) {
console.error(`Error al obtener bundle ${bundleId}:`, e);
console.error('Response data:', e.response?.data);
}
return null;
},
/**
* Generar ticket de venta
* @param {Object} saleData - Datos de la venta
@ -31,7 +79,8 @@ const ticketService = {
const {
businessName = 'HIKVISION DISTRIBUIDOR',
autoDownload = true,
autoPrint = false
autoPrint = false,
bundleMap = {}
} = options;
// Detectar ubicación del usuario
@ -150,12 +199,109 @@ const ticketService = {
doc.line(leftMargin, yPosition, rightMargin, yPosition);
yPosition += 4;
// Iterar sobre los productos
// Separar details: agrupar los de bundle por bundle_sale_group, dejar individuales aparte
const rawDetails = saleData.details || saleData.items || [];
const bundleGroupMap = {};
const regularItems = [];
rawDetails.forEach(item => {
if (item.bundle_id) {
const groupKey = item.bundle_sale_group || `bundle-${item.bundle_id}`;
if (!bundleGroupMap[groupKey]) {
bundleGroupMap[groupKey] = {
bundle_id: item.bundle_id,
components: [],
total_subtotal: 0,
serials: []
};
}
bundleGroupMap[groupKey].components.push(item);
const itemSubtotal = parseFloat(item.subtotal)
|| (parseFloat(item.unit_price) * parseFloat(item.quantity))
|| 0;
bundleGroupMap[groupKey].total_subtotal += itemSubtotal;
const itemSerials = item.serial_numbers || item.serials || [];
itemSerials.forEach(s => bundleGroupMap[groupKey].serials.push(s));
} else {
regularItems.push(item);
}
});
// Obtener IDs únicos de bundles que NO están en bundleMap
const missingBundleIds = [...new Set(
Object.values(bundleGroupMap)
.map(d => d.bundle_id)
.filter(id => !bundleMap[id])
)];
// Fetch de bundles faltantes en paralelo
if (missingBundleIds.length > 0) {
const fetched = await Promise.all(
missingBundleIds.map(id => this.fetchBundleInfo(id))
);
missingBundleIds.forEach((id, i) => {
if (fetched[i]) {
bundleMap[id] = fetched[i];
}
});
}
// Construir lista final
const displayItems = [
...regularItems,
...Object.values(bundleGroupMap).map((data) => {
const info = bundleMap[data.bundle_id] || {};
const bundleName = info.name || `Paquete #${data.bundle_id}`;
// Calcular precio con fallbacks múltiples
let bundlePrice = 0;
// 1. Precio del bundleMap (viene del fetch o de Point.vue)
if (info.unit_price && parseFloat(info.unit_price) > 0) {
bundlePrice = parseFloat(info.unit_price);
}
// 2. Suma de subtotales de los componentes
else if (data.total_subtotal > 0) {
bundlePrice = data.total_subtotal;
}
// 3. Recalcular desde unit_price × quantity de cada componente
else {
bundlePrice = data.components.reduce((sum, c) => {
const price = parseFloat(c.unit_price || c.price || 0);
const qty = parseFloat(c.quantity || 1);
return sum + (price * qty);
}, 0);
}
// 4. Si aún es 0, usar el subtotal de la venta dividido
if (bundlePrice === 0) {
const saleSubtotal = parseFloat(saleData.subtotal || 0);
const regularSubtotal = regularItems.reduce((sum, i) => {
return sum + (parseFloat(i.unit_price || 0) * parseFloat(i.quantity || 1));
}, 0);
bundlePrice = saleSubtotal - regularSubtotal;
if (bundlePrice < 0) bundlePrice = 0;
}
console.log(`Bundle ${data.bundle_id}: nombre="${bundleName}", precio=${bundlePrice}`);
return {
product_name: bundleName,
sku: info.sku || '',
quantity: 1,
unit_price: bundlePrice,
serials: data.serials,
is_bundle: true
};
})
];
// Iterar sobre los items a mostrar
doc.setFont('helvetica', 'normal');
doc.setTextColor(...blackColor);
const items = saleData.details || saleData.items || [];
items.forEach((item) => {
displayItems.forEach((item) => {
// Nombre del producto
const productName = item.product_name || item.name || 'Producto';
const nameLines = doc.splitTextToSize(productName, 45);
@ -174,7 +320,7 @@ const ticketService = {
yPosition += 3;
}
// Cantidad y Precio unitario
// Cantidad y Precio
const quantity = item.quantity || 1;
const unitPrice = formatMoney(item.unit_price);
@ -188,7 +334,7 @@ const ticketService = {
yPosition += 4;
// Números de serie (si existen)
const serials = item.serial_numbers || item.serials || [];
const serials = item.serials || item.serial_numbers || [];
if (serials.length > 0) {
doc.setFontSize(6);
doc.setFont('helvetica', 'normal');

View File

@ -40,7 +40,8 @@ const useCart = defineStore('cart', {
actions: {
// Agregar producto al carrito
addProduct(product, serialConfig = null) {
const existingItem = this.items.find(item => item.inventory_id === product.id);
const key = 'p:' + product.id;
const existingItem = this.items.find(item => item.item_key === key);
if (existingItem) {
// Si ya existe y tiene seriales, abrir selector de nuevo
@ -62,7 +63,10 @@ const useCart = defineStore('cart', {
} else {
// Agregar nuevo item
this.items.push({
item_key: key,
inventory_id: product.id,
bundle_id: null,
is_bundle: false,
product_name: product.name,
sku: product.sku,
quantity: 1,
@ -80,9 +84,42 @@ const useCart = defineStore('cart', {
}
},
// Agregar bundle/kit al carrito
addBundle(bundle) {
const key = 'b:' + bundle.id;
const existingItem = this.items.find(item => item.item_key === key);
if (existingItem) {
if (existingItem.quantity < bundle.available_stock) {
existingItem.quantity++;
} else {
window.Notify.warning('No hay suficiente stock disponible');
}
} else {
this.items.push({
item_key: key,
bundle_id: bundle.id,
inventory_id: null,
is_bundle: true,
product_name: bundle.name,
sku: bundle.sku,
quantity: 1,
unit_price: parseFloat(bundle.price?.retail_price || 0),
tax_rate: parseFloat(bundle.price?.tax || 16),
max_stock: bundle.available_stock,
track_serials: false,
serial_numbers: [],
serial_selection_mode: null,
unit_of_measure: null,
allows_decimals: false,
});
}
},
// Agregar producto con seriales ya configurados
addProductWithSerials(product, quantity, serialConfig) {
const existingItem = this.items.find(item => item.inventory_id === product.id);
const key = 'p:' + product.id;
const existingItem = this.items.find(item => item.item_key === key);
const newSerials = serialConfig.serialNumbers || [];
if (existingItem) {
@ -94,7 +131,10 @@ const useCart = defineStore('cart', {
} else {
// Agregar nuevo item con seriales
this.items.push({
item_key: key,
inventory_id: product.id,
bundle_id: null,
is_bundle: false,
product_name: product.name,
sku: product.sku,
quantity: quantity,
@ -111,8 +151,8 @@ const useCart = defineStore('cart', {
},
// Actualizar seriales de un item
updateSerials(inventoryId, serialConfig) {
const item = this.items.find(i => i.inventory_id === inventoryId);
updateSerials(itemKey, serialConfig) {
const item = this.items.find(i => i.item_key === itemKey);
if (item) {
item.serial_numbers = serialConfig.serialNumbers || [];
item.serial_selection_mode = serialConfig.selectionMode;
@ -127,8 +167,8 @@ const useCart = defineStore('cart', {
},
// Verificar si un item necesita selección de seriales
needsSerialSelection(inventoryId) {
const item = this.items.find(i => i.inventory_id === inventoryId);
needsSerialSelection(itemKey) {
const item = this.items.find(i => i.item_key === itemKey);
if (!item || !item.track_serials) return false;
// Necesita selección si es manual y no tiene suficientes seriales
if (item.serial_selection_mode === 'manual') {
@ -137,15 +177,15 @@ const useCart = defineStore('cart', {
return false;
},
// Actualizar cantidad de un item
updateQuantity(inventoryId, quantity) {
const item = this.items.find(i => i.inventory_id === inventoryId);
// Actualizar cantidad de un item (por item_key)
updateQuantity(itemKey, quantity) {
const item = this.items.find(i => i.item_key === itemKey);
if (item) {
// Convertir a número (puede ser decimal)
const numQuantity = parseFloat(quantity);
if (isNaN(numQuantity) || numQuantity <= 0) {
this.removeProduct(inventoryId);
this.removeProduct(itemKey);
} else if (numQuantity <= item.max_stock) {
// Si NO permite decimales, redondear a entero
item.quantity = item.allows_decimals ? numQuantity : Math.floor(numQuantity);
@ -155,9 +195,9 @@ const useCart = defineStore('cart', {
}
},
// Remover producto
removeProduct(inventoryId) {
const index = this.items.findIndex(i => i.inventory_id === inventoryId);
// Remover producto o bundle (por item_key)
removeProduct(itemKey) {
const index = this.items.findIndex(i => i.item_key === itemKey);
if (index !== -1) {
this.items.splice(index, 1);
}