import type { FC } from 'react'; import { useEffect, useState, useCallback } from 'react'; import { useSearchParams } from 'react-router-dom'; import { Search, Plus, Minus, Wallet, SlidersHorizontal, History } from 'lucide-react'; import Layout from '../components/Layout'; import { Card, Button, Input, Select, TextArea, Modal, EmptyState, PageHeader, Skeleton, MobileCard, Badge, toast, } from '../components/ui'; import { odooApi, type Wallet as WalletType, type MonederoPuntosReport, type WalletTransaction } from '../services/odoo'; import { useAuth } from '../lib/auth'; import { DATE_PRESETS, rangeForPreset, readDateFilterPrefs, writeDateFilterPrefs, type DatePreset, } from '../lib/dateRange'; type TxType = 'accrual' | 'redemption'; const TABS = [ { key: 'cuentas', label: 'Cuentas' }, { key: 'reporte', label: 'Reporte de puntos' }, ] as const; const txTypeLabels: Record = { accrual: 'Acumulación', redemption: 'Redención', adjustment: 'Ajuste', expiration: 'Vencimiento', }; const TX_FILTER_OPTIONS = [ { value: '', label: 'Todos los movimientos' }, { value: 'accrual', label: 'Acumulaciones' }, { value: 'redemption', label: 'Redenciones' }, { value: 'adjustment', label: 'Ajustes' }, { value: 'expiration', label: 'Vencimientos' }, ]; // Rango recordado de los movimientos del monedero (misma convención que la agenda) const MOV_FILTERS_KEY = 'skeen_monedero_mov_filtros'; const ROLES_AJUSTAN = ['admin', 'gerencia']; const inicioDeMes = () => { const d = new Date(); d.setDate(1); return d.toISOString().split('T')[0]; }; const fmtMoney = (n: number) => (n || 0).toLocaleString('es-MX', { style: 'currency', currency: 'MXN', maximumFractionDigits: 0 }); const Monedero: FC = () => { const [searchParams, setSearchParams] = useSearchParams(); const { user } = useAuth(); const puedeAjustar = ROLES_AJUSTAN.includes(user?.role || ''); const tab = searchParams.get('tab') || 'cuentas'; const setTab = (t: string) => setSearchParams({ tab: t }); const [wallets, setWallets] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [search, setSearch] = useState(''); const [page, setPage] = useState(1); const [pageSize, setPageSize] = useState(50); const [total, setTotal] = useState(0); const [totalPages, setTotalPages] = useState(0); const [modalOpen, setModalOpen] = useState(false); const [selected, setSelected] = useState(null); const [txType, setTxType] = useState('accrual'); const [points, setPoints] = useState(''); const [description, setDescription] = useState(''); const [submitting, setSubmitting] = useState(false); const [reporte, setReporte] = useState(null); const [reporteLoading, setReporteLoading] = useState(false); const [repStart, setRepStart] = useState(inicioDeMes()); const [repEnd, setRepEnd] = useState(() => new Date().toISOString().split('T')[0]); // Movimientos del monedero (narrados, con filtros) const savedMov = readDateFilterPrefs(MOV_FILTERS_KEY); const [movWallet, setMovWallet] = useState(null); const [movimientos, setMovimientos] = useState([]); const [movLoading, setMovLoading] = useState(false); const [movType, setMovType] = useState(''); const [movPreset, setMovPreset] = useState(savedMov?.preset || 'mes'); const [movStart, setMovStart] = useState(savedMov?.start || rangeForPreset('mes').start); const [movEnd, setMovEnd] = useState(savedMov?.end || rangeForPreset('mes').end); const [movSum, setMovSum] = useState({ pos: 0, neg: 0 }); // Ajuste manual auditado const [ajusteOpen, setAjusteOpen] = useState(false); const [ajusteSigno, setAjusteSigno] = useState<'sumar' | 'restar'>('sumar'); const [ajustePuntos, setAjustePuntos] = useState(''); const [ajusteMotivo, setAjusteMotivo] = useState(''); const [ajustando, setAjustando] = useState(false); const load = useCallback(async () => { if (tab !== 'cuentas') return; try { setLoading(true); setError(null); const res = await odooApi.getWallets({ search, page, page_size: pageSize }); if (res.status === 'success') { setWallets(res.wallets); setTotal(res.total ?? 0); setTotalPages(res.total_pages ?? 0); } } catch (err) { setError('Error al cargar monederos'); toast.error('Error al cargar monederos'); console.error(err); } finally { setLoading(false); } }, [search, page, pageSize, tab]); const loadReporte = useCallback(async () => { if (tab !== 'reporte') return; try { setReporteLoading(true); const res = await odooApi.getMonederoPuntos(repStart || undefined, repEnd || undefined); if (res.status === 'success') { const { by_type, puntos_activos, cuentas, equivalente_mxn } = res; setReporte({ by_type, puntos_activos, cuentas, equivalente_mxn }); } } catch (err) { toast.error('Error al cargar reporte de puntos'); console.error(err); } finally { setReporteLoading(false); } }, [tab, repStart, repEnd]); const loadMovimientos = useCallback(async (wallet: WalletType, filtros?: { type?: string; from?: string; to?: string }) => { try { setMovLoading(true); const res = await odooApi.getWalletTransactions(wallet.id, { ...(filtros?.type ? { type: filtros.type } : {}), ...(filtros?.from ? { date_from: filtros.from } : {}), ...(filtros?.to ? { date_to: filtros.to } : {}), }); if (res.status === 'success') { setMovimientos(res.movimientos); setMovSum({ pos: res.sum_positive, neg: res.sum_negative }); } } catch (err) { toast.error('Error al cargar movimientos'); console.error(err); } finally { setMovLoading(false); } }, []); useEffect(() => { setPage(1); }, [search]); useEffect(() => { load(); }, [load]); useEffect(() => { loadReporte(); }, [loadReporte]); // Tipo de cambio para mostrar el equivalente en USD const [rate, setRate] = useState(null); useEffect(() => { odooApi.getExchangeRate() .then((res) => { if (res.current?.rate) setRate(res.current.rate); }) .catch(() => {}); }, []); const fmtUsd = (mxn: number) => rate ? `≈ ${(mxn / rate).toLocaleString('en-US', { style: 'currency', currency: 'USD' })}` : ''; const openModal = (w: WalletType, type: TxType) => { setSelected(w); setTxType(type); setPoints(''); setDescription(''); setModalOpen(true); }; const submit = async () => { if (!selected) return; const value = parseFloat(points); if (Number.isNaN(value) || value <= 0) { toast.error('Ingresa una cantidad válida de puntos'); return; } if (txType === 'redemption' && value > selected.points) { toast.error('No puede redimir más puntos de los disponibles'); return; } try { setSubmitting(true); await odooApi.walletTransaction({ phone: selected.phone, type: txType, points: value, description, }); toast.success(txType === 'accrual' ? 'Puntos acumulados' : 'Puntos redimidos'); setModalOpen(false); await load(); } catch (err) { toast.error('Error en transacción'); console.error(err); } finally { setSubmitting(false); } }; // --- Movimientos --- const abrirMovimientos = (w: WalletType) => { setMovWallet(w); loadMovimientos(w, { type: movType, from: movStart, to: movEnd }); }; const aplicarPresetMov = (p: DatePreset) => { setMovPreset(p); let from = movStart; let to = movEnd; if (p !== 'custom') { const r = rangeForPreset(p); from = r.start; to = r.end; setMovStart(r.start); setMovEnd(r.end); } writeDateFilterPrefs(MOV_FILTERS_KEY, { preset: p, start: from, end: to }); if (movWallet) loadMovimientos(movWallet, { type: movType, from, to }); }; const aplicarTipoMov = (t: string) => { setMovType(t); if (movWallet) loadMovimientos(movWallet, { type: t, from: movStart, to: movEnd }); }; // --- Ajuste manual --- const abrirAjuste = () => { setAjusteSigno('sumar'); setAjustePuntos(''); setAjusteMotivo(''); setAjusteOpen(true); }; const guardarAjuste = async () => { if (!movWallet) return; const value = parseInt(ajustePuntos, 10); if (Number.isNaN(value) || value <= 0) { toast.error('Ingresa una cantidad de puntos válida'); return; } if (!ajusteMotivo.trim()) { toast.error('El motivo del ajuste es obligatorio'); return; } try { setAjustando(true); const res = await odooApi.adjustWallet(movWallet.id, { points: ajusteSigno === 'sumar' ? value : -value, reason: ajusteMotivo.trim(), }); toast.success(`Ajuste aplicado: ${res.applied > 0 ? '+' : ''}${res.applied} pts`); setAjusteOpen(false); setMovWallet(res.wallet); await loadMovimientos(res.wallet, { type: movType, from: movStart, to: movEnd }); await load(); } catch (err) { const msg = (err as { response?: { data?: { message?: string } } })?.response?.data?.message; toast.error(msg || 'Error al aplicar el ajuste'); console.error(err); } finally { setAjustando(false); } }; const saldoMov = movWallet?.points ?? 0; return ( {/* Tabs */}
{TABS.map((t) => ( ))}
{tab === 'reporte' ? ( <>
setRepStart(e.target.value)} className="sm:max-w-[160px]" /> setRepEnd(e.target.value)} className="sm:max-w-[160px]" />
{reporteLoading ? (
{Array.from({ length: 3 }).map((_, i) => )}
) : reporte ? ( <>

Puntos activos

{reporte.puntos_activos} pts

Equivalente

{fmtMoney(reporte.equivalente_mxn)}

{rate &&

{fmtUsd(reporte.equivalente_mxn)} USD

}

Cuentas con monedero

{reporte.cuentas}

Movimientos del periodo

1 punto = $1 MXN (conversión fija del sistema).

{Object.keys(reporte.by_type).length === 0 ? (

Sin movimientos en el periodo.

) : (
{Object.entries(reporte.by_type).map(([type, points]) => (

{txTypeLabels[type] || type}

{points > 0 && type !== 'redemption' && type !== 'expiration' ? '+' : ''}{points} pts

{fmtMoney(points)}{rate ? ` · ${fmtUsd(points)} USD` : ''}

))}
)}
) : null} ) : ( <>
setSearch(e.target.value)} className="pl-10" />
{error &&

{error}

} {loading ? ( ) : wallets.length === 0 ? ( } /> ) : ( <>
{wallets.map((w) => ( ))}
Paciente Teléfono Puntos Saldo (MXN / USD) Acciones
{w.patient} {w.phone} {w.points} pts {fmtMoney(w.equivalent_mxn)} {rate && {fmtUsd(w.equivalent_mxn)} USD}
{wallets.map((w) => ( } /> ))}
{/* Paginación */}

{total} monederos · página {page} de {totalPages || 1}

)}
setModalOpen(false)} title={txType === 'accrual' ? 'Acumular puntos' : 'Redimir puntos'} maxWidth="sm" footer={ <> } > {selected && (

Paciente: {selected.patient}

Puntos disponibles: {selected.points} pts ${selected.equivalent_mxn} MXN{rate ? ` · ${fmtUsd(selected.equivalent_mxn)} USD` : ''}

setPoints(e.target.value)} />