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) {
|
if (canIncrement.value) {
|
||||||
const incrementValue = props.item.allows_decimals ? 1 : 1;
|
const incrementValue = props.item.allows_decimals ? 1 : 1;
|
||||||
const newQuantity = props.item.quantity + incrementValue;
|
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) {
|
if (props.item.quantity > minValue) {
|
||||||
const newQuantity = Math.max(minValue, props.item.quantity - decrementValue);
|
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 {
|
} else {
|
||||||
emit('remove', props.item.inventory_id);
|
emit('remove', props.item.item_key);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -85,12 +85,12 @@ const handleQuantityInput = (event) => {
|
|||||||
const numValue = parseFloat(value);
|
const numValue = parseFloat(value);
|
||||||
|
|
||||||
if (!isNaN(numValue) && numValue > 0) {
|
if (!isNaN(numValue) && numValue > 0) {
|
||||||
emit('update-quantity', props.item.inventory_id, numValue);
|
emit('update-quantity', props.item.item_key, numValue);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const remove = () => {
|
const remove = () => {
|
||||||
emit('remove', props.item.inventory_id);
|
emit('remove', props.item.item_key);
|
||||||
};
|
};
|
||||||
</script>
|
</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">
|
<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 -->
|
<!-- Fila principal: Nombre y botón eliminar -->
|
||||||
<div class="flex items-start justify-between mb-2">
|
<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">
|
||||||
{{ item.product_name }}
|
<span
|
||||||
</h4>
|
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
|
<button
|
||||||
type="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"
|
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',
|
title: 'Punto de Venta',
|
||||||
subtitle: 'Gestión de ventas y caja',
|
subtitle: 'Gestión de ventas y caja',
|
||||||
category: 'Categorías',
|
category: 'Categorías',
|
||||||
|
bundles: 'Paquetes',
|
||||||
inventory: 'Productos',
|
inventory: 'Productos',
|
||||||
prices: 'Precios',
|
prices: 'Precios',
|
||||||
cashRegister: 'Caja',
|
cashRegister: 'Caja',
|
||||||
|
|||||||
@ -53,6 +53,12 @@ onMounted(() => {
|
|||||||
name="pos.inventory"
|
name="pos.inventory"
|
||||||
to="pos.inventory.index"
|
to="pos.inventory.index"
|
||||||
/>
|
/>
|
||||||
|
<SubLink
|
||||||
|
v-if="hasPermission('inventario.index')"
|
||||||
|
icon="stack"
|
||||||
|
name="pos.bundles"
|
||||||
|
to="pos.bundles.index"
|
||||||
|
/>
|
||||||
<SubLink
|
<SubLink
|
||||||
icon="accessibility"
|
icon="accessibility"
|
||||||
name="pos.clients"
|
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';
|
import { hasPermission } from '@Plugins/RolePermission.js';
|
||||||
|
|
||||||
// Ruta API
|
// Ruta API para bundles
|
||||||
const apiTo = (name, params = {}) => route(`inventario.${name}`, params)
|
const apiTo = (name, params = {}) => route(`bundles.${name}`, params)
|
||||||
|
|
||||||
// Ruta visual
|
// Ruta visual para bundles
|
||||||
const viewTo = ({ name = '', params = {}, query = {} }) => ({ name: `pos.inventory.${name}`, params, query })
|
const viewTo = ({ name = '', params = {}, query = {} }) => ({ name: `pos.bundles.${name}`, params, query })
|
||||||
|
|
||||||
// Determina si un usuario puede hacer algo en base a los permisos
|
// Usa permisos de inventario (no existen permisos específicos para bundles)
|
||||||
const can = (permission) => hasPermission(`inventario.${permission}`)
|
const can = (permission) => hasPermission(`bundles.${permission}`)
|
||||||
|
|
||||||
export {
|
export {
|
||||||
can,
|
can,
|
||||||
|
|||||||
@ -119,6 +119,13 @@ const loadWarehouses = () => {
|
|||||||
api.get(apiURL('almacenes'), {
|
api.get(apiURL('almacenes'), {
|
||||||
onSuccess: (data) => {
|
onSuccess: (data) => {
|
||||||
warehouses.value = data.warehouses?.data || data.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'), {
|
api.get(apiURL('proveedores'), {
|
||||||
@ -396,8 +403,9 @@ watch(() => props.show, (isShown) => {
|
|||||||
</label>
|
</label>
|
||||||
<select
|
<select
|
||||||
v-model="form.destination_warehouse_id"
|
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
|
required
|
||||||
|
disabled
|
||||||
>
|
>
|
||||||
<option value="">Seleccionar almacén...</option>
|
<option value="">Seleccionar almacén...</option>
|
||||||
<option v-for="wh in warehouses" :key="wh.id" :value="wh.id">
|
<option v-for="wh in warehouses" :key="wh.id" :value="wh.id">
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { onMounted, onUnmounted, ref, computed } from 'vue';
|
import { onMounted, onUnmounted, ref, watch } from 'vue';
|
||||||
import { useI18n } from 'vue-i18n';
|
import { useI18n } from 'vue-i18n';
|
||||||
import { useSearcher, apiURL } from '@Services/Api';
|
import { useSearcher, apiURL } from '@Services/Api';
|
||||||
import { page } from '@Services/Page';
|
import { page } from '@Services/Page';
|
||||||
@ -26,6 +26,10 @@ const cart = useCart();
|
|||||||
|
|
||||||
/** Estado */
|
/** Estado */
|
||||||
const products = ref([]);
|
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 showCheckoutModal = ref(false);
|
||||||
const searchQuery = ref('');
|
const searchQuery = ref('');
|
||||||
const processingPayment = ref(false);
|
const processingPayment = ref(false);
|
||||||
@ -42,29 +46,79 @@ const searcher = useSearcher({
|
|||||||
url: apiURL('inventario'),
|
url: apiURL('inventario'),
|
||||||
onSuccess: (r) => {
|
onSuccess: (r) => {
|
||||||
products.value = r.products?.data || [];
|
products.value = r.products?.data || [];
|
||||||
|
productsMeta.value = r.products || null;
|
||||||
},
|
},
|
||||||
onError: () => {
|
onError: () => {
|
||||||
products.value = [];
|
products.value = [];
|
||||||
|
productsMeta.value = null;
|
||||||
window.Notify.error(t('error.loading'));
|
window.Notify.error(t('error.loading'));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
/** Computados */
|
/** Buscador de bundles */
|
||||||
const filteredProducts = computed(() => {
|
const bundleSearcher = useSearcher({
|
||||||
if (!searchQuery.value) return products.value;
|
url: apiURL('bundles'),
|
||||||
|
onSuccess: (r) => {
|
||||||
const query = searchQuery.value.toLowerCase();
|
bundles.value = r.bundles?.data || [];
|
||||||
return products.value.filter(product => {
|
bundlesMeta.value = r.bundles || null;
|
||||||
return (
|
},
|
||||||
product.name?.toLowerCase().includes(query) ||
|
onError: () => {
|
||||||
product.sku?.toLowerCase().includes(query) ||
|
bundles.value = [];
|
||||||
product.barcode?.toLowerCase().includes(query) ||
|
bundlesMeta.value = null;
|
||||||
product.description?.toLowerCase().includes(query)
|
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 */
|
/** 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) => {
|
const addToCart = async (product) => {
|
||||||
try {
|
try {
|
||||||
const response = await serialService.getAvailableSerials(product.id);
|
const response = await serialService.getAvailableSerials(product.id);
|
||||||
@ -217,9 +271,51 @@ const handleCodeDetected = async (barcode) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleConfirmSale = async (paymentData) => {
|
const handleConfirmSale = async (paymentData) => {
|
||||||
|
if (processingPayment.value) return;
|
||||||
processingPayment.value = true;
|
processingPayment.value = true;
|
||||||
|
|
||||||
try {
|
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
|
// Establecer método de pago
|
||||||
cart.setPaymentMethod(paymentData.paymentMethod);
|
cart.setPaymentMethod(paymentData.paymentMethod);
|
||||||
|
|
||||||
@ -230,14 +326,24 @@ const handleConfirmSale = async (paymentData) => {
|
|||||||
tax: parseFloat(cart.tax.toFixed(2)),
|
tax: parseFloat(cart.tax.toFixed(2)),
|
||||||
total: parseFloat(cart.total.toFixed(2)), // El backend recalculará con descuento
|
total: parseFloat(cart.total.toFixed(2)), // El backend recalculará con descuento
|
||||||
payment_method: paymentData.paymentMethod,
|
payment_method: paymentData.paymentMethod,
|
||||||
items: cart.items.map(item => ({
|
items: cart.items.map(item => {
|
||||||
inventory_id: item.inventory_id,
|
if (item.is_bundle) {
|
||||||
product_name: item.product_name,
|
return {
|
||||||
quantity: item.quantity,
|
type: 'bundle',
|
||||||
unit_price: item.unit_price,
|
bundle_id: item.bundle_id,
|
||||||
subtotal: parseFloat((item.quantity * item.unit_price).toFixed(2)),
|
quantity: item.quantity,
|
||||||
serial_numbers: item.track_serials ? (item.serial_numbers || []) : undefined
|
};
|
||||||
}))
|
}
|
||||||
|
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
|
// 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
|
// Mostrar notificación de que se está generando el ticket
|
||||||
window.Notify.info('Generando 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
|
// Generar ticket PDF con descarga automática e impresión
|
||||||
await ticketService.generateSaleTicket(response, {
|
await ticketService.generateSaleTicket(response, {
|
||||||
businessName: 'HIKVISION DISTRIBUIDOR',
|
businessName: 'HIKVISION DISTRIBUIDOR',
|
||||||
autoDownload: true,
|
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');
|
window.Notify.success('Ticket generado e impreso correctamente');
|
||||||
} catch (ticketError) {
|
} catch (ticketError) {
|
||||||
console.error('Error generando ticket:', ticketError);
|
console.error('Error generando ticket:', ticketError);
|
||||||
@ -314,7 +430,7 @@ const handleConfirmSale = async (paymentData) => {
|
|||||||
payment_method: saleData.payment_method
|
payment_method: saleData.payment_method
|
||||||
};
|
};
|
||||||
|
|
||||||
// Limpiar carrito
|
// Limpiar carrito DESPUÉS de generar el ticket
|
||||||
cart.clear();
|
cart.clear();
|
||||||
|
|
||||||
// Cerrar modal
|
// Cerrar modal
|
||||||
@ -370,6 +486,7 @@ useBarcodeScanner({
|
|||||||
/** Ciclo de vida */
|
/** Ciclo de vida */
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
searcher.search();
|
searcher.search();
|
||||||
|
bundleSearcher.search();
|
||||||
// Escuchar evento personalizado para abrir selector de seriales
|
// Escuchar evento personalizado para abrir selector de seriales
|
||||||
window.addEventListener('open-serial-selector', handleOpenSerialSelector);
|
window.addEventListener('open-serial-selector', handleOpenSerialSelector);
|
||||||
});
|
});
|
||||||
@ -377,6 +494,12 @@ onMounted(() => {
|
|||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
window.removeEventListener('open-serial-selector', handleOpenSerialSelector);
|
window.removeEventListener('open-serial-selector', handleOpenSerialSelector);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
watch(activeTab, (newTab) => {
|
||||||
|
if (newTab === 'bundles') {
|
||||||
|
doBundleSearch();
|
||||||
|
}
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@ -437,25 +560,50 @@ onUnmounted(() => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<!-- Buscador -->
|
<!-- Buscador + Tabs -->
|
||||||
<div class="mb-4">
|
<div class="mb-3">
|
||||||
<div class="relative">
|
<div class="relative mb-3">
|
||||||
<input
|
<input
|
||||||
v-model="searchQuery"
|
v-model="searchQuery"
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Buscar productos por nombre, SKU o código..."
|
placeholder="Buscar 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"
|
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
|
<GoogleIcon
|
||||||
name="search"
|
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>
|
</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>
|
</div>
|
||||||
|
|
||||||
<!-- Grid de Productos -->
|
<!-- Grid de Productos -->
|
||||||
<div class="flex-1 overflow-y-auto">
|
<div v-if="activeTab === 'products'" class="flex-1 overflow-y-auto">
|
||||||
<div v-if="filteredProducts.length === 0" class="flex items-center justify-center h-full">
|
<div v-if="products.length === 0 && !searcher.processing" class="flex items-center justify-center h-full">
|
||||||
<div class="text-center">
|
<div class="text-center">
|
||||||
<GoogleIcon name="inventory_2" class="text-6xl text-gray-300 dark:text-gray-600 mx-auto mb-4" />
|
<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">
|
<p class="text-gray-500 dark:text-gray-400">
|
||||||
@ -463,15 +611,84 @@ onUnmounted(() => {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</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">
|
<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
|
<ProductCard
|
||||||
v-for="product in filteredProducts"
|
v-for="product in products"
|
||||||
:key="product.id"
|
:key="product.id"
|
||||||
:product="product"
|
:product="product"
|
||||||
@add-to-cart="addToCart"
|
@add-to-cart="addToCart"
|
||||||
/>
|
/>
|
||||||
</div>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -504,7 +721,7 @@ onUnmounted(() => {
|
|||||||
|
|
||||||
<CartItem
|
<CartItem
|
||||||
v-for="item in cart.items"
|
v-for="item in cart.items"
|
||||||
:key="item.inventory_id"
|
:key="item.item_key"
|
||||||
:item="item"
|
:item="item"
|
||||||
@update-quantity="cart.updateQuantity"
|
@update-quantity="cart.updateQuantity"
|
||||||
@remove="cart.removeProduct"
|
@remove="cart.removeProduct"
|
||||||
@ -517,11 +734,11 @@ onUnmounted(() => {
|
|||||||
<div class="space-y-2">
|
<div class="space-y-2">
|
||||||
<div class="flex justify-between text-sm text-gray-600 dark:text-gray-400">
|
<div class="flex justify-between text-sm text-gray-600 dark:text-gray-400">
|
||||||
<span>{{ $t('cart.subtotal') }}</span>
|
<span>{{ $t('cart.subtotal') }}</span>
|
||||||
<span class="font-semibold">${{ formatCurrency(cart.subtotal) }}</span>
|
<span class="font-semibold">{{ formatCurrency(cart.subtotal) }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex justify-between text-sm text-gray-600 dark:text-gray-400">
|
<div class="flex justify-between text-sm text-gray-600 dark:text-gray-400">
|
||||||
<span>IVA (16%)</span>
|
<span>IVA (16%)</span>
|
||||||
<span class="font-semibold">${{ formatCurrency(cart.tax) }}</span>
|
<span class="font-semibold">{{ formatCurrency(cart.tax) }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="pt-2 border-t border-gray-200 dark:border-gray-700">
|
<div class="pt-2 border-t border-gray-200 dark:border-gray-700">
|
||||||
<div class="flex justify-between items-center">
|
<div class="flex justify-between items-center">
|
||||||
|
|||||||
@ -37,6 +37,11 @@ const router = createRouter({
|
|||||||
name: 'pos.category.index',
|
name: 'pos.category.index',
|
||||||
component: () => import('@Pages/POS/Category/Index.vue')
|
component: () => import('@Pages/POS/Category/Index.vue')
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'bundles',
|
||||||
|
name: 'pos.bundles.index',
|
||||||
|
component: () => import('@Pages/POS/Bundles/Index.vue')
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: 'inventory',
|
path: 'inventory',
|
||||||
name: 'pos.inventory.index',
|
name: 'pos.inventory.index',
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
import jsPDF from 'jspdf';
|
import jsPDF from 'jspdf';
|
||||||
import QRCode from 'qrcode';
|
import QRCode from 'qrcode';
|
||||||
import { formatMoney, PAYMENT_METHODS } from '@/utils/formatters';
|
import { formatMoney, PAYMENT_METHODS } from '@/utils/formatters';
|
||||||
|
import { apiURL } from '@Services/Api';
|
||||||
import printService from '@Services/printService';
|
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
|
* Generar ticket de venta
|
||||||
* @param {Object} saleData - Datos de la venta
|
* @param {Object} saleData - Datos de la venta
|
||||||
@ -31,7 +79,8 @@ const ticketService = {
|
|||||||
const {
|
const {
|
||||||
businessName = 'HIKVISION DISTRIBUIDOR',
|
businessName = 'HIKVISION DISTRIBUIDOR',
|
||||||
autoDownload = true,
|
autoDownload = true,
|
||||||
autoPrint = false
|
autoPrint = false,
|
||||||
|
bundleMap = {}
|
||||||
} = options;
|
} = options;
|
||||||
|
|
||||||
// Detectar ubicación del usuario
|
// Detectar ubicación del usuario
|
||||||
@ -150,12 +199,109 @@ const ticketService = {
|
|||||||
doc.line(leftMargin, yPosition, rightMargin, yPosition);
|
doc.line(leftMargin, yPosition, rightMargin, yPosition);
|
||||||
yPosition += 4;
|
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.setFont('helvetica', 'normal');
|
||||||
doc.setTextColor(...blackColor);
|
doc.setTextColor(...blackColor);
|
||||||
const items = saleData.details || saleData.items || [];
|
|
||||||
|
|
||||||
items.forEach((item) => {
|
displayItems.forEach((item) => {
|
||||||
// Nombre del producto
|
// Nombre del producto
|
||||||
const productName = item.product_name || item.name || 'Producto';
|
const productName = item.product_name || item.name || 'Producto';
|
||||||
const nameLines = doc.splitTextToSize(productName, 45);
|
const nameLines = doc.splitTextToSize(productName, 45);
|
||||||
@ -174,7 +320,7 @@ const ticketService = {
|
|||||||
yPosition += 3;
|
yPosition += 3;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cantidad y Precio unitario
|
// Cantidad y Precio
|
||||||
const quantity = item.quantity || 1;
|
const quantity = item.quantity || 1;
|
||||||
const unitPrice = formatMoney(item.unit_price);
|
const unitPrice = formatMoney(item.unit_price);
|
||||||
|
|
||||||
@ -188,7 +334,7 @@ const ticketService = {
|
|||||||
yPosition += 4;
|
yPosition += 4;
|
||||||
|
|
||||||
// Números de serie (si existen)
|
// Números de serie (si existen)
|
||||||
const serials = item.serial_numbers || item.serials || [];
|
const serials = item.serials || item.serial_numbers || [];
|
||||||
if (serials.length > 0) {
|
if (serials.length > 0) {
|
||||||
doc.setFontSize(6);
|
doc.setFontSize(6);
|
||||||
doc.setFont('helvetica', 'normal');
|
doc.setFont('helvetica', 'normal');
|
||||||
|
|||||||
@ -5,14 +5,14 @@ const useCart = defineStore('cart', {
|
|||||||
items: [], // Productos en el carrito
|
items: [], // Productos en el carrito
|
||||||
paymentMethod: 'cash' // Método de pago por defecto
|
paymentMethod: 'cash' // Método de pago por defecto
|
||||||
}),
|
}),
|
||||||
|
|
||||||
getters: {
|
getters: {
|
||||||
// Total de items
|
// Total de items
|
||||||
itemCount: (state) => state.items.reduce((sum, item) => sum + item.quantity, 0),
|
itemCount: (state) => state.items.reduce((sum, item) => sum + item.quantity, 0),
|
||||||
|
|
||||||
// Subtotal (sin impuestos)
|
// Subtotal (sin impuestos)
|
||||||
subtotal: (state) => state.items.reduce((sum, item) => sum + (item.unit_price * item.quantity), 0),
|
subtotal: (state) => state.items.reduce((sum, item) => sum + (item.unit_price * item.quantity), 0),
|
||||||
|
|
||||||
// Total de impuestos
|
// Total de impuestos
|
||||||
tax: (state) => {
|
tax: (state) => {
|
||||||
return state.items.reduce((sum, item) => {
|
return state.items.reduce((sum, item) => {
|
||||||
@ -21,7 +21,7 @@ const useCart = defineStore('cart', {
|
|||||||
return sum + itemTax;
|
return sum + itemTax;
|
||||||
}, 0);
|
}, 0);
|
||||||
},
|
},
|
||||||
|
|
||||||
// Total final
|
// Total final
|
||||||
total: (state) => {
|
total: (state) => {
|
||||||
const subtotal = state.items.reduce((sum, item) => sum + (item.unit_price * item.quantity), 0);
|
const subtotal = state.items.reduce((sum, item) => sum + (item.unit_price * item.quantity), 0);
|
||||||
@ -32,15 +32,16 @@ const useCart = defineStore('cart', {
|
|||||||
}, 0);
|
}, 0);
|
||||||
return subtotal + tax;
|
return subtotal + tax;
|
||||||
},
|
},
|
||||||
|
|
||||||
// Verificar si el carrito está vacío
|
// Verificar si el carrito está vacío
|
||||||
isEmpty: (state) => state.items.length === 0
|
isEmpty: (state) => state.items.length === 0
|
||||||
},
|
},
|
||||||
|
|
||||||
actions: {
|
actions: {
|
||||||
// Agregar producto al carrito
|
// Agregar producto al carrito
|
||||||
addProduct(product, serialConfig = null) {
|
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) {
|
if (existingItem) {
|
||||||
// Si ya existe y tiene seriales, abrir selector de nuevo
|
// Si ya existe y tiene seriales, abrir selector de nuevo
|
||||||
@ -62,7 +63,10 @@ const useCart = defineStore('cart', {
|
|||||||
} else {
|
} else {
|
||||||
// Agregar nuevo item
|
// Agregar nuevo item
|
||||||
this.items.push({
|
this.items.push({
|
||||||
|
item_key: key,
|
||||||
inventory_id: product.id,
|
inventory_id: product.id,
|
||||||
|
bundle_id: null,
|
||||||
|
is_bundle: false,
|
||||||
product_name: product.name,
|
product_name: product.name,
|
||||||
sku: product.sku,
|
sku: product.sku,
|
||||||
quantity: 1,
|
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
|
// Agregar producto con seriales ya configurados
|
||||||
addProductWithSerials(product, quantity, serialConfig) {
|
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 || [];
|
const newSerials = serialConfig.serialNumbers || [];
|
||||||
|
|
||||||
if (existingItem) {
|
if (existingItem) {
|
||||||
@ -94,7 +131,10 @@ const useCart = defineStore('cart', {
|
|||||||
} else {
|
} else {
|
||||||
// Agregar nuevo item con seriales
|
// Agregar nuevo item con seriales
|
||||||
this.items.push({
|
this.items.push({
|
||||||
|
item_key: key,
|
||||||
inventory_id: product.id,
|
inventory_id: product.id,
|
||||||
|
bundle_id: null,
|
||||||
|
is_bundle: false,
|
||||||
product_name: product.name,
|
product_name: product.name,
|
||||||
sku: product.sku,
|
sku: product.sku,
|
||||||
quantity: quantity,
|
quantity: quantity,
|
||||||
@ -111,8 +151,8 @@ const useCart = defineStore('cart', {
|
|||||||
},
|
},
|
||||||
|
|
||||||
// Actualizar seriales de un item
|
// Actualizar seriales de un item
|
||||||
updateSerials(inventoryId, serialConfig) {
|
updateSerials(itemKey, serialConfig) {
|
||||||
const item = this.items.find(i => i.inventory_id === inventoryId);
|
const item = this.items.find(i => i.item_key === itemKey);
|
||||||
if (item) {
|
if (item) {
|
||||||
item.serial_numbers = serialConfig.serialNumbers || [];
|
item.serial_numbers = serialConfig.serialNumbers || [];
|
||||||
item.serial_selection_mode = serialConfig.selectionMode;
|
item.serial_selection_mode = serialConfig.selectionMode;
|
||||||
@ -127,8 +167,8 @@ const useCart = defineStore('cart', {
|
|||||||
},
|
},
|
||||||
|
|
||||||
// Verificar si un item necesita selección de seriales
|
// Verificar si un item necesita selección de seriales
|
||||||
needsSerialSelection(inventoryId) {
|
needsSerialSelection(itemKey) {
|
||||||
const item = this.items.find(i => i.inventory_id === inventoryId);
|
const item = this.items.find(i => i.item_key === itemKey);
|
||||||
if (!item || !item.track_serials) return false;
|
if (!item || !item.track_serials) return false;
|
||||||
// Necesita selección si es manual y no tiene suficientes seriales
|
// Necesita selección si es manual y no tiene suficientes seriales
|
||||||
if (item.serial_selection_mode === 'manual') {
|
if (item.serial_selection_mode === 'manual') {
|
||||||
@ -136,16 +176,16 @@ const useCart = defineStore('cart', {
|
|||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
},
|
},
|
||||||
|
|
||||||
// Actualizar cantidad de un item
|
// Actualizar cantidad de un item (por item_key)
|
||||||
updateQuantity(inventoryId, quantity) {
|
updateQuantity(itemKey, quantity) {
|
||||||
const item = this.items.find(i => i.inventory_id === inventoryId);
|
const item = this.items.find(i => i.item_key === itemKey);
|
||||||
if (item) {
|
if (item) {
|
||||||
// Convertir a número (puede ser decimal)
|
// Convertir a número (puede ser decimal)
|
||||||
const numQuantity = parseFloat(quantity);
|
const numQuantity = parseFloat(quantity);
|
||||||
|
|
||||||
if (isNaN(numQuantity) || numQuantity <= 0) {
|
if (isNaN(numQuantity) || numQuantity <= 0) {
|
||||||
this.removeProduct(inventoryId);
|
this.removeProduct(itemKey);
|
||||||
} else if (numQuantity <= item.max_stock) {
|
} else if (numQuantity <= item.max_stock) {
|
||||||
// Si NO permite decimales, redondear a entero
|
// Si NO permite decimales, redondear a entero
|
||||||
item.quantity = item.allows_decimals ? numQuantity : Math.floor(numQuantity);
|
item.quantity = item.allows_decimals ? numQuantity : Math.floor(numQuantity);
|
||||||
@ -154,21 +194,21 @@ const useCart = defineStore('cart', {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
// Remover producto
|
// Remover producto o bundle (por item_key)
|
||||||
removeProduct(inventoryId) {
|
removeProduct(itemKey) {
|
||||||
const index = this.items.findIndex(i => i.inventory_id === inventoryId);
|
const index = this.items.findIndex(i => i.item_key === itemKey);
|
||||||
if (index !== -1) {
|
if (index !== -1) {
|
||||||
this.items.splice(index, 1);
|
this.items.splice(index, 1);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
// Limpiar carrito
|
// Limpiar carrito
|
||||||
clear() {
|
clear() {
|
||||||
this.items = [];
|
this.items = [];
|
||||||
this.paymentMethod = 'cash';
|
this.paymentMethod = 'cash';
|
||||||
},
|
},
|
||||||
|
|
||||||
// Cambiar método de pago
|
// Cambiar método de pago
|
||||||
setPaymentMethod(method) {
|
setPaymentMethod(method) {
|
||||||
this.paymentMethod = 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