Monedero configurable por servicio y artículo
- Campo genera_monedero (default true) en servicios e items de inventario - Puntos proporcionales a las líneas elegibles al liquidar la venta - Toggle con icono Wallet en Servicios e Inventario
This commit is contained in:
@@ -1,7 +1,7 @@
|
|||||||
import type { FC } from 'react';
|
import type { FC } from 'react';
|
||||||
import { useCallback, useEffect, useState } from 'react';
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
import { useSearchParams } from 'react-router-dom';
|
import { useSearchParams } from 'react-router-dom';
|
||||||
import { Boxes, Plus, AlertTriangle, PackageCheck, ArrowDownToLine, ArrowUpFromLine, History } from 'lucide-react';
|
import { Boxes, Plus, AlertTriangle, PackageCheck, ArrowDownToLine, ArrowUpFromLine, History, Wallet } from 'lucide-react';
|
||||||
import Layout from '../components/Layout';
|
import Layout from '../components/Layout';
|
||||||
import MovimientosPanel from '../components/inventario/MovimientosPanel';
|
import MovimientosPanel from '../components/inventario/MovimientosPanel';
|
||||||
import AlertasPanel from '../components/inventario/AlertasPanel';
|
import AlertasPanel from '../components/inventario/AlertasPanel';
|
||||||
@@ -153,6 +153,16 @@ const Inventario: FC = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const toggleMonedero = async (it: InventoryItem) => {
|
||||||
|
try {
|
||||||
|
await odooApi.updateInventoryItem(it.id, { genera_monedero: it.genera_monedero === false });
|
||||||
|
toast.success(it.genera_monedero === false ? 'El artículo ahora genera monedero' : 'El artículo ya no genera monedero');
|
||||||
|
load();
|
||||||
|
} catch {
|
||||||
|
toast.error('Error al actualizar monedero');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const lvl = summary.levels;
|
const lvl = summary.levels;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -276,6 +286,17 @@ const Inventario: FC = () => {
|
|||||||
<td className="px-4 py-3 text-theme-muted">{it.expiry_date || '—'}</td>
|
<td className="px-4 py-3 text-theme-muted">{it.expiry_date || '—'}</td>
|
||||||
<td className="px-4 py-3">
|
<td className="px-4 py-3">
|
||||||
<div className="flex justify-end gap-2">
|
<div className="flex justify-end gap-2">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => toggleMonedero(it)}
|
||||||
|
title={it.genera_monedero === false ? 'No genera monedero — clic para activar' : 'Genera monedero — clic para desactivar'}
|
||||||
|
>
|
||||||
|
<Wallet
|
||||||
|
size={14}
|
||||||
|
className={it.genera_monedero === false ? 'text-theme-muted opacity-50' : 'text-theme-accent'}
|
||||||
|
/>
|
||||||
|
</Button>
|
||||||
<Button size="sm" variant="outline" onClick={() => openHistory(it)}><History size={14} /></Button>
|
<Button size="sm" variant="outline" onClick={() => openHistory(it)}><History size={14} /></Button>
|
||||||
<Button size="sm" onClick={() => openAdjust(it)}>Ajustar</Button>
|
<Button size="sm" onClick={() => openAdjust(it)}>Ajustar</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { FC } from 'react';
|
import type { FC } from 'react';
|
||||||
import { useEffect, useState, useCallback } from 'react';
|
import { useEffect, useState, useCallback } from 'react';
|
||||||
import { Star, Edit2, Plus, Clock, Tag, Package, Search } from 'lucide-react';
|
import { Star, Wallet, Edit2, Plus, Clock, Tag, Package, Search } from 'lucide-react';
|
||||||
import Layout from '../components/Layout';
|
import Layout from '../components/Layout';
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
@@ -123,6 +123,17 @@ const Servicios: FC = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const toggleMonedero = async (s: Service) => {
|
||||||
|
try {
|
||||||
|
await odooApi.updateService(s.id, { genera_monedero: s.genera_monedero === false });
|
||||||
|
toast.success(s.genera_monedero === false ? 'El servicio ahora genera monedero' : 'El servicio ya no genera monedero');
|
||||||
|
await load();
|
||||||
|
} catch (err) {
|
||||||
|
toast.error('Error al actualizar monedero');
|
||||||
|
console.error(err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const openCreate = () => {
|
const openCreate = () => {
|
||||||
setEditing(null);
|
setEditing(null);
|
||||||
setForm(emptyForm);
|
setForm(emptyForm);
|
||||||
@@ -273,18 +284,31 @@ const Servicios: FC = () => {
|
|||||||
/>
|
/>
|
||||||
<h4 className="font-medium text-theme-heading pr-2 truncate">{s.name}</h4>
|
<h4 className="font-medium text-theme-heading pr-2 truncate">{s.name}</h4>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<div className="flex items-center shrink-0">
|
||||||
variant="ghost"
|
<Button
|
||||||
size="sm"
|
variant="ghost"
|
||||||
onClick={() => toggleFavorite(s)}
|
size="sm"
|
||||||
title={s.is_favorite ? 'Quitar favorito' : 'Marcar favorito'}
|
onClick={() => toggleMonedero(s)}
|
||||||
>
|
title={s.genera_monedero === false ? 'No genera monedero — clic para activar' : 'Genera monedero — clic para desactivar'}
|
||||||
<Star
|
>
|
||||||
size={18}
|
<Wallet
|
||||||
className={s.is_favorite ? 'text-amber-500' : 'text-theme-muted'}
|
size={18}
|
||||||
fill={s.is_favorite ? 'currentColor' : 'none'}
|
className={s.genera_monedero === false ? 'text-theme-muted opacity-50' : 'text-theme-accent'}
|
||||||
/>
|
/>
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => toggleFavorite(s)}
|
||||||
|
title={s.is_favorite ? 'Quitar favorito' : 'Marcar favorito'}
|
||||||
|
>
|
||||||
|
<Star
|
||||||
|
size={18}
|
||||||
|
className={s.is_favorite ? 'text-amber-500' : 'text-theme-muted'}
|
||||||
|
fill={s.is_favorite ? 'currentColor' : 'none'}
|
||||||
|
/>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-wrap items-center gap-2 text-xs text-theme-muted mb-4">
|
<div className="flex flex-wrap items-center gap-2 text-xs text-theme-muted mb-4">
|
||||||
<span className="inline-flex items-center bg-theme-bg rounded-md px-2 py-0.5">
|
<span className="inline-flex items-center bg-theme-bg rounded-md px-2 py-0.5">
|
||||||
|
|||||||
@@ -239,6 +239,7 @@ export interface Service {
|
|||||||
color: string;
|
color: string;
|
||||||
service_group: string;
|
service_group: string;
|
||||||
is_favorite: boolean;
|
is_favorite: boolean;
|
||||||
|
genera_monedero?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Doctor {
|
export interface Doctor {
|
||||||
@@ -383,6 +384,7 @@ export interface InventoryItem {
|
|||||||
expiry_date: string | null;
|
expiry_date: string | null;
|
||||||
last_count_date: string | null;
|
last_count_date: string | null;
|
||||||
active: boolean;
|
active: boolean;
|
||||||
|
genera_monedero?: boolean;
|
||||||
notes: string;
|
notes: string;
|
||||||
stock_level: InventoryLevel;
|
stock_level: InventoryLevel;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,6 +35,8 @@ class SkeenServicio(models.Model):
|
|||||||
color = fields.Char(string='Color', default='#1abc9c')
|
color = fields.Char(string='Color', default='#1abc9c')
|
||||||
service_group = fields.Char(string='Grupo')
|
service_group = fields.Char(string='Grupo')
|
||||||
is_favorite = fields.Boolean(string='Favorito', default=False)
|
is_favorite = fields.Boolean(string='Favorito', default=False)
|
||||||
|
genera_monedero = fields.Boolean(string='Genera monedero', default=True,
|
||||||
|
help='Si está activo, la venta de este servicio acumula puntos monedero.')
|
||||||
tag_ids = fields.Many2many('skeen.service.tag', string='Etiquetas')
|
tag_ids = fields.Many2many('skeen.service.tag', string='Etiquetas')
|
||||||
|
|
||||||
# Configuración
|
# Configuración
|
||||||
|
|||||||
@@ -120,6 +120,7 @@
|
|||||||
<field name="code"/>
|
<field name="code"/>
|
||||||
<field name="category"/>
|
<field name="category"/>
|
||||||
<field name="active"/>
|
<field name="active"/>
|
||||||
|
<field name="genera_monedero"/>
|
||||||
</group>
|
</group>
|
||||||
<group>
|
<group>
|
||||||
<field name="price"/>
|
<field name="price"/>
|
||||||
|
|||||||
@@ -28,6 +28,8 @@ class SkeenInventarioItem(models.Model):
|
|||||||
expiry_date = fields.Date(string='Caducidad')
|
expiry_date = fields.Date(string='Caducidad')
|
||||||
last_count_date = fields.Date(string='Último conteo físico')
|
last_count_date = fields.Date(string='Último conteo físico')
|
||||||
active = fields.Boolean(string='Activo', default=True)
|
active = fields.Boolean(string='Activo', default=True)
|
||||||
|
genera_monedero = fields.Boolean(string='Genera monedero', default=True,
|
||||||
|
help='Si está activo, la venta de este artículo acumula puntos monedero.')
|
||||||
notes = fields.Text(string='Notas')
|
notes = fields.Text(string='Notas')
|
||||||
|
|
||||||
move_ids = fields.One2many('skeen.inventario.move', 'item_id', string='Movimientos')
|
move_ids = fields.One2many('skeen.inventario.move', 'item_id', string='Movimientos')
|
||||||
|
|||||||
@@ -16,6 +16,7 @@
|
|||||||
<field name="qty_optimal"/>
|
<field name="qty_optimal"/>
|
||||||
<field name="cost"/>
|
<field name="cost"/>
|
||||||
<field name="price"/>
|
<field name="price"/>
|
||||||
|
<field name="genera_monedero"/>
|
||||||
<field name="inventory_value" sum="Total"/>
|
<field name="inventory_value" sum="Total"/>
|
||||||
<field name="stock_level" widget="badge"
|
<field name="stock_level" widget="badge"
|
||||||
decoration-danger="stock_level=='out'"
|
decoration-danger="stock_level=='out'"
|
||||||
@@ -41,6 +42,7 @@
|
|||||||
<field name="category"/>
|
<field name="category"/>
|
||||||
<field name="unit"/>
|
<field name="unit"/>
|
||||||
<field name="active"/>
|
<field name="active"/>
|
||||||
|
<field name="genera_monedero"/>
|
||||||
</group>
|
</group>
|
||||||
<group>
|
<group>
|
||||||
<field name="qty"/>
|
<field name="qty"/>
|
||||||
|
|||||||
@@ -87,11 +87,16 @@ class SkeenVenta(models.Model):
|
|||||||
'payment_state': 'paid',
|
'payment_state': 'paid',
|
||||||
'amount_paid': venta.total,
|
'amount_paid': venta.total,
|
||||||
})
|
})
|
||||||
# Acumular puntos
|
# Acumular puntos (proporcional a líneas que generan monedero)
|
||||||
monedero = self.env['skeen.monedero'].search([('partner_id', '=', venta.partner_id.id)], limit=1)
|
monedero = self.env['skeen.monedero'].search([('partner_id', '=', venta.partner_id.id)], limit=1)
|
||||||
if not monedero:
|
if not monedero:
|
||||||
monedero = self.env['skeen.monedero'].create({'partner_id': venta.partner_id.id})
|
monedero = self.env['skeen.monedero'].create({'partner_id': venta.partner_id.id})
|
||||||
points = int(venta.total / 10)
|
subtotal_total = sum(l.subtotal for l in venta.line_ids)
|
||||||
|
elegible = sum(
|
||||||
|
l.subtotal for l in venta.line_ids
|
||||||
|
if (l.service_id and l.service_id.genera_monedero) or (l.item_id and l.item_id.genera_monedero)
|
||||||
|
)
|
||||||
|
points = int(venta.total * (elegible / subtotal_total) / 10) if subtotal_total else 0
|
||||||
if points > 0:
|
if points > 0:
|
||||||
monedero.add_points(points, description=f'Venta {venta.name}', reference=venta.name)
|
monedero.add_points(points, description=f'Venta {venta.name}', reference=venta.name)
|
||||||
|
|
||||||
|
|||||||
@@ -1555,7 +1555,7 @@ class SkeenFrontendController(http.Controller):
|
|||||||
if not service.exists():
|
if not service.exists():
|
||||||
return json_response({'status': 'error', 'message': 'Servicio no encontrado'}, 404)
|
return json_response({'status': 'error', 'message': 'Servicio no encontrado'}, 404)
|
||||||
data = _parse_json_body()
|
data = _parse_json_body()
|
||||||
allowed = ['name', 'code', 'category', 'price', 'duration_min', 'package_price', 'package_sessions', 'package_notes', 'description', 'color', 'service_group', 'is_favorite', 'active']
|
allowed = ['name', 'code', 'category', 'price', 'duration_min', 'package_price', 'package_sessions', 'package_notes', 'description', 'color', 'service_group', 'is_favorite', 'genera_monedero', 'active']
|
||||||
vals = {k: data[k] for k in allowed if k in data}
|
vals = {k: data[k] for k in allowed if k in data}
|
||||||
Servicio = request.env['skeen.servicio'].sudo()
|
Servicio = request.env['skeen.servicio'].sudo()
|
||||||
if vals.get('name'):
|
if vals.get('name'):
|
||||||
@@ -1586,6 +1586,7 @@ class SkeenFrontendController(http.Controller):
|
|||||||
'color': s.color,
|
'color': s.color,
|
||||||
'service_group': s.service_group or '',
|
'service_group': s.service_group or '',
|
||||||
'is_favorite': s.is_favorite,
|
'is_favorite': s.is_favorite,
|
||||||
|
'genera_monedero': s.genera_monedero,
|
||||||
}
|
}
|
||||||
|
|
||||||
# ============================================================
|
# ============================================================
|
||||||
@@ -3290,6 +3291,7 @@ class SkeenFrontendController(http.Controller):
|
|||||||
'expiry_date': it.expiry_date.strftime('%Y-%m-%d') if it.expiry_date else None,
|
'expiry_date': it.expiry_date.strftime('%Y-%m-%d') if it.expiry_date else None,
|
||||||
'last_count_date': it.last_count_date.strftime('%Y-%m-%d') if it.last_count_date else None,
|
'last_count_date': it.last_count_date.strftime('%Y-%m-%d') if it.last_count_date else None,
|
||||||
'active': it.active,
|
'active': it.active,
|
||||||
|
'genera_monedero': it.genera_monedero,
|
||||||
'notes': it.notes or '',
|
'notes': it.notes or '',
|
||||||
'stock_level': it.stock_level,
|
'stock_level': it.stock_level,
|
||||||
}
|
}
|
||||||
@@ -3373,7 +3375,7 @@ class SkeenFrontendController(http.Controller):
|
|||||||
return json_response({'status': 'error', 'message': 'Item no encontrado'}, 404)
|
return json_response({'status': 'error', 'message': 'Item no encontrado'}, 404)
|
||||||
data = _parse_json_body()
|
data = _parse_json_body()
|
||||||
allowed = ['name', 'kind', 'sku', 'category', 'unit', 'qty_optimal', 'qty_min',
|
allowed = ['name', 'kind', 'sku', 'category', 'unit', 'qty_optimal', 'qty_min',
|
||||||
'cost', 'expiry_date', 'notes', 'active']
|
'cost', 'expiry_date', 'notes', 'active', 'genera_monedero']
|
||||||
vals = {}
|
vals = {}
|
||||||
for k in allowed:
|
for k in allowed:
|
||||||
if k in data:
|
if k in data:
|
||||||
|
|||||||
Reference in New Issue
Block a user