diff --git a/frontend/src/components/ReciboPrint.tsx b/frontend/src/components/ReciboPrint.tsx new file mode 100644 index 0000000..566816e --- /dev/null +++ b/frontend/src/components/ReciboPrint.tsx @@ -0,0 +1,150 @@ +import type { FC } from 'react'; +import { Printer } from 'lucide-react'; +import { Button, Modal } from './ui'; +import type { ReceiptData } from '../services/odoo'; + +// Recibo de pago imprimible. Sigue el patrón de impresión existente +// (TicketPrint / CortePrint / EstadoCuentaPrint): contenido en un +// div.print-recibo + window.print(); el CSS de @media print en index.css +// oculta el resto de la página. + +const METODO_LABELS: Record = { + cash: 'Efectivo', card: 'Tarjeta', transfer: 'Transferencia', + stripe: 'Stripe', mercadopago: 'MercadoPago', +}; + +const money = (v: number) => + '$' + (v ?? 0).toLocaleString('es-MX', { minimumFractionDigits: 2, maximumFractionDigits: 2 }); + +interface ClinicSettings { + name: string; + phone: string; + address: string; +} + +const clinicSettings = (): ClinicSettings => { + try { + const saved = localStorage.getItem('skeen_clinic_settings'); + if (saved) return JSON.parse(saved); + } catch { + // ignore + } + return { name: 'SKEEN Derma Experts', phone: '', address: '' }; +}; + +export const ReciboPrint: FC<{ receipt: ReceiptData | null; onClose: () => void }> = ({ receipt, onClose }) => { + if (!receipt) return null; + const clinic = clinicSettings(); + + return ( + +
+
+

{clinic.name}

+ {clinic.address &&

{clinic.address}

} + {clinic.phone &&

{clinic.phone}

} +

RECIBO DE PAGO

+

Folio {receipt.folio} · {receipt.date}

