728 lines
29 KiB
TypeScript
728 lines
29 KiB
TypeScript
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<string, string> = {
|
|
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<WalletType[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const [error, setError] = useState<string | null>(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<WalletType | null>(null);
|
|
const [txType, setTxType] = useState<TxType>('accrual');
|
|
const [points, setPoints] = useState('');
|
|
const [description, setDescription] = useState('');
|
|
const [submitting, setSubmitting] = useState(false);
|
|
const [reporte, setReporte] = useState<MonederoPuntosReport | null>(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<WalletType | null>(null);
|
|
const [movimientos, setMovimientos] = useState<WalletTransaction[]>([]);
|
|
const [movLoading, setMovLoading] = useState(false);
|
|
const [movType, setMovType] = useState('');
|
|
const [movPreset, setMovPreset] = useState<DatePreset>(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<number | null>(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 (
|
|
<Layout title="Monedero" subtitle="Puntos y recompensas">
|
|
<PageHeader title="Monedero" subtitle="Busca por teléfono y gestiona puntos" />
|
|
|
|
{/* Tabs */}
|
|
<div className="flex flex-wrap gap-1.5 mb-4">
|
|
{TABS.map((t) => (
|
|
<button
|
|
key={t.key}
|
|
type="button"
|
|
onClick={() => setTab(t.key)}
|
|
className={`px-4 py-2 text-sm font-medium rounded-full transition ${
|
|
tab === t.key ? 'bg-theme-accent text-theme-inverse' : 'bg-theme-accent-bg text-theme-heading hover:bg-theme-bg'
|
|
}`}
|
|
>
|
|
{t.label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
|
|
{tab === 'reporte' ? (
|
|
<>
|
|
<Card className="mb-4">
|
|
<Card.Body>
|
|
<div className="flex flex-col sm:flex-row gap-3">
|
|
<Input label="Inicio" type="date" value={repStart} onChange={(e) => setRepStart(e.target.value)} className="sm:max-w-[160px]" />
|
|
<Input label="Fin" type="date" value={repEnd} onChange={(e) => setRepEnd(e.target.value)} className="sm:max-w-[160px]" />
|
|
</div>
|
|
</Card.Body>
|
|
</Card>
|
|
|
|
{reporteLoading ? (
|
|
<div className="grid grid-cols-2 md:grid-cols-3 gap-4">
|
|
{Array.from({ length: 3 }).map((_, i) => <Skeleton key={i} className="h-24 w-full" />)}
|
|
</div>
|
|
) : reporte ? (
|
|
<>
|
|
<div className="grid grid-cols-2 md:grid-cols-3 gap-4 mb-4">
|
|
<Card className="p-4 bg-theme-bg border-0">
|
|
<p className="text-xs text-theme-muted">Puntos activos</p>
|
|
<p className="text-xl font-heading font-semibold text-theme-heading">{reporte.puntos_activos} pts</p>
|
|
</Card>
|
|
<Card className="p-4 bg-theme-bg border-0">
|
|
<p className="text-xs text-theme-muted">Equivalente</p>
|
|
<p className="text-xl font-heading font-semibold text-theme-heading">{fmtMoney(reporte.equivalente_mxn)}</p>
|
|
{rate && <p className="text-xs text-theme-muted mt-0.5">{fmtUsd(reporte.equivalente_mxn)} USD</p>}
|
|
</Card>
|
|
<Card className="p-4 bg-theme-bg border-0">
|
|
<p className="text-xs text-theme-muted">Cuentas con monedero</p>
|
|
<p className="text-xl font-heading font-semibold text-theme-heading">{reporte.cuentas}</p>
|
|
</Card>
|
|
</div>
|
|
<Card>
|
|
<Card.Body>
|
|
<h3 className="font-heading text-xl text-theme-heading mb-1">Movimientos del periodo</h3>
|
|
<p className="text-xs text-theme-muted mb-4">1 punto = $1 MXN (conversión fija del sistema).</p>
|
|
{Object.keys(reporte.by_type).length === 0 ? (
|
|
<p className="text-sm text-theme-muted">Sin movimientos en el periodo.</p>
|
|
) : (
|
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
|
{Object.entries(reporte.by_type).map(([type, points]) => (
|
|
<div key={type} className="p-3 bg-theme-bg rounded-xl">
|
|
<p className="text-xs text-theme-muted">{txTypeLabels[type] || type}</p>
|
|
<p className={`text-lg font-semibold ${type === 'redemption' || type === 'expiration' ? 'text-rose-600' : 'text-theme-heading'}`}>
|
|
{points > 0 && type !== 'redemption' && type !== 'expiration' ? '+' : ''}{points} pts
|
|
</p>
|
|
<p className="text-[11px] text-theme-muted mt-0.5">{fmtMoney(points)}{rate ? ` · ${fmtUsd(points)} USD` : ''}</p>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</Card.Body>
|
|
</Card>
|
|
</>
|
|
) : null}
|
|
</>
|
|
) : (
|
|
<>
|
|
<Card>
|
|
<Card.Body>
|
|
<div className="flex flex-col sm:flex-row sm:items-center gap-3 mb-4 sm:mb-6">
|
|
<div className="relative w-full sm:max-w-sm">
|
|
<Search size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-theme-muted" />
|
|
<Input
|
|
placeholder="Buscar por teléfono..."
|
|
value={search}
|
|
onChange={(e) => setSearch(e.target.value)}
|
|
className="pl-10"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
{error && <p className="text-rose-600 text-sm mb-4">{error}</p>}
|
|
|
|
{loading ? (
|
|
<Skeleton count={5} className="h-14 w-full" />
|
|
) : wallets.length === 0 ? (
|
|
<EmptyState title="Sin monederos" subtitle="No se encontraron monederos." icon={<Wallet size={28} />} />
|
|
) : (
|
|
<>
|
|
<div className="hidden sm:block overflow-x-auto">
|
|
<table className="w-full">
|
|
<thead className="bg-theme-bg">
|
|
<tr>
|
|
<th className="text-left p-3 text-xs font-medium text-theme-muted uppercase">Paciente</th>
|
|
<th className="text-left p-3 text-xs font-medium text-theme-muted uppercase">Teléfono</th>
|
|
<th className="text-left p-3 text-xs font-medium text-theme-muted uppercase">Puntos</th>
|
|
<th className="text-left p-3 text-xs font-medium text-theme-muted uppercase">Saldo (MXN / USD)</th>
|
|
<th className="text-left p-3 text-xs font-medium text-theme-muted uppercase">Acciones</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody className="divide-y">
|
|
{wallets.map((w) => (
|
|
<tr key={w.id} className="hover:bg-theme-bg">
|
|
<td className="p-3 text-sm font-medium text-theme-heading">{w.patient}</td>
|
|
<td className="p-3 text-sm text-theme-muted">{w.phone}</td>
|
|
<td className="p-3 text-sm font-medium text-theme-heading">{w.points} pts</td>
|
|
<td className="p-3 text-sm text-theme-muted">
|
|
<span className="font-medium text-theme-heading">{fmtMoney(w.equivalent_mxn)}</span>
|
|
{rate && <span className="block text-xs">{fmtUsd(w.equivalent_mxn)} USD</span>}
|
|
</td>
|
|
<td className="p-3">
|
|
<div className="flex items-center gap-1">
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => abrirMovimientos(w)}
|
|
title="Ver movimientos narrados"
|
|
>
|
|
<History size={14} className="mr-1.5" />
|
|
Movimientos
|
|
</Button>
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => openModal(w, 'accrual')}
|
|
title="Acumular"
|
|
>
|
|
<Plus size={16} className="text-theme-heading" />
|
|
</Button>
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => openModal(w, 'redemption')}
|
|
title="Redimir"
|
|
disabled={w.points <= 0}
|
|
>
|
|
<Minus size={16} className="text-rose-600" />
|
|
</Button>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
|
|
<div className="sm:hidden space-y-3">
|
|
{wallets.map((w) => (
|
|
<MobileCard
|
|
key={w.id}
|
|
title={w.patient}
|
|
subtitle={w.phone}
|
|
rows={[
|
|
{ label: 'Puntos', value: `${w.points} pts` },
|
|
{ label: 'Saldo', value: `${fmtMoney(w.equivalent_mxn)}${rate ? ` · ${fmtUsd(w.equivalent_mxn)} USD` : ''}` },
|
|
]}
|
|
actions={
|
|
<>
|
|
<Button variant="ghost" size="sm" onClick={() => abrirMovimientos(w)} title="Movimientos">
|
|
<History size={16} />
|
|
</Button>
|
|
<Button variant="ghost" size="sm" onClick={() => openModal(w, 'accrual')}>
|
|
<Plus size={16} className="text-theme-heading" />
|
|
</Button>
|
|
<Button variant="ghost" size="sm" onClick={() => openModal(w, 'redemption')} disabled={w.points <= 0}>
|
|
<Minus size={16} className="text-rose-600" />
|
|
</Button>
|
|
</>
|
|
}
|
|
/>
|
|
))}
|
|
</div>
|
|
|
|
{/* Paginación */}
|
|
<div className="flex flex-col sm:flex-row items-center justify-between gap-3 mt-4 pt-4 border-t border-theme-border">
|
|
<p className="text-sm text-theme-muted">
|
|
{total} monederos · página {page} de {totalPages || 1}
|
|
</p>
|
|
<div className="flex items-center gap-2">
|
|
<select
|
|
value={pageSize}
|
|
onChange={(e) => { setPageSize(parseInt(e.target.value, 10)); setPage(1); }}
|
|
className="border border-theme-border-strong rounded-lg px-2 py-1.5 text-sm"
|
|
>
|
|
<option value={25}>25</option>
|
|
<option value={50}>50</option>
|
|
<option value={100}>100</option>
|
|
<option value={200}>200</option>
|
|
</select>
|
|
<Button variant="outline" size="sm" disabled={page <= 1} onClick={() => setPage((p) => Math.max(1, p - 1))}>
|
|
Anterior
|
|
</Button>
|
|
<Button variant="outline" size="sm" disabled={page >= totalPages} onClick={() => setPage((p) => p + 1)}>
|
|
Siguiente
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</>
|
|
)}
|
|
</Card.Body>
|
|
</Card>
|
|
|
|
<Modal
|
|
isOpen={modalOpen}
|
|
onClose={() => setModalOpen(false)}
|
|
title={txType === 'accrual' ? 'Acumular puntos' : 'Redimir puntos'}
|
|
maxWidth="sm"
|
|
footer={
|
|
<>
|
|
<Button variant="outline" onClick={() => setModalOpen(false)}>Cancelar</Button>
|
|
<Button onClick={submit} loading={submitting}>Guardar</Button>
|
|
</>
|
|
}
|
|
>
|
|
{selected && (
|
|
<div className="space-y-4">
|
|
<p className="text-sm text-theme-muted">
|
|
Paciente: <span className="font-medium text-theme-heading">{selected.patient}</span>
|
|
</p>
|
|
<p className="text-sm text-theme-muted">
|
|
Puntos disponibles: <span className="font-medium text-theme-heading">{selected.points} pts</span>
|
|
<span className="block text-xs mt-0.5">
|
|
${selected.equivalent_mxn} MXN{rate ? ` · ${fmtUsd(selected.equivalent_mxn)} USD` : ''}
|
|
</span>
|
|
</p>
|
|
<Input
|
|
label="Puntos *"
|
|
type="number"
|
|
min={1}
|
|
value={points}
|
|
onChange={(e) => setPoints(e.target.value)}
|
|
/>
|
|
<TextArea
|
|
label="Descripción"
|
|
value={description}
|
|
onChange={(e) => setDescription(e.target.value)}
|
|
/>
|
|
</div>
|
|
)}
|
|
</Modal>
|
|
|
|
{/* Movimientos del monedero con narración y filtros */}
|
|
<Modal
|
|
isOpen={!!movWallet}
|
|
onClose={() => setMovWallet(null)}
|
|
title={movWallet ? `Movimientos · ${movWallet.patient}` : 'Movimientos'}
|
|
maxWidth="lg"
|
|
footer={
|
|
<>
|
|
{puedeAjustar && (
|
|
<Button variant="outline" onClick={abrirAjuste}>
|
|
<SlidersHorizontal size={14} className="mr-1.5" />
|
|
Ajustar puntos
|
|
</Button>
|
|
)}
|
|
<Button variant="outline" onClick={() => setMovWallet(null)}>Cerrar</Button>
|
|
</>
|
|
}
|
|
>
|
|
<div className="space-y-4">
|
|
<div className="grid grid-cols-3 gap-3">
|
|
<div className="p-3 bg-theme-bg rounded-xl">
|
|
<p className="text-xs text-theme-muted">Saldo</p>
|
|
<p className="text-lg font-semibold text-theme-heading">{saldoMov} pts</p>
|
|
</div>
|
|
<div className="p-3 bg-theme-bg rounded-xl">
|
|
<p className="text-xs text-theme-muted">En pesos</p>
|
|
<p className="text-lg font-semibold text-theme-heading">{fmtMoney(saldoMov)}</p>
|
|
</div>
|
|
<div className="p-3 bg-theme-bg rounded-xl">
|
|
<p className="text-xs text-theme-muted">En dólares</p>
|
|
<p className="text-lg font-semibold text-theme-heading">{rate ? fmtUsd(saldoMov).replace('≈ ', '') : '—'}</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<div className="flex flex-wrap items-center gap-1.5">
|
|
{DATE_PRESETS.map((p) => (
|
|
<button
|
|
key={p.value}
|
|
type="button"
|
|
onClick={() => aplicarPresetMov(p.value)}
|
|
className={`px-3 py-1.5 rounded-full text-xs font-medium border transition-all ${
|
|
movPreset === p.value
|
|
? 'bg-theme-accent text-theme-inverse border-theme-accent'
|
|
: 'bg-theme-bg text-theme-muted border-theme-border hover:border-theme-border-strong'
|
|
}`}
|
|
>
|
|
{p.label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
<div className="flex flex-col sm:flex-row gap-2 sm:items-end">
|
|
<Select
|
|
options={TX_FILTER_OPTIONS}
|
|
value={movType}
|
|
onChange={(e) => aplicarTipoMov(e.target.value)}
|
|
className="sm:max-w-[220px]"
|
|
/>
|
|
<Input
|
|
label="Desde"
|
|
type="date"
|
|
value={movStart}
|
|
onChange={(e) => { setMovStart(e.target.value); setMovPreset('custom'); }}
|
|
className="sm:max-w-[150px]"
|
|
/>
|
|
<Input
|
|
label="Hasta"
|
|
type="date"
|
|
value={movEnd}
|
|
onChange={(e) => { setMovEnd(e.target.value); setMovPreset('custom'); }}
|
|
className="sm:max-w-[150px]"
|
|
/>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
className="mb-[1px]"
|
|
onClick={() => movWallet && loadMovimientos(movWallet, { type: movType, from: movStart, to: movEnd })}
|
|
>
|
|
Aplicar
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex items-center gap-3 text-xs text-theme-muted">
|
|
<span>{movimientos.length} movimientos</span>
|
|
<span className="text-emerald-700">+{movSum.pos} pts</span>
|
|
<span className="text-rose-600">{movSum.neg} pts</span>
|
|
</div>
|
|
|
|
{movLoading ? (
|
|
<Skeleton count={5} className="h-12 w-full" />
|
|
) : movimientos.length === 0 ? (
|
|
<p className="text-sm text-theme-muted py-6 text-center">Sin movimientos con estos filtros.</p>
|
|
) : (
|
|
<div className="divide-y divide-theme-border max-h-[45vh] overflow-y-auto">
|
|
{movimientos.map((m) => (
|
|
<div key={m.id} className="py-2.5 flex items-start justify-between gap-3">
|
|
<div className="min-w-0">
|
|
<p className="text-sm text-theme-heading">{m.narration}</p>
|
|
<p className="text-[11px] text-theme-muted mt-0.5">
|
|
{m.date || '—'}
|
|
{m.operator ? ` · registró ${m.operator}` : ''}
|
|
</p>
|
|
</div>
|
|
<div className="text-right shrink-0">
|
|
<p className={`text-sm font-semibold ${m.points > 0 ? 'text-emerald-700' : m.points < 0 ? 'text-rose-600' : 'text-theme-muted'}`}>
|
|
{m.points > 0 ? '+' : ''}{m.points} pts
|
|
</p>
|
|
<Badge variant={m.type === 'redemption' || m.type === 'expiration' ? 'danger' : m.type === 'adjustment' ? 'warning' : 'success'}>
|
|
{m.type_label}
|
|
</Badge>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</Modal>
|
|
|
|
{/* Ajuste manual auditado */}
|
|
<Modal
|
|
isOpen={ajusteOpen}
|
|
onClose={() => setAjusteOpen(false)}
|
|
title={movWallet ? `Ajustar puntos · ${movWallet.patient}` : 'Ajustar puntos'}
|
|
maxWidth="sm"
|
|
footer={
|
|
<>
|
|
<Button variant="outline" onClick={() => setAjusteOpen(false)}>Cancelar</Button>
|
|
<Button onClick={guardarAjuste} loading={ajustando}>Aplicar ajuste</Button>
|
|
</>
|
|
}
|
|
>
|
|
<div className="space-y-4">
|
|
<p className="text-sm text-theme-muted">
|
|
Saldo actual: <span className="font-medium text-theme-heading">{saldoMov} pts</span>
|
|
</p>
|
|
<div>
|
|
<p className="block text-xs font-medium mb-1.5 text-theme-muted">Tipo de ajuste</p>
|
|
<div className="inline-flex rounded-full border border-theme-border-strong overflow-hidden">
|
|
<button
|
|
type="button"
|
|
onClick={() => setAjusteSigno('sumar')}
|
|
className={`px-4 py-1.5 text-sm font-medium transition ${
|
|
ajusteSigno === 'sumar' ? 'bg-theme-accent text-theme-inverse' : 'bg-theme-surface text-theme-muted hover:bg-theme-bg'
|
|
}`}
|
|
>
|
|
Sumar puntos
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => setAjusteSigno('restar')}
|
|
className={`px-4 py-1.5 text-sm font-medium transition ${
|
|
ajusteSigno === 'restar' ? 'bg-theme-accent text-theme-inverse' : 'bg-theme-surface text-theme-muted hover:bg-theme-bg'
|
|
}`}
|
|
>
|
|
Restar puntos
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<Input
|
|
label="Puntos *"
|
|
type="number"
|
|
min={1}
|
|
value={ajustePuntos}
|
|
onChange={(e) => setAjustePuntos(e.target.value)}
|
|
/>
|
|
<TextArea
|
|
label="Motivo del ajuste *"
|
|
rows={2}
|
|
value={ajusteMotivo}
|
|
onChange={(e) => setAjusteMotivo(e.target.value)}
|
|
placeholder="Ej. cortesía, corrección de captura, promoción..."
|
|
/>
|
|
<p className="text-xs text-theme-muted">
|
|
El ajuste queda registrado en el historial del monedero con el motivo y el usuario que lo capturó.
|
|
Un ajuste negativo hace piso en 0 puntos.
|
|
</p>
|
|
</div>
|
|
</Modal>
|
|
</>
|
|
)}
|
|
</Layout>
|
|
);
|
|
};
|
|
|
|
export default Monedero;
|