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:
parent
1466cd2166
commit
32949fe13a
@ -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`
|
||||
@ -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">
|
||||
{{ item.product_name }}
|
||||
</h4>
|
||||
<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"
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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,
|
||||
|
||||
@ -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'), {
|
||||
@ -396,8 +403,9 @@ watch(() => props.show, (isShown) => {
|
||||
</label>
|
||||
<select
|
||||
v-model="form.destination_warehouse_id"
|
||||
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 text-sm focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
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">
|
||||
|
||||
@ -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 => ({
|
||||
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
|
||||
}))
|
||||
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">
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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');
|
||||
|
||||
@ -5,14 +5,14 @@ const useCart = defineStore('cart', {
|
||||
items: [], // Productos en el carrito
|
||||
paymentMethod: 'cash' // Método de pago por defecto
|
||||
}),
|
||||
|
||||
|
||||
getters: {
|
||||
// Total de items
|
||||
itemCount: (state) => state.items.reduce((sum, item) => sum + item.quantity, 0),
|
||||
|
||||
|
||||
// Subtotal (sin impuestos)
|
||||
subtotal: (state) => state.items.reduce((sum, item) => sum + (item.unit_price * item.quantity), 0),
|
||||
|
||||
|
||||
// Total de impuestos
|
||||
tax: (state) => {
|
||||
return state.items.reduce((sum, item) => {
|
||||
@ -21,7 +21,7 @@ const useCart = defineStore('cart', {
|
||||
return sum + itemTax;
|
||||
}, 0);
|
||||
},
|
||||
|
||||
|
||||
// Total final
|
||||
total: (state) => {
|
||||
const subtotal = state.items.reduce((sum, item) => sum + (item.unit_price * item.quantity), 0);
|
||||
@ -32,15 +32,16 @@ const useCart = defineStore('cart', {
|
||||
}, 0);
|
||||
return subtotal + tax;
|
||||
},
|
||||
|
||||
|
||||
// Verificar si el carrito está vacío
|
||||
isEmpty: (state) => state.items.length === 0
|
||||
},
|
||||
|
||||
|
||||
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') {
|
||||
@ -136,16 +176,16 @@ 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);
|
||||
@ -154,21 +194,21 @@ 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);
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
// Limpiar carrito
|
||||
clear() {
|
||||
this.items = [];
|
||||
this.paymentMethod = 'cash';
|
||||
},
|
||||
|
||||
|
||||
// Cambiar método de pago
|
||||
setPaymentMethod(method) {
|
||||
this.paymentMethod = method;
|
||||
@ -176,4 +216,4 @@ const useCart = defineStore('cart', {
|
||||
}
|
||||
});
|
||||
|
||||
export default useCart;
|
||||
export default useCart;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user