+
+ + + + + + + + {receipt.patient_code && ( + + + + + )} + {receipt.phone && ( + + + + + )} + + + + + + + + + +
Paciente{receipt.patient}
Expediente{receipt.patient_code}
Teléfono{receipt.phone}
Método de pago{METODO_LABELS[receipt.payment_method] || receipt.payment_method}
Registró{receipt.operator || '—'}
+ + {receipt.aplicado.length > 0 && ( + + + + + + + + + + + {receipt.aplicado.map((a) => ( + + + + + + + ))} + +
VentaFechaAplicadoSaldo venta
{a.name}{a.date || '—'}{money(a.applied)}{money(a.amount_due)}
+ )} + + + + + + + + + + + + + + + + {receipt.wallet_points > 0 && ( + + + + + )} + +
Adeudo anterior{money(receipt.adeudo_anterior)}
Total recibido{money(receipt.amount)}
Saldo restante 0 ? 'text-rose-700' : 'text-emerald-700'}`}> + {money(receipt.saldo_restante)} +
Puntos monedero{receipt.wallet_points} pts
+ +

+ {receipt.saldo_restante > 0 + ? 'El paciente mantiene saldo pendiente. Este recibo ampara el pago parcial recibido.' + : 'El adeudo queda liquidado. Gracias por su pago.'} +

+ +
+ _____________________
Recibió (caja)
+ _____________________
Paciente
+
+

Impreso el {new Date().toLocaleString('es-MX')}

+
+ +
+ +
+
+ ); +}; + +export default ReciboPrint; diff --git a/frontend/src/components/inventario/AlertasPanel.tsx b/frontend/src/components/inventario/AlertasPanel.tsx index ca854bc..a25f89a 100644 --- a/frontend/src/components/inventario/AlertasPanel.tsx +++ b/frontend/src/components/inventario/AlertasPanel.tsx @@ -1,8 +1,9 @@ import type { FC } from 'react'; import { useCallback, useEffect, useState } from 'react'; -import { CalendarX, ShoppingCart } from 'lucide-react'; -import { Card, Badge, EmptyState, Skeleton, toast } from '../ui'; +import { CalendarX, ShoppingCart, ArrowUpFromLine } from 'lucide-react'; +import { Card, Button, Badge, EmptyState, Skeleton, toast } from '../ui'; import { ExportButton } from '../ui/ExportButton'; +import MovimientoModal from './MovimientoModal'; import { exportToExcel } from '../../lib/exporter'; import { odooApi, type CaducidadRow, type SugerenciaRow } from '../../services/odoo'; import type { BadgeVariant } from '../ui/Badge'; @@ -22,6 +23,8 @@ const AlertasPanel: FC = () => { const [totalSugerencia, setTotalSugerencia] = useState({ items: 0, costo_total: 0 }); const [loading, setLoading] = useState(true); const [exporting, setExporting] = useState(false); + // Acción directa de compra/baja desde la alerta (item precargado) + const [movModal, setMovModal] = useState<{ tipo: 'compra' | 'baja'; itemId: number; qty: number } | null>(null); const load = useCallback(async () => { try { @@ -95,6 +98,7 @@ const AlertasPanel: FC = () => { Existencia Caduca Estado + Acción @@ -107,6 +111,21 @@ const AlertasPanel: FC = () => { {c.qty} {c.unit} {c.expiry_date} {b.label} + + {c.vencido && c.qty > 0 ? ( + + ) : ( + — + )} + ); })} @@ -144,7 +163,8 @@ const AlertasPanel: FC = () => { Mínimo Óptimo Sugerido - Costo est. + Costo est. + Acción @@ -155,7 +175,18 @@ const AlertasPanel: FC = () => { {s.qty_min || '-'} {s.qty_optimal || '-'} +{s.sugerido} {s.unit} - {fmtMoney(s.costo_estimado)} + {fmtMoney(s.costo_estimado)} + + + ))} @@ -171,6 +202,14 @@ const AlertasPanel: FC = () => { )} + setMovModal(null)} + onSaved={() => { load(); }} + /> ); }; diff --git a/frontend/src/components/inventario/ConteoFisicoModal.tsx b/frontend/src/components/inventario/ConteoFisicoModal.tsx new file mode 100644 index 0000000..46baee2 --- /dev/null +++ b/frontend/src/components/inventario/ConteoFisicoModal.tsx @@ -0,0 +1,204 @@ +import type { FC } from 'react'; +import { useEffect, useMemo, useState } from 'react'; +import { Search, ClipboardCheck } from 'lucide-react'; +import { Button, Input, Modal, Skeleton, toast } from '../ui'; +import { odooApi, type InventoryItem, type InventoryConteo } from '../../services/odoo'; + +const money = (v: number) => + '$' + (v ?? 0).toLocaleString('es-MX', { minimumFractionDigits: 2, maximumFractionDigits: 2 }); + +interface ConteoFisicoModalProps { + isOpen: boolean; + onClose: () => void; + /** Devuelve el conteo guardado (con diferencias) para imprimir el resumen. */ + onSaved: (conteo: InventoryConteo) => void; +} + +// Conteo físico cíclico: lista los artículos activos con el stock del sistema +// prellenado y aplica un ajuste por cada diferencia al guardar. +export const ConteoFisicoModal: FC = ({ isOpen, onClose, onSaved }) => { + const [items, setItems] = useState([]); + const [loading, setLoading] = useState(true); + const [search, setSearch] = useState(''); + const [contados, setContados] = useState>({}); + const [notes, setNotes] = useState(''); + const [saving, setSaving] = useState(false); + + useEffect(() => { + if (!isOpen) return; + setLoading(true); + setContados({}); + setNotes(''); + setSearch(''); + odooApi.getInventory({}) + .then((res) => { + const activos = (res.items || []).filter((i) => i.active !== false); + setItems(activos); + setContados(Object.fromEntries(activos.map((i) => [i.id, String(i.qty)]))); + }) + .catch(() => toast.error('No se pudo cargar el inventario')) + .finally(() => setLoading(false)); + }, [isOpen]); + + const visibles = useMemo(() => { + const q = search.trim().toLowerCase(); + if (!q) return items; + return items.filter((i) => + [i.name, i.sku, i.category].filter(Boolean).some((v) => String(v).toLowerCase().includes(q))); + }, [items, search]); + + const valorContado = (it: InventoryItem) => { + const raw = contados[it.id]; + if (raw === undefined || raw === '') return it.qty; + const n = parseFloat(raw); + return Number.isNaN(n) ? it.qty : n; + }; + + const resumen = useMemo(() => { + let dif = 0; + let faltante = 0; + let sobrante = 0; + for (const it of items) { + const d = valorContado(it) - it.qty; + if (Math.abs(d) < 1e-9) continue; + dif += 1; + const valor = d * (it.cost || 0); + if (valor < 0) faltante += valor; + else sobrante += valor; + } + return { dif, faltante, sobrante, neto: faltante + sobrante }; + }, [items, contados]); + + const guardar = async () => { + if (items.length === 0) { + toast.error('No hay artículos activos para contar'); + return; + } + try { + setSaving(true); + const res = await odooApi.createConteo({ + notes, + lines: items.map((i) => ({ item_id: i.id, qty_contado: valorContado(i) })), + }); + toast.success( + `${res.conteo.name} guardado · ${res.ajustes} ${res.ajustes === 1 ? 'ajuste aplicado' : 'ajustes aplicados'}`, + ); + onClose(); + onSaved(res.conteo); + } catch (err) { + const msg = (err as { response?: { data?: { message?: string } } })?.response?.data?.message; + toast.error(msg || 'Error al guardar el conteo'); + console.error(err); + } finally { + setSaving(false); + } + }; + + return ( + +
+ {resumen.dif} con diferencia · faltante {money(resumen.faltante)} · + sobrante {money(resumen.sobrante)} · neto{' '} + {money(resumen.neto)} +
+
+ + +
+ + } + > +
+

+ Captura la existencia real de cada artículo. El campo viene prellenado con la existencia del sistema; + solo cambia lo que no coincida. Al guardar se genera un movimiento de ajuste por + cada diferencia y queda el historial del conteo. +

+
+ + setSearch(e.target.value)} + className="pl-10" + /> +
+ + {loading ? ( + + ) : items.length === 0 ? ( +

No hay artículos activos en el inventario.

+ ) : ( +
+ + + + + + + + + + + + {visibles.map((it) => { + const diff = valorContado(it) - it.qty; + return ( + + + + + + + + ); + })} + +
ArtículoTipoSistemaContadoDiferencia
+

{it.name}

+

{[it.sku, it.category].filter(Boolean).join(' · ') || '—'}

+
+ + {it.kind === 'consumible' ? 'Consumible' : 'Producto'} + + {it.qty} {it.unit} + setContados({ ...contados, [it.id]: e.target.value })} + className={`w-24 border rounded-lg px-2 py-1 text-sm text-right ${ + Math.abs(diff) > 1e-9 ? 'border-amber-400 bg-amber-50' : 'border-theme-border-strong' + }`} + /> + 0 ? 'text-emerald-700' : 'text-theme-muted' + }`}> + {Math.abs(diff) < 1e-9 ? '—' : `${diff > 0 ? '+' : ''}${Math.round(diff * 100) / 100}`} +
+
+ )} + + setNotes(e.target.value)} + placeholder="Ej. conteo mensual de consumibles" + /> +
+
+ ); +}; + +export default ConteoFisicoModal; diff --git a/frontend/src/components/inventario/ConteoPrint.tsx b/frontend/src/components/inventario/ConteoPrint.tsx new file mode 100644 index 0000000..9718f84 --- /dev/null +++ b/frontend/src/components/inventario/ConteoPrint.tsx @@ -0,0 +1,118 @@ +import type { FC } from 'react'; +import { Printer } from 'lucide-react'; +import { Button, Modal } from '../ui'; +import type { InventoryConteo } from '../../services/odoo'; + +// Resumen imprimible del conteo físico. Sigue el patrón print-* existente +// (TicketPrint / CortePrint): div.print-recibo + window.print(). + +const money = (v: number) => + '$' + (v ?? 0).toLocaleString('es-MX', { minimumFractionDigits: 2, maximumFractionDigits: 2 }); + +interface ClinicSettings { + name: string; + phone: string; + address: string; +} + +const clinicSettings = (): ClinicSettings => { + try { + const saved = localStorage.getItem('skeen_clinic_settings'); + if (saved) return JSON.parse(saved); + } catch { + // ignore + } + return { name: 'SKEEN Derma Experts', phone: '', address: '' }; +}; + +export const ConteoPrint: FC<{ conteo: InventoryConteo | null; onClose: () => void }> = ({ conteo, onClose }) => { + if (!conteo) return null; + const clinic = clinicSettings(); + const lineas = conteo.lines || []; + + return ( + +
+
+

