feat: contrato soporta múltiples servicios (many2many) con precio sugerido

This commit is contained in:
Lizandro Guarnizo
2026-04-30 22:15:31 -05:00
parent 1b0374204c
commit e751a9f894
5 changed files with 157 additions and 76 deletions
+52 -17
View File
@@ -28,7 +28,7 @@
<thead class="text-left border-b border-gray-200 bg-gray-50">
<tr>
<th class="py-2 px-3">Cliente</th>
<th class="py-2 px-3">Servicio</th>
<th class="py-2 px-3">Servicios</th>
<th class="py-2 px-3">Vencimiento</th>
<th class="py-2 px-3">Días</th>
<th class="py-2 px-3">Precio</th>
@@ -43,14 +43,19 @@
<div class="font-medium" x-text="d.cliente?.nombre||'—'"></div>
<div class="text-xs text-gray-400" x-text="d.cliente?.empresa||''"></div>
</td>
<td class="py-2 px-3" x-text="d.servicio?.nombre||'—'"></td>
<td class="py-2 px-3">
<template x-for="s in (d.servicios||[])" :key="s.ID">
<span class="inline-block text-xs bg-gray-100 rounded px-1.5 py-0.5 mr-1 mb-0.5" x-text="s.nombre"></span>
</template>
<span x-show="!d.servicios||d.servicios.length===0" class="text-gray-400"></span>
</td>
<td class="py-2 px-3" x-text="fmtDate(d.fecha_vencimiento)"></td>
<td class="py-2 px-3">
<span class="px-2 py-0.5 rounded text-xs font-semibold"
:class="d.urgencia==='rojo' ? 'bg-red-100 text-red-700' : d.urgencia==='amarillo' ? 'bg-yellow-100 text-yellow-700' : 'bg-green-100 text-green-700'"
x-text="d.dias_restantes <= 0 ? 'Vencido' : d.dias_restantes+' días'"></span>
</td>
<td class="py-2 px-3" x-text="(d.servicio?.moneda||'') +' '+ Number(d.precio_acordado).toFixed(2)"></td>
<td class="py-2 px-3" x-text="Number(d.precio_acordado).toFixed(2)"></td>
<td class="py-2 px-3">
<span class="px-2 py-0.5 rounded text-xs font-medium"
:class="{
@@ -111,14 +116,20 @@
</template>
</select>
</div>
<div class="col-span-2" x-show="!editModal">
<label class="text-xs font-medium text-gray-600">Servicio *</label>
<select x-model="form.servicio_id" required class="mt-1 w-full border rounded px-3 py-2 text-sm">
<option value="">Seleccionar...</option>
<div class="col-span-2">
<label class="text-xs font-medium text-gray-600">Servicios <span class="font-normal text-gray-400">(uno o varios)</span></label>
<div class="mt-1 border rounded max-h-36 overflow-y-auto divide-y text-sm">
<template x-for="s in servicios" :key="s.ID">
<option :value="s.ID" x-text="s.nombre"></option>
<label class="flex items-center gap-2 px-3 py-1.5 hover:bg-gray-50 cursor-pointer">
<input type="checkbox" :value="s.ID"
:checked="form.servicio_ids.includes(s.ID)"
@change="toggleServicio(s.ID)" />
<span class="flex-1" x-text="s.nombre"></span>
<span class="text-xs text-gray-400" x-text="s.moneda+' '+Number(s.precio).toFixed(2)"></span>
</label>
</template>
</select>
<div x-show="servicios.length===0" class="text-xs text-gray-400 px-3 py-2">Sin servicios disponibles</div>
</div>
</div>
<div>
<label class="text-xs font-medium text-gray-600">Fecha inicio *</label>
@@ -131,6 +142,9 @@
<div>
<label class="text-xs font-medium text-gray-600">Precio acordado</label>
<input x-model="form.precio_acordado" type="number" step="0.01" class="mt-1 w-full border rounded px-3 py-2 text-sm" />
<p class="text-xs text-gray-400 mt-0.5" x-show="precioSugerido > 0">
Sugerido: <span class="font-medium text-gray-600" x-text="precioSugerido.toFixed(2)"></span>
</p>
</div>
<div x-show="editModal">
<label class="text-xs font-medium text-gray-600">Estado</label>
@@ -188,7 +202,7 @@ document.addEventListener('alpine:init', () => {
addModal: false, editModal: false, deleteModal: false,
clientes: [], servicios: [],
selectedId: null,
form: { cliente_id:'', servicio_id:'', fecha_inicio:'', fecha_vencimiento:'', precio_acordado:0, estado:'activo', auto_renovar:false, notas:'' },
form: { cliente_id:'', servicio_ids:[], fecha_inicio:'', fecha_vencimiento:'', precio_acordado:0, estado:'activo', auto_renovar:false, notas:'' },
toast: { show: false, msg: '', type: 'ok' },
async init() {
@@ -205,16 +219,37 @@ document.addEventListener('alpine:init', () => {
},
async loadSelects() {
const [c, s] = await Promise.all([
axios.get('/app/api/clientes/select'),
axios.get('/app/api/servicios/select'),
]);
this.clientes = c.data.registros || [];
this.servicios = s.data.registros || [];
try {
const [c, s] = await Promise.all([
axios.get('/app/api/clientes/select'),
axios.get('/app/api/servicios/select'),
]);
this.clientes = Array.isArray(c.data) ? c.data : (c.data.registros || []);
this.servicios = Array.isArray(s.data) ? s.data : (s.data.registros || []);
} catch(e) {
this.showToast('Error cargando listas: ' + (e.response?.data?.error || e.message), 'error');
}
},
get precioSugerido() {
return this.servicios
.filter(s => (this.form.servicio_ids || []).includes(s.ID))
.reduce((acc, s) => acc + (parseFloat(s.precio) || 0), 0);
},
toggleServicio(id) {
const idx = (this.form.servicio_ids || []).indexOf(id);
if (idx === -1) {
this.form.servicio_ids.push(id);
} else {
this.form.servicio_ids.splice(idx, 1);
}
this.form.precio_acordado = this.precioSugerido;
},
openEdit(d) {
this.form = {
cliente_id: d.cliente_id,
servicio_ids: (d.servicios || []).map(s => s.ID),
fecha_inicio: d.fecha_inicio ? d.fecha_inicio.substring(0,10) : '',
fecha_vencimiento: d.fecha_vencimiento ? d.fecha_vencimiento.substring(0,10) : '',
precio_acordado: d.precio_acordado,
@@ -229,7 +264,7 @@ document.addEventListener('alpine:init', () => {
closeModals() {
this.addModal = this.editModal = this.deleteModal = false;
this.selectedId = null;
this.form = { cliente_id:'', servicio_id:'', fecha_inicio:'', fecha_vencimiento:'', precio_acordado:0, estado:'activo', auto_renovar:false, notas:'' };
this.form = { cliente_id:'', servicio_ids:[], fecha_inicio:'', fecha_vencimiento:'', precio_acordado:0, estado:'activo', auto_renovar:false, notas:'' };
},
async save() {