This commit is contained in:
Lizandro Guarnizo
2026-04-10 23:04:28 -05:00
parent 64495816a9
commit c8bef9d9c4
8 changed files with 273 additions and 77 deletions
+33 -4
View File
@@ -90,7 +90,24 @@ $filas = $db->fetchAll(
$params $params
); );
// Resumen financiero // Enriquecer con parcialidades de pago
$domIds = array_column($filas, 'id');
$parciales = [];
if ($domIds) {
$in = implode(',', array_map('intval', $domIds));
$rows = $db->fetchAll(
"SELECT domicilio_id, modo, monto, notas FROM lab_domicilio_pagos WHERE domicilio_id IN ($in) ORDER BY id ASC"
);
foreach ($rows as $row) {
$parciales[(int)$row['domicilio_id']][] = $row;
}
}
foreach ($filas as &$f) {
$f['pagos_detalle'] = $parciales[(int)$f['id']] ?? [];
}
unset($f);
// Resumen financiero — sumar desde parcialidades cuando existen
$resumen = [ $resumen = [
'total' => count($filas), 'total' => count($filas),
'pagados' => 0, 'pagados' => 0,
@@ -104,9 +121,21 @@ foreach ($filas as $f) {
$est = $f['pago_estado'] ?? 'pendiente'; $est = $f['pago_estado'] ?? 'pendiente';
if ($est === 'pagado') { if ($est === 'pagado') {
$resumen['pagados']++; $resumen['pagados']++;
$resumen['monto_total'] += (float)$f['pago_monto']; if (!empty($f['pagos_detalle'])) {
$key = $f['pago_modo'] ?: 'sin_registrar'; // Sumar desde parcialidades
$resumen['por_modo'][$key] = ($resumen['por_modo'][$key] ?? 0.0) + (float)$f['pago_monto']; foreach ($f['pagos_detalle'] as $p) {
$monto = (float)$p['monto'];
$key = $p['modo'] ?: 'sin_registrar';
$resumen['monto_total'] += $monto;
$resumen['por_modo'][$key] = ($resumen['por_modo'][$key] ?? 0.0) + $monto;
}
} else {
// Fallback registro legacy
$monto = (float)$f['pago_monto'];
$key = $f['pago_modo'] ?: 'sin_registrar';
$resumen['monto_total'] += $monto;
$resumen['por_modo'][$key] = ($resumen['por_modo'][$key] ?? 0.0) + $monto;
}
} elseif ($est === 'exento') { } elseif ($est === 'exento') {
$resumen['exentos']++; $resumen['exentos']++;
} else { } else {
+8 -2
View File
@@ -32,12 +32,18 @@ try {
// Agenda del día con pleno detalle // Agenda del día con pleno detalle
$agenda = $enf->agenda($eid, $fecha); $agenda = $enf->agenda($eid, $fecha);
// Enriquecer con servicios extra (el resto ya viene de la query agenda()) // Enriquecer con servicios extra y pagos parciales
$db = Database::getInstance(); $db = Database::getInstance();
foreach ($agenda as &$item) { foreach ($agenda as &$item) {
$domId = (int)$item['domicilio_id'];
$item['servicios_extra'] = $db->fetchAll( $item['servicios_extra'] = $db->fetchAll(
"SELECT * FROM lab_servicios_extra WHERE domicilio_id = ? ORDER BY created_at ASC", "SELECT * FROM lab_servicios_extra WHERE domicilio_id = ? ORDER BY created_at ASC",
[(int)$item['domicilio_id']] [$domId]
);
// Parcialidades de pago (tabla nueva); vacío si no hay pagos registrados
$item['pagos_detalle'] = $db->fetchAll(
"SELECT modo, monto, notas FROM lab_domicilio_pagos WHERE domicilio_id = ? ORDER BY id ASC",
[$domId]
); );
} }
unset($item); unset($item);
+53 -18
View File
@@ -2,15 +2,17 @@
/** /**
* POST /api/lab/registrar_pago.php * POST /api/lab/registrar_pago.php
* Registra el pago de un domicilio por parte del cliente. * Registra el pago de un domicilio por parte del cliente.
* Solo puede llamarse desde el portal del enfermero o por un admin. * Soporta múltiples métodos de pago (pagos parciales).
* *
* Body JSON: * Body JSON:
* { * {
* domicilio_id: int, * domicilio_id: int,
* pago_estado: 'pagado' | 'exento' | 'pendiente', * pago_estado: 'pagado' | 'exento' | 'pendiente',
* pago_modo: 'efectivo' | 'transferencia' | 'otro', (requerido si pagado) * pagos: [ (requerido si pagado)
* pago_monto: float, (requerido si pagado) * { modo: 'efectivo'|'transferencia'|'otro', monto: float, notas: string|null },
* pago_notas: string|null * ...
* ],
* pago_notas: string|null (nota general, opcional)
* } * }
*/ */
require_once __DIR__ . '/_helpers.php'; require_once __DIR__ . '/_helpers.php';
@@ -22,33 +24,64 @@ try {
$domId = (int)($datos['domicilio_id'] ?? 0); $domId = (int)($datos['domicilio_id'] ?? 0);
$estado = $datos['pago_estado'] ?? ''; $estado = $datos['pago_estado'] ?? '';
if (!$domId) jsonError('domicilio_id requerido'); if (!$domId) jsonError('domicilio_id requerido');
if (!in_array($estado, ['pagado', 'exento', 'pendiente'], true)) if (!in_array($estado, ['pagado', 'exento', 'pendiente'], true))
jsonError('pago_estado inválido (pagado|exento|pendiente)'); jsonError('pago_estado inválido (pagado|exento|pendiente)');
$pagos = [];
if ($estado === 'pagado') { if ($estado === 'pagado') {
if (empty($datos['pago_modo']) || !in_array($datos['pago_modo'], ['efectivo','transferencia','otro'], true)) $raw = $datos['pagos'] ?? [];
jsonError('pago_modo inválido (efectivo|transferencia|otro)'); if (!is_array($raw) || empty($raw)) jsonError('Se requiere al menos un método de pago');
if (!isset($datos['pago_monto']) || (float)$datos['pago_monto'] <= 0) $modosValidos = ['efectivo', 'transferencia', 'otro'];
jsonError('pago_monto debe ser mayor a 0'); foreach ($raw as $i => $p) {
$modo = $p['modo'] ?? '';
$monto = (float)($p['monto'] ?? 0);
if (!in_array($modo, $modosValidos, true)) jsonError("Método de pago inválido en fila " . ($i + 1));
if ($monto <= 0) jsonError("El monto debe ser mayor a 0 en fila " . ($i + 1));
$pagos[] = [
'modo' => $modo,
'monto' => $monto,
'notas' => isset($p['notas']) && $p['notas'] !== '' ? trim($p['notas']) : null,
];
}
} }
$adminId = adminId(); $adminId = adminId();
$dom = new Domicilio(); $dom = new Domicilio();
$db = Database::getInstance();
// Verificar que existe // Verificar que existe
$exist = Database::getInstance()->fetch( $exist = $db->fetch('SELECT id, pago_estado FROM lab_domicilios WHERE id = ?', [$domId]);
'SELECT id, pago_estado FROM lab_domicilios WHERE id = ?', [$domId]
);
if (!$exist) jsonError("Domicilio #$domId no encontrado"); if (!$exist) jsonError("Domicilio #$domId no encontrado");
// ── Persistir parcialidades ──────────────────────────────────────────
// Eliminar registros previos y reinsertar (permite editar el pago)
$db->execute('DELETE FROM lab_domicilio_pagos WHERE domicilio_id = ?', [$domId]);
$totalMonto = 0;
$primerModo = null;
$notasGeneral = !empty($datos['pago_notas']) ? trim($datos['pago_notas']) : null;
if ($estado === 'pagado') {
foreach ($pagos as $p) {
$db->execute(
'INSERT INTO lab_domicilio_pagos (domicilio_id, modo, monto, notas) VALUES (?, ?, ?, ?)',
[$domId, $p['modo'], $p['monto'], $p['notas']]
);
$totalMonto += $p['monto'];
if ($primerModo === null) $primerModo = $p['modo'];
}
}
// ── Actualizar campos resumen en lab_domicilios ─────────────────────
// pago_modo guarda el primer método (o el único); pago_monto guarda el total.
$campos = [ $campos = [
'pago_estado' => $estado, 'pago_estado' => $estado,
'pago_modo' => ($estado === 'pagado') ? ($datos['pago_modo'] ?? null) : null, 'pago_modo' => ($estado === 'pagado') ? $primerModo : null,
'pago_monto' => ($estado === 'pagado') ? ((float)$datos['pago_monto']) : null, 'pago_monto' => ($estado === 'pagado') ? $totalMonto : null,
'pago_fecha' => ($estado === 'pagado') ? date('Y-m-d H:i:s') : null, 'pago_fecha' => ($estado === 'pagado') ? date('Y-m-d H:i:s') : null,
'pago_notas' => !empty($datos['pago_notas']) ? trim($datos['pago_notas']) : null, 'pago_notas' => $notasGeneral,
'pago_registrado_por' => $adminId, 'pago_registrado_por' => $adminId,
]; ];
$dom->actualizar($domId, $campos, $adminId); $dom->actualizar($domId, $campos, $adminId);
@@ -56,6 +89,8 @@ try {
jsonOk([ jsonOk([
'domicilio_id' => $domId, 'domicilio_id' => $domId,
'pago_estado' => $estado, 'pago_estado' => $estado,
'pago_total' => $totalMonto,
'pagos' => $pagos,
], 'Pago registrado correctamente'); ], 'Pago registrado correctamente');
} catch (InvalidArgumentException $e) { } catch (InvalidArgumentException $e) {
+6
View File
@@ -163,6 +163,12 @@ class Domicilio {
[$id] [$id]
); );
// Pagos parciales por método de pago
$dom['pagos_detalle'] = $this->db->fetchAll(
"SELECT modo, monto, notas FROM lab_domicilio_pagos WHERE domicilio_id = ? ORDER BY id ASC",
[$id]
);
// Vínculo: domicilio origen (este fue reprogramado desde otro) // Vínculo: domicilio origen (este fue reprogramado desde otro)
if (!empty($dom['domicilio_origen_id'])) { if (!empty($dom['domicilio_origen_id'])) {
$dom['origen_info'] = $this->db->fetch( $dom['origen_info'] = $this->db->fetch(
+104 -45
View File
@@ -422,28 +422,25 @@ $hoy = date('Y-m-d');
</div> </div>
</div> </div>
</div> </div>
<!-- Métodos de pago (múltiples) -->
<div id="pago-campos-pago"> <div id="pago-campos-pago">
<div class="mb-3"> <label class="form-label small fw-semibold">Métodos de pago <span class="text-danger">*</span></label>
<label class="form-label small fw-semibold">Modo de pago <span class="text-danger">*</span></label> <div id="pago-metodos-lista"></div>
<select class="form-select form-select-sm" id="pago-modo"> <button type="button" class="btn btn-outline-success btn-sm mt-1 w-100"
<option value="efectivo">💵 Efectivo</option> onclick="pagoModal.agregarMetodo()">
<option value="transferencia">📲 Transferencia</option> <i class="fas fa-plus me-1"></i>Agregar método de pago
<option value="otro">📋 Otro</option> </button>
</select> <div class="d-flex justify-content-between align-items-center mt-2 pt-2 border-top">
</div> <span class="small fw-semibold text-muted">Total:</span>
<div class="mb-3"> <span class="fw-bold text-success" id="pago-total-label">$0</span>
<label class="form-label small fw-semibold">Monto cobrado <span class="text-danger">*</span></label>
<div class="input-group input-group-sm">
<span class="input-group-text">$</span>
<input type="number" class="form-control" id="pago-monto"
placeholder="0" min="0" step="1000">
</div>
</div> </div>
</div> </div>
<div class="mb-2">
<label class="form-label small">Notas / referencia</label> <div class="mb-2 mt-2">
<label class="form-label small">Notas generales</label>
<input type="text" class="form-control form-control-sm" id="pago-notas" <input type="text" class="form-control form-control-sm" id="pago-notas"
placeholder="Ej: nequi ref #123456"> placeholder="Ej: incluye copago Nequi">
</div> </div>
</div> </div>
<div class="modal-footer py-2"> <div class="modal-footer py-2">
@@ -604,12 +601,18 @@ const portal = {
${item.valor_copago ? `<span>Copago: <strong>$${Number(item.valor_copago).toLocaleString('es-CO')}</strong></span>` : ''} ${item.valor_copago ? `<span>Copago: <strong>$${Number(item.valor_copago).toLocaleString('es-CO')}</strong></span>` : ''}
<span class="fw-bold text-success">Total: <strong>$${totalCobro.toLocaleString('es-CO')}</strong></span> <span class="fw-bold text-success">Total: <strong>$${totalCobro.toLocaleString('es-CO')}</strong></span>
</div> </div>
${item.pago_estado === 'pagado' && item.pago_modo ? ` ${item.pago_estado === 'pagado' && (item.pagos_detalle || []).length ? `
<div class="small text-success"> <div class="small text-success mt-1">
${item.pagos_detalle.map(p =>
`<div>${MODO_LABEL[p.modo] || p.modo}: <strong>$${Number(p.monto).toLocaleString('es-CO')}</strong>${p.notas ? ` · <em>${esc(p.notas)}</em>` : ''}</div>`
).join('')}
</div>` :
(item.pago_estado === 'pagado' && item.pago_modo ? `
<div class="small text-success mt-1">
${MODO_LABEL[item.pago_modo] || item.pago_modo} ${MODO_LABEL[item.pago_modo] || item.pago_modo}
${item.pago_monto ? ` · $${Number(item.pago_monto).toLocaleString('es-CO')}` : ''} ${item.pago_monto ? ` · $${Number(item.pago_monto).toLocaleString('es-CO')}` : ''}
${item.pago_notas ? ` · <em>${esc(item.pago_notas)}</em>` : ''} ${item.pago_notas ? ` · <em>${esc(item.pago_notas)}</em>` : ''}
</div>` : ''} </div>` : '')}
${item.pago_estado !== 'pagado' && item.pago_estado !== 'exento' ? ` ${item.pago_estado !== 'pagado' && item.pago_estado !== 'exento' ? `
<button class="btn btn-sm mt-2 text-white w-100" style="background:#065f46;font-size:.78rem" <button class="btn btn-sm mt-2 text-white w-100" style="background:#065f46;font-size:.78rem"
onclick="pagoModal.abrir(${item.domicilio_id}, ${totalCobro})"> onclick="pagoModal.abrir(${item.domicilio_id}, ${totalCobro})">
@@ -1898,23 +1901,29 @@ const formEnvio = {
}; };
// ══════════════════════════════════════════════════════════ // ══════════════════════════════════════════════════════════
// OBJETO: pagoModal — Gestión del modal de registro de pago // OBJETO: pagoModal — Gestión del modal de registro de pago (multi-método)
// ══════════════════════════════════════════════════════════ // ══════════════════════════════════════════════════════════
const pagoModal = { const pagoModal = {
_bsModal: null, _bsModal: null,
_filas: [], // [{ modo, monto, notas }]
// Abrir modal pre-cargado con domicilio y monto sugerido // Abrir modal pre-cargado con domicilio y monto sugerido
abrir(domId, totalSugerido = 0) { abrir(domId, totalSugerido = 0) {
document.getElementById('pago-dom-id').value = domId; document.getElementById('pago-dom-id').value = domId;
document.getElementById('pago-monto').value = totalSugerido > 0 ? totalSugerido : '';
document.getElementById('pago-notas').value = ''; document.getElementById('pago-notas').value = '';
document.getElementById('pago-modo').value = 'efectivo';
// Recuperar estado actual desde la agenda cargada // Recuperar estado y pagos actuales desde la agenda cargada
const item = (portal._agenda || []).find(d => d.domicilio_id == domId); const item = (portal._agenda || []).find(d => d.domicilio_id == domId);
const est = item ? (item.pago_estado || 'pendiente') : 'pendiente'; const est = item ? (item.pago_estado || 'pendiente') : 'pendiente';
document.querySelector(`input[name="pago-estado"][value="${est}"]`).checked = true; document.querySelector(`input[name="pago-estado"][value="${est}"]`).checked = true;
// Cargar filas existentes o inicializar con una vacía
const existentes = (item && item.pagos_detalle && item.pagos_detalle.length)
? item.pagos_detalle
: [{ modo: 'efectivo', monto: totalSugerido > 0 ? totalSugerido : '', notas: '' }];
this._filas = existentes.map(p => ({ modo: p.modo || 'efectivo', monto: p.monto || '', notas: p.notas || '' }));
this._renderFilas();
this.toggleModo(); this.toggleModo();
if (!this._bsModal) { if (!this._bsModal) {
@@ -1923,7 +1932,54 @@ const pagoModal = {
this._bsModal.show(); this._bsModal.show();
}, },
// Mostrar/ocultar campos de modo y monto según estado // Renderiza la lista de filas de métodos de pago
_renderFilas() {
const lista = document.getElementById('pago-metodos-lista');
if (!lista) return;
lista.innerHTML = this._filas.map((f, i) => `
<div class="d-flex gap-1 align-items-center mb-2" id="pago-fila-${i}">
<select class="form-select form-select-sm" style="max-width:130px"
onchange="pagoModal._filas[${i}].modo=this.value">
<option value="efectivo" ${f.modo==='efectivo' ? 'selected':''}>💵 Efectivo</option>
<option value="transferencia" ${f.modo==='transferencia' ? 'selected':''}>📲 Transferencia</option>
<option value="otro" ${f.modo==='otro' ? 'selected':''}>📋 Otro</option>
</select>
<div class="input-group input-group-sm" style="flex:1">
<span class="input-group-text">$</span>
<input type="number" class="form-control" placeholder="0" min="0" step="1000"
value="${f.monto !== '' ? f.monto : ''}"
oninput="pagoModal._filas[${i}].monto=parseFloat(this.value)||0;pagoModal._actualizarTotal()"
>
</div>
<input type="text" class="form-control form-control-sm" style="flex:1;min-width:60px"
placeholder="Ref." value="${esc(String(f.notas||''))}"
oninput="pagoModal._filas[${i}].notas=this.value">
${this._filas.length > 1
? `<button type="button" class="btn btn-sm btn-outline-danger px-2"
onclick="pagoModal.eliminarFila(${i})"><i class="fas fa-times"></i></button>`
: ''}
</div>`
).join('');
this._actualizarTotal();
},
_actualizarTotal() {
const sum = this._filas.reduce((s, f) => s + (parseFloat(f.monto) || 0), 0);
const lbl = document.getElementById('pago-total-label');
if (lbl) lbl.textContent = '$' + sum.toLocaleString('es-CO');
},
agregarMetodo() {
this._filas.push({ modo: 'efectivo', monto: '', notas: '' });
this._renderFilas();
},
eliminarFila(i) {
this._filas.splice(i, 1);
this._renderFilas();
},
// Mostrar/ocultar sección de métodos según estado
toggleModo() { toggleModo() {
const est = document.querySelector('input[name="pago-estado"]:checked')?.value; const est = document.querySelector('input[name="pago-estado"]:checked')?.value;
document.getElementById('pago-campos-pago').style.display = document.getElementById('pago-campos-pago').style.display =
@@ -1932,23 +1988,26 @@ const pagoModal = {
// Guardar pago vía API // Guardar pago vía API
async guardar() { async guardar() {
const domId = document.getElementById('pago-dom-id').value; const domId = document.getElementById('pago-dom-id').value;
const estado = document.querySelector('input[name="pago-estado"]:checked')?.value; const estado = document.querySelector('input[name="pago-estado"]:checked')?.value;
const modo = document.getElementById('pago-modo').value; const notas = document.getElementById('pago-notas').value.trim();
const monto = parseFloat(document.getElementById('pago-monto').value) || 0;
const notas = document.getElementById('pago-notas').value.trim();
if (!estado) { alert('Selecciona el estado del pago.'); return; } if (!estado) { alert('Selecciona el estado del pago.'); return; }
if (estado === 'pagado' && (!modo || monto <= 0)) {
alert('Para marcar como pagado debes ingresar el modo y el monto cobrado.'); let pagos = [];
return; if (estado === 'pagado') {
pagos = this._filas.map(f => ({
modo: f.modo,
monto: parseFloat(f.monto) || 0,
notas: f.notas || null,
}));
if (!pagos.length || pagos.some(p => p.monto <= 0)) {
alert('Ingresa al menos un método de pago con monto mayor a 0.');
return;
}
} }
const payload = { domicilio_id: domId, pago_estado: estado }; const payload = { domicilio_id: domId, pago_estado: estado, pagos };
if (estado === 'pagado') {
payload.pago_modo = modo;
payload.pago_monto = monto;
}
if (notas) payload.pago_notas = notas; if (notas) payload.pago_notas = notas;
try { try {
@@ -1960,16 +2019,16 @@ const pagoModal = {
const res = await r.json(); const res = await r.json();
if (!res.success) throw new Error(res.error || 'Error al guardar'); if (!res.success) throw new Error(res.error || 'Error al guardar');
// Actualizar item en la agenda local para evitar recarga completa // Actualizar item en la agenda local
const idx = (portal._agenda || []).findIndex(d => d.domicilio_id == domId); const idx = (portal._agenda || []).findIndex(d => d.domicilio_id == domId);
if (idx >= 0) { if (idx >= 0) {
portal._agenda[idx].pago_estado = estado; portal._agenda[idx].pago_estado = estado;
portal._agenda[idx].pago_modo = estado === 'pagado' ? modo : null; portal._agenda[idx].pago_monto = estado === 'pagado' ? res.pago_total : null;
portal._agenda[idx].pago_monto = estado === 'pagado' ? monto : null; portal._agenda[idx].pago_modo = estado === 'pagado' && pagos.length ? pagos[0].modo : null;
portal._agenda[idx].pago_notas = notas || null; portal._agenda[idx].pago_notas = notas || null;
portal._agenda[idx].pagos_detalle = estado === 'pagado' ? pagos : [];
} }
// Cerrar modal y re-renderizar
if (this._bsModal) this._bsModal.hide(); if (this._bsModal) this._bsModal.hide();
portal._renderLista(); portal._renderLista();
} catch(e) { } catch(e) {
+17 -6
View File
@@ -894,12 +894,23 @@ async function verDomicilio(id) {
${dom.valor_copago ? `<span class="small">Copago: <strong>$${Number(dom.valor_copago).toLocaleString('es-CO')}</strong></span>` : ''} ${dom.valor_copago ? `<span class="small">Copago: <strong>$${Number(dom.valor_copago).toLocaleString('es-CO')}</strong></span>` : ''}
${totalC > 0 ? `<span class="small fw-bold text-success">Total: $${totalC.toLocaleString('es-CO')}</span>` : ''} ${totalC > 0 ? `<span class="small fw-bold text-success">Total: $${totalC.toLocaleString('es-CO')}</span>` : ''}
</div> </div>
${dom.pago_estado === 'pagado' ? ` ${dom.pago_estado === 'pagado' ? (() => {
<div class="small mt-1 text-success"> // Usar pagos_detalle (multi-método) si existen
${MODO_LABEL[dom.pago_modo] || (dom.pago_modo||'')} const detalle = dom.pagos_detalle || [];
${dom.pago_monto ? ` · $${Number(dom.pago_monto).toLocaleString('es-CO')} cobrado` : ''} if (detalle.length) {
${dom.pago_notas ? ` · <em>${esc(dom.pago_notas)}</em>` : ''} return `<div class="small mt-1 text-success">
</div>` : ''} ${detalle.map(p =>
`<div>${MODO_LABEL[p.modo] || p.modo}: <strong>$${Number(p.monto).toLocaleString('es-CO')}</strong>${p.notas ? ` · <em>${esc(p.notas)}</em>` : ''}</div>`
).join('')}
</div>`;
}
// Fallback registro legacy (un solo método)
return `<div class="small mt-1 text-success">
${MODO_LABEL[dom.pago_modo] || (dom.pago_modo||'')}
${dom.pago_monto ? ` · $${Number(dom.pago_monto).toLocaleString('es-CO')} cobrado` : ''}
${dom.pago_notas ? ` · <em>${esc(dom.pago_notas)}</em>` : ''}
</div>`;
})() : ''}
</div> </div>
</div> </div>
</div>`; </div>`;
+18 -2
View File
@@ -812,6 +812,22 @@ async function cargarReportePagos() {
const cliente = f.tipo_cliente === 'seguro' && f.seguro_nombre const cliente = f.tipo_cliente === 'seguro' && f.seguro_nombre
? `Seguro: ${esc(f.seguro_nombre)}` ? `Seguro: ${esc(f.seguro_nombre)}`
: esc(f.tipo_cliente || 'particular'); : esc(f.tipo_cliente || 'particular');
// Columna modo: usar parcialidades si existen, sino legacy
const detalle = f.pagos_detalle || [];
const modoHtml = pagoEst === 'pagado'
? (detalle.length
? detalle.map(p => `${esc(MODO_LABEL[p.modo] || p.modo)}: $${Number(p.monto).toLocaleString('es-CO')}`).join('<br>')
: esc(MODO_LABEL[f.pago_modo] || f.pago_modo || '—'))
: '—';
// Monto total recibido
const montoTotal = pagoEst === 'pagado'
? (detalle.length
? detalle.reduce((s, p) => s + parseFloat(p.monto || 0), 0)
: parseFloat(f.pago_monto || 0))
: 0;
return `<tr class="${pagoEst === 'pagado' ? '' : 'opacity-75'}"> return `<tr class="${pagoEst === 'pagado' ? '' : 'opacity-75'}">
<td class="ps-3 text-muted">${f.id}</td> <td class="ps-3 text-muted">${f.id}</td>
<td class="text-nowrap">${esc(f.fecha_programada?.slice(0,10) || '—')}</td> <td class="text-nowrap">${esc(f.fecha_programada?.slice(0,10) || '—')}</td>
@@ -823,9 +839,9 @@ async function cargarReportePagos() {
<td class="text-center"> <td class="text-center">
<span class="badge bg-${PAGO_BADGE[pagoEst] || 'secondary'}">${esc(pagoEst)}</span> <span class="badge bg-${PAGO_BADGE[pagoEst] || 'secondary'}">${esc(pagoEst)}</span>
</td> </td>
<td class="text-center text-muted small">${esc(f.pago_modo) || '—'}</td> <td class="text-start small" style="white-space:pre-line">${modoHtml}</td>
<td class="text-end fw-semibold ${pagoEst === 'pagado' ? 'text-success' : 'text-muted'}"> <td class="text-end fw-semibold ${pagoEst === 'pagado' ? 'text-success' : 'text-muted'}">
${pagoEst === 'pagado' ? fmtMoney(f.pago_monto) : '—'} ${pagoEst === 'pagado' ? fmtMoney(montoTotal) : '—'}
</td> </td>
<td class="text-nowrap text-muted small">${f.pago_fecha ? esc(f.pago_fecha.slice(0,16)) : '—'}</td> <td class="text-nowrap text-muted small">${f.pago_fecha ? esc(f.pago_fecha.slice(0,16)) : '—'}</td>
<td> <td>
@@ -0,0 +1,34 @@
-- ============================================================
-- Migration: 20260410_lab_domicilio_pagos_parciales
-- Soporta múltiples métodos de pago por domicilio.
-- ============================================================
CREATE TABLE IF NOT EXISTS `lab_domicilio_pagos` (
`id` INT(11) NOT NULL AUTO_INCREMENT,
`domicilio_id` INT(11) NOT NULL COMMENT 'FK lab_domicilios.id',
`modo` ENUM('efectivo','transferencia','otro') NOT NULL DEFAULT 'efectivo'
COMMENT 'Método de pago',
`monto` DECIMAL(12,2) NOT NULL DEFAULT 0.00 COMMENT 'Monto recibido por este método',
`notas` VARCHAR(255) DEFAULT NULL COMMENT 'Referencia / observación del pago',
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
INDEX `idx_domicilio` (`domicilio_id`),
CONSTRAINT `fk_ldp_domicilio`
FOREIGN KEY (`domicilio_id`) REFERENCES `lab_domicilios` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
COMMENT='Pagos parciales por método de pago para cada domicilio';
-- Migrar registros existentes que ya tienen pago_modo + pago_monto registrados
INSERT INTO `lab_domicilio_pagos` (`domicilio_id`, `modo`, `monto`, `notas`, `created_at`)
SELECT id,
pago_modo,
pago_monto,
pago_notas,
COALESCE(pago_fecha, created_at)
FROM `lab_domicilios`
WHERE pago_estado = 'pagado'
AND pago_modo IS NOT NULL
AND pago_monto > 0
AND NOT EXISTS (
SELECT 1 FROM `lab_domicilio_pagos` ldp WHERE ldp.domicilio_id = `lab_domicilios`.id
);