{clinic.name}

+

CONTEO FÍSICO DE INVENTARIO

+

+ {conteo.name} · {conteo.date || '—'} · Realizó: {conteo.user_name || '—'} +

+
+ + + + + + + + + + + + + {lineas.map((l) => ( + + + + + + + + ))} + +
ArtículoSistemaContadoDiferenciaValor
{l.item}{l.qty_sistema} {l.unit}{l.qty_contado} {l.unit} 0 ? 'text-emerald-700' : 'text-[#555]'}`}> + {l.difference > 0 ? '+' : ''}{l.difference} + {money(l.value_diff)}
+ + + + + + + + + + + + + + + + + + + + + + + + +
Artículos contados{conteo.total_items}
Artículos con diferencia{conteo.total_diferencias}
Faltante valorizado{money(conteo.total_faltante)}
Sobrante valorizado{money(conteo.total_sobrante)}
Diferencia neta{money(conteo.total_value_diff)}
+ + {conteo.notes && ( +
+

Notas:

+

{conteo.notes}

+
+ )} + +
+ _____________________
Contó: {conteo.user_name || ''}
+ _____________________
Revisó (administración)
+
+

Impreso el {new Date().toLocaleString('es-MX')}

+
+ +
+ +
+
+ ); +}; + +export default ConteoPrint; diff --git a/frontend/src/components/inventario/MovimientoModal.tsx b/frontend/src/components/inventario/MovimientoModal.tsx new file mode 100644 index 0000000..db56e7a --- /dev/null +++ b/frontend/src/components/inventario/MovimientoModal.tsx @@ -0,0 +1,171 @@ +import type { FC } from 'react'; +import { useEffect, useState } from 'react'; +import { Button, Input, Select, Modal, toast } from '../ui'; +import { odooApi, type InventoryItem } from '../../services/odoo'; + +const fmtMoney = (n: number) => + (n || 0).toLocaleString('es-MX', { style: 'currency', currency: 'MXN', maximumFractionDigits: 0 }); + +interface MovimientoModalProps { + isOpen: boolean; + tipo: 'compra' | 'baja'; + /** Artículo precargado (acciones directas desde alertas). */ + initialItemId?: number | null; + initialQty?: number | null; + onClose: () => void; + onSaved: () => void; +} + +// Modal de registro de compra / baja, reutilizado por el panel de movimientos +// y por las acciones directas de la lista de alertas. +export const MovimientoModal: FC = ({ + isOpen, + tipo, + initialItemId = null, + initialQty = null, + onClose, + onSaved, +}) => { + const esCompra = tipo === 'compra'; + const [items, setItems] = useState([]); + const [itemSearch, setItemSearch] = useState(''); + const [form, setForm] = useState({ item_id: '', qty: '1', cost: '', reference: '', notes: '' }); + const [saving, setSaving] = useState(false); + + useEffect(() => { + if (!isOpen) return; + odooApi.getInventarioItems(itemSearch || undefined) + .then((res) => { if (res.status === 'success') setItems(res.items); }) + .catch(() => {}); + }, [isOpen, itemSearch]); + + useEffect(() => { + if (!isOpen) return; + setForm({ + item_id: initialItemId ? String(initialItemId) : '', + qty: initialQty ? String(initialQty) : '1', + cost: '', + reference: '', + notes: '', + }); + setItemSearch(''); + }, [isOpen, initialItemId, initialQty, tipo]); + + // El artículo precargado puede no venir en la primera página del catálogo + useEffect(() => { + if (!isOpen || !initialItemId) return; + if (items.some((i) => i.id === initialItemId)) return; + odooApi.getInventarioItems() + .then((res) => { + if (res.status !== 'success') return; + setItems((prev) => { + const extra = res.items.filter((i) => !prev.some((p) => p.id === i.id)); + return [...prev, ...extra]; + }); + }) + .catch(() => {}); + }, [isOpen, initialItemId, items]); + + const guardar = async () => { + const qty = parseFloat(form.qty); + if (!form.item_id) { + toast.error('Selecciona un artículo'); + return; + } + if (Number.isNaN(qty) || qty <= 0) { + toast.error('La cantidad debe ser mayor a 0'); + return; + } + try { + setSaving(true); + const payload = { + item_id: parseInt(form.item_id, 10), + qty, + reference: form.reference, + notes: form.notes, + ...(esCompra && form.cost !== '' ? { cost: parseFloat(form.cost) } : {}), + }; + const res = esCompra ? await odooApi.createCompra(payload) : await odooApi.createBaja(payload); + if (res.status === 'success') { + toast.success(esCompra ? 'Compra registrada' : 'Baja registrada'); + onClose(); + onSaved(); + } + } catch (err) { + const msg = (err as { response?: { data?: { message?: string } } })?.response?.data?.message; + toast.error(msg || 'Error al registrar el movimiento'); + console.error(err); + } finally { + setSaving(false); + } + }; + + return ( + + + + + } + > +
+ setItemSearch(e.target.value)} + /> + setForm({ ...form, qty: e.target.value })} + /> + {esCompra && ( + setForm({ ...form, cost: e.target.value })} + placeholder="Opcional" + /> + )} + setForm({ ...form, reference: e.target.value })} + /> + setForm({ ...form, notes: e.target.value })} + placeholder={esCompra ? undefined : 'Ej. caducado, dañado, merma'} + /> + {esCompra && form.cost !== '' && ( +

+ Costo total estimado: {fmtMoney((parseFloat(form.qty) || 0) * (parseFloat(form.cost) || 0))} +

+ )} +
+
+ ); +}; + +export default MovimientoModal; diff --git a/frontend/src/components/inventario/MovimientosPanel.tsx b/frontend/src/components/inventario/MovimientosPanel.tsx index 7747f8d..1e8ac0c 100644 --- a/frontend/src/components/inventario/MovimientosPanel.tsx +++ b/frontend/src/components/inventario/MovimientosPanel.tsx @@ -5,16 +5,12 @@ import { Card, Button, Input, - Select, - Modal, EmptyState, Skeleton, toast, } from '../ui'; -import { odooApi, type MovimientoRow, type InventoryItem } from '../../services/odoo'; - -const fmtMoney = (n: number) => - (n || 0).toLocaleString('es-MX', { style: 'currency', currency: 'MXN', maximumFractionDigits: 0 }); +import MovimientoModal from './MovimientoModal'; +import { odooApi, type MovimientoRow } from '../../services/odoo'; interface MovimientosPanelProps { tipo: 'compra' | 'baja'; @@ -30,10 +26,6 @@ const MovimientosPanel: FC = ({ tipo }) => { const [total, setTotal] = useState(0); const [modalOpen, setModalOpen] = useState(false); - const [items, setItems] = useState([]); - const [itemSearch, setItemSearch] = useState(''); - const [form, setForm] = useState({ item_id: '', qty: '1', cost: '', reference: '', notes: '' }); - const [saving, setSaving] = useState(false); const load = useCallback(async () => { try { @@ -57,53 +49,7 @@ const MovimientosPanel: FC = ({ tipo }) => { useEffect(() => { setPage(1); }, [search, tipo]); useEffect(() => { load(); }, [load]); - // Catálogo de artículos para el select del modal - useEffect(() => { - if (!modalOpen) return; - odooApi.getInventarioItems(itemSearch || undefined) - .then((res) => { if (res.status === 'success') setItems(res.items); }) - .catch(() => {}); - }, [modalOpen, itemSearch]); - - const openModal = () => { - setForm({ item_id: '', qty: '1', cost: '', reference: '', notes: '' }); - setItemSearch(''); - setModalOpen(true); - }; - - const guardar = async () => { - const qty = parseFloat(form.qty); - if (!form.item_id) { - toast.error('Selecciona un artículo'); - return; - } - if (Number.isNaN(qty) || qty <= 0) { - toast.error('La cantidad debe ser mayor a 0'); - return; - } - try { - setSaving(true); - const payload = { - item_id: parseInt(form.item_id, 10), - qty, - reference: form.reference, - notes: form.notes, - ...(esCompra && form.cost !== '' ? { cost: parseFloat(form.cost) } : {}), - }; - const res = esCompra ? await odooApi.createCompra(payload) : await odooApi.createBaja(payload); - if (res.status === 'success') { - toast.success(esCompra ? 'Compra registrada' : 'Baja registrada'); - setModalOpen(false); - await load(); - } - } catch (err) { - const msg = (err as { response?: { data?: { message?: string } } })?.response?.data?.message; - toast.error(msg || 'Error al registrar el movimiento'); - console.error(err); - } finally { - setSaving(false); - } - }; + const openModal = () => setModalOpen(true); return ( <> @@ -184,70 +130,12 @@ const MovimientosPanel: FC = ({ tipo }) => { - {/* Modal registrar movimiento */} - setModalOpen(false)} - title={esCompra ? 'Registrar compra' : 'Registrar baja / merma'} - maxWidth="md" - footer={ - <> - - - - } - > -
- setItemSearch(e.target.value)} - /> - setForm({ ...form, qty: e.target.value })} - /> - {esCompra && ( - setForm({ ...form, cost: e.target.value })} - placeholder="Opcional" - /> - )} - setForm({ ...form, reference: e.target.value })} - /> - setForm({ ...form, notes: e.target.value })} - /> - {esCompra && form.cost !== '' && ( -

- Costo total estimado: {fmtMoney((parseFloat(form.qty) || 0) * (parseFloat(form.cost) || 0))} -

- )} -
-
+ onSaved={() => { load(); }} + /> ); }; diff --git a/frontend/src/components/reportes/AdeudosReport.tsx b/frontend/src/components/reportes/AdeudosReport.tsx index 21a7acf..cff1339 100644 --- a/frontend/src/components/reportes/AdeudosReport.tsx +++ b/frontend/src/components/reportes/AdeudosReport.tsx @@ -1,21 +1,58 @@ import type { FC } from 'react'; import { useEffect, useState, useCallback } from 'react'; import { Link } from 'react-router-dom'; -import { HandCoins } from 'lucide-react'; -import { Card, EmptyState, Skeleton, toast } from '../ui'; +import { HandCoins, CreditCard, CalendarClock, X } from 'lucide-react'; +import { Card, Button, Input, Select, Modal, TextArea, EmptyState, Skeleton, toast } from '../ui'; import { ExportButton } from '../ui/ExportButton'; import { exportToExcel } from '../../lib/exporter'; -import { odooApi, type AdeudoRow } from '../../services/odoo'; +import ReciboPrint from '../ReciboPrint'; +import { odooApi, type AdeudoRow, type PaymentPlan, type ReceiptData } from '../../services/odoo'; const formatCurrency = (value: number) => new Intl.NumberFormat('es-MX', { style: 'currency', currency: 'MXN', maximumFractionDigits: 0 }).format(value); +const formatCurrency2 = (value: number) => + new Intl.NumberFormat('es-MX', { style: 'currency', currency: 'MXN', minimumFractionDigits: 2, maximumFractionDigits: 2 }).format(value); + +const MESES_CORTOS = ['ene', 'feb', 'mar', 'abr', 'may', 'jun', 'jul', 'ago', 'sep', 'oct', 'nov', 'dic']; + +// "2026-10-15" -> "15-oct" +const fechaCorta = (iso?: string | null) => { + if (!iso) return ''; + const [y, m, d] = iso.split('-').map((v) => parseInt(v, 10)); + if (!y || !m || !d) return iso; + return `${d}-${MESES_CORTOS[m - 1]}`; +}; + +const METODOS = [ + { value: 'cash', label: 'Efectivo' }, + { value: 'card', label: 'Tarjeta' }, + { value: 'transfer', label: 'Transferencia' }, + { value: 'stripe', label: 'Stripe' }, + { value: 'mercadopago', label: 'MercadoPago' }, +]; + const AdeudosReport: FC = () => { const [adeudos, setAdeudos] = useState([]); const [totalCartera, setTotalCartera] = useState(0); const [totalPacientes, setTotalPacientes] = useState(0); const [loading, setLoading] = useState(true); + // Cobro del adeudo + const [pagoAdeudo, setPagoAdeudo] = useState(null); + const [monto, setMonto] = useState(''); + const [metodo, setMetodo] = useState('cash'); + const [cobrando, setCobrando] = useState(false); + const [recibo, setRecibo] = useState(null); + + // Plan de pagos + const [planAdeudo, setPlanAdeudo] = useState(null); + const [planCount, setPlanCount] = useState('3'); + const [planAmount, setPlanAmount] = useState(''); + const [planNext, setPlanNext] = useState(''); + const [planNote, setPlanNote] = useState(''); + const [guardandoPlan, setGuardandoPlan] = useState(false); + const load = useCallback(async () => { try { setLoading(true); @@ -50,8 +87,12 @@ const AdeudosReport: FC = () => { { header: '# Ventas', key: 'num_ventas', format: 'number' }, { header: 'Adeudo', key: 'total_adeudo', format: 'currency' }, { header: 'Última venta', key: 'ultima_venta', format: 'date' }, + { header: 'Plan', key: 'plan_txt' }, ], - rows: adeudos.map((a) => ({ ...a })), + rows: adeudos.map((a) => ({ + ...a, + plan_txt: a.plan ? `${a.plan.count} pagos de ${formatCurrency(a.plan.amount)}` : '', + })), totals: { name: '', total_adeudo: totalCartera, num_ventas: adeudos.reduce((a, x) => a + x.num_ventas, 0) }, }); toast.success('Excel descargado'); @@ -63,6 +104,105 @@ const AdeudosReport: FC = () => { } }; + const abrirPago = (a: AdeudoRow) => { + setPagoAdeudo(a); + setMonto(String(a.total_adeudo)); + setMetodo('cash'); + }; + + const cobrar = async () => { + if (!pagoAdeudo) return; + const value = parseFloat(monto); + if (Number.isNaN(value) || value <= 0) { + toast.error('Ingresa un monto válido'); + return; + } + if (value > pagoAdeudo.total_adeudo + 0.01) { + toast.error(`El monto no puede exceder el adeudo (${formatCurrency(pagoAdeudo.total_adeudo)})`); + return; + } + try { + setCobrando(true); + const res = await odooApi.payAdeudo(pagoAdeudo.id, { amount: value, payment_method: metodo }); + if (res.status === 'success') { + setPagoAdeudo(null); + setRecibo(res.receipt); + toast.success(`Pago ${res.receipt.folio} registrado · saldo ${formatCurrency(res.receipt.saldo_restante)}`); + await load(); + } + } catch (err) { + const msg = (err as { response?: { data?: { message?: string } } })?.response?.data?.message; + toast.error(msg || 'Error al registrar el pago'); + console.error(err); + } finally { + setCobrando(false); + } + }; + + const abrirPlan = (a: AdeudoRow) => { + const plan: PaymentPlan | null = a.plan || null; + setPlanAdeudo(a); + setPlanCount(String(plan?.count || 3)); + setPlanAmount(String(plan?.amount || Math.ceil(a.total_adeudo / 3))); + setPlanNext(plan?.next_payment_date || ''); + setPlanNote(plan?.note || ''); + }; + + const recalcularMonto = (count: string) => { + setPlanCount(count); + if (!planAdeudo) return; + const n = parseInt(count, 10); + if (n > 0) setPlanAmount(String(Math.ceil(planAdeudo.total_adeudo / n))); + }; + + const guardarPlan = async () => { + if (!planAdeudo) return; + const n = parseInt(planCount, 10); + const amt = parseFloat(planAmount); + if (Number.isNaN(n) || n <= 0) { + toast.error('Ingresa el número de pagos acordados'); + return; + } + if (Number.isNaN(amt) || amt <= 0) { + toast.error('Ingresa el monto por pago'); + return; + } + try { + setGuardandoPlan(true); + await odooApi.setPaymentPlan(planAdeudo.id, { + count: n, + amount: amt, + next_payment_date: planNext || null, + note: planNote, + }); + toast.success(`Plan de ${n} pagos registrado`); + setPlanAdeudo(null); + await load(); + } catch (err) { + const msg = (err as { response?: { data?: { message?: string } } })?.response?.data?.message; + toast.error(msg || 'Error al guardar el plan'); + console.error(err); + } finally { + setGuardandoPlan(false); + } + }; + + const quitarPlan = async () => { + if (!planAdeudo) return; + try { + setGuardandoPlan(true); + await odooApi.setPaymentPlan(planAdeudo.id, { count: 0, amount: 0 }); + toast.success('Plan de pagos eliminado'); + setPlanAdeudo(null); + await load(); + } catch (err) { + toast.error('Error al quitar el plan'); + console.error(err); + } finally { + setGuardandoPlan(false); + } + }; + return (
@@ -94,9 +234,10 @@ const AdeudosReport: FC = () => { Paciente Teléfono - # Ventas Adeudo + Plan Última venta + Acciones @@ -112,9 +253,39 @@ const AdeudosReport: FC = () => { )} {a.phone || '-'} - {a.num_ventas} {formatCurrency(a.total_adeudo)} + + {a.plan ? ( + + + Plan: {a.plan.count} pagos + {a.plan.remaining_count < a.plan.count ? ` · ${a.plan.remaining_count} restantes` : ''} + {a.plan.next_payment_date ? ` · siguiente ${fechaCorta(a.plan.next_payment_date)}` : ''} + + ) : ( + — + )} + {a.ultima_venta || '-'} + +
+ + +
+ ))} @@ -123,6 +294,138 @@ const AdeudosReport: FC = () => { )} + + {/* Modal registrar pago del adeudo */} + setPagoAdeudo(null)} + title={pagoAdeudo ? `Registrar pago · ${pagoAdeudo.name}` : 'Registrar pago'} + maxWidth="sm" + footer={ + <> + + + + } + > + {pagoAdeudo && ( +
+
+
+

Adeudo pendiente

+

{formatCurrency2(pagoAdeudo.total_adeudo)}

+
+
+

Ventas con saldo

+

{pagoAdeudo.num_ventas}

+
+
+ setMonto(e.target.value)} + /> +
+ {[0.25, 0.5, 1].map((f) => ( + + ))} +
+ recalcularMonto(e.target.value)} + /> + setPlanAmount(e.target.value)} + /> +
+ setPlanNext(e.target.value)} + /> +