Oleada UX 4: Pagos/Adeudos (cobro en 1 click con recibo, plan de pagos), Monedero (movimientos narrados, ajustes auditados, MXN+USD), Inventario (alta rapida, conteo fisico con ajuste e historial, alertas accionables) + fix de folios de pago y cantidades de movimientos

This commit is contained in:
2026-09-17 20:31:52 +00:00
parent ac5896b1fc
commit 9ee3343e22
20 changed files with 2173 additions and 165 deletions

View File

@@ -1,18 +1,21 @@
import type { FC } from 'react';
import { useCallback, useEffect, useState } from 'react';
import { useSearchParams } from 'react-router-dom';
import { Boxes, Plus, AlertTriangle, PackageCheck, ArrowDownToLine, ArrowUpFromLine, History, Wallet } from 'lucide-react';
import { Boxes, Plus, AlertTriangle, PackageCheck, ArrowDownToLine, ArrowUpFromLine, History, Wallet, FlaskConical, ClipboardCheck, Printer } from 'lucide-react';
import Layout from '../components/Layout';
import MovimientosPanel from '../components/inventario/MovimientosPanel';
import AlertasPanel from '../components/inventario/AlertasPanel';
import { Card, Button, Input, Select, Modal, PageHeader, toast } from '../components/ui';
import ConteoFisicoModal from '../components/inventario/ConteoFisicoModal';
import ConteoPrint from '../components/inventario/ConteoPrint';
import { Card, Button, Input, Select, Modal, PageHeader, EmptyState, Skeleton, toast } from '../components/ui';
import { odooApi } from '../services/odoo';
import type { InventoryItem, InventoryMove, InventoryKind, InventoryLevel, InventoryMoveType, InventorySummary } from '../services/odoo';
import type { InventoryItem, InventoryMove, InventoryKind, InventoryLevel, InventoryMoveType, InventorySummary, InventoryConteo } from '../services/odoo';
const TABS = [
{ key: 'items', label: 'Items' },
{ key: 'compras', label: 'Compras' },
{ key: 'bajas', label: 'Bajas' },
{ key: 'conteos', label: 'Conteos' },
{ key: 'alertas', label: 'Alertas' },
] as const;
@@ -84,6 +87,28 @@ const Inventario: FC = () => {
const [historyItem, setHistoryItem] = useState<InventoryItem | null>(null);
const [moves, setMoves] = useState<InventoryMove[]>([]);
// Conteo físico cíclico
const [conteoOpen, setConteoOpen] = useState(false);
const [conteoPrint, setConteoPrint] = useState<InventoryConteo | null>(null);
const [conteos, setConteos] = useState<InventoryConteo[]>([]);
const [conteosLoading, setConteosLoading] = useState(false);
const loadConteos = useCallback(async () => {
setConteosLoading(true);
try {
const res = await odooApi.getConteos(30);
setConteos(res.conteos || []);
} catch {
toast.error('No se pudo cargar el historial de conteos');
} finally {
setConteosLoading(false);
}
}, []);
useEffect(() => {
if (tab === 'conteos') loadConteos();
}, [tab, loadConteos]);
const load = useCallback(async () => {
if (tab !== 'items') return;
setLoading(true);
@@ -104,14 +129,32 @@ const Inventario: FC = () => {
useEffect(() => { load(); }, [load]);
// Alta rápida: abre el formulario ya en modo consumible con defaults sensatos
const nuevoConsumible = () => {
setNewForm({
...emptyItem,
kind: 'consumible',
unit: 'pieza',
qty_min: 5,
qty_optimal: 20,
});
setNewOpen(true);
};
const createItem = async () => {
if (!newForm.name) { toast.error('El nombre es obligatorio'); return; }
setSavingNew(true);
try {
await odooApi.createInventoryItem(newForm);
toast.success('Item creado');
toast.success(newForm.kind === 'consumible' ? 'Consumible creado' : 'Item creado');
setNewOpen(false);
setNewForm(emptyItem);
// Deja visible el artículo recién creado
if (newForm.kind === 'consumible') {
setKind('consumible');
setLevel('');
setSearch(newForm.name || '');
}
load();
} catch {
toast.error('Error al crear el item');
@@ -172,10 +215,18 @@ const Inventario: FC = () => {
title="Inventario y consumibles"
subtitle="Existencias, niveles y movimientos (compras, ventas, bajas, ajustes)"
>
<Button variant="outline" onClick={() => setConteoOpen(true)}>
<ClipboardCheck size={16} className="mr-2" /> Conteo físico
</Button>
{tab === 'items' && (
<Button onClick={() => setNewOpen(true)}>
<Plus size={16} className="mr-2" /> Nuevo item
</Button>
<>
<Button variant="outline" onClick={nuevoConsumible} title="Alta rápida de consumible">
<FlaskConical size={16} className="mr-2" /> Nuevo consumible
</Button>
<Button onClick={() => { setNewForm(emptyItem); setNewOpen(true); }}>
<Plus size={16} className="mr-2" /> Nuevo item
</Button>
</>
)}
</PageHeader>
@@ -201,6 +252,75 @@ const Inventario: FC = () => {
<MovimientosPanel tipo="baja" />
) : tab === 'alertas' ? (
<AlertasPanel />
) : tab === 'conteos' ? (
<>
{conteosLoading ? (
<Skeleton count={5} className="h-14 w-full" />
) : conteos.length === 0 ? (
<EmptyState
title="Sin conteos"
subtitle="Aún no se ha registrado ningún conteo físico."
actionLabel="Iniciar conteo"
onAction={() => setConteoOpen(true)}
icon={<ClipboardCheck size={28} />}
/>
) : (
<Card>
<Card.Body>
<p className="text-xs text-theme-muted mb-3">
Historial de conteos físicos. Cada conteo guarda la existencia del sistema, lo contado y los ajustes aplicados.
</p>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead className="bg-theme-bg text-theme-muted text-xs uppercase">
<tr>
<th className="text-left px-4 py-3">Conteo</th>
<th className="text-left px-4 py-3 hidden sm:table-cell">Realizó</th>
<th className="text-right px-4 py-3">Items</th>
<th className="text-right px-4 py-3">Con diferencia</th>
<th className="text-right px-4 py-3">Faltante</th>
<th className="text-right px-4 py-3">Sobrante</th>
<th className="text-right px-4 py-3">Acción</th>
</tr>
</thead>
<tbody className="divide-y divide-theme-border">
{conteos.map((c) => (
<tr key={c.id} className="hover:bg-theme-bg">
<td className="px-4 py-3">
<p className="font-medium text-theme-heading">{c.name}</p>
<p className="text-xs text-theme-muted">{c.date || '—'}{c.notes ? ` · ${c.notes}` : ''}</p>
</td>
<td className="px-4 py-3 text-theme-muted hidden sm:table-cell">{c.user_name || '—'}</td>
<td className="px-4 py-3 text-right text-theme-muted">{c.total_items}</td>
<td className="px-4 py-3 text-right text-theme-heading">{c.total_diferencias}</td>
<td className="px-4 py-3 text-right text-rose-600">{fmtMoney(c.total_faltante)}</td>
<td className="px-4 py-3 text-right text-emerald-700">{fmtMoney(c.total_sobrante)}</td>
<td className="px-4 py-3 text-right">
<Button
size="sm"
variant="outline"
onClick={async () => {
try {
const res = await odooApi.getConteo(c.id);
setConteoPrint(res.conteo);
} catch {
toast.error('No se pudo abrir el conteo');
}
}}
>
<Printer size={13} className="mr-1.5" />
Ver / imprimir
</Button>
</td>
</tr>
))}
</tbody>
</table>
</div>
</Card.Body>
</Card>
)}
</>
) : (
<>
{/* KPIs */}
@@ -370,6 +490,18 @@ const Inventario: FC = () => {
</div>
)}
</Modal>
{/* Conteo físico cíclico */}
<ConteoFisicoModal
isOpen={conteoOpen}
onClose={() => setConteoOpen(false)}
onSaved={(c) => {
setConteoPrint(c);
load();
if (tab === 'conteos') loadConteos();
}}
/>
<ConteoPrint conteo={conteoPrint} onClose={() => setConteoPrint(null)} />
</>
)}
</Layout>

View File

@@ -1,21 +1,31 @@
import type { FC } from 'react';
import { useEffect, useState, useCallback } from 'react';
import { useSearchParams } from 'react-router-dom';
import { Search, Plus, Minus, Wallet } from 'lucide-react';
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 } from '../services/odoo';
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';
@@ -28,6 +38,18 @@ 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);
@@ -39,6 +61,8 @@ const fmtMoney = (n: number) =>
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[]>([]);
@@ -60,6 +84,24 @@ const Monedero: FC = () => {
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 {
@@ -97,6 +139,26 @@ const Monedero: FC = () => {
}
}, [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]);
@@ -158,6 +220,73 @@ const Monedero: FC = () => {
}
};
// --- 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" />
@@ -224,6 +353,7 @@ const Monedero: FC = () => {
<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>
@@ -264,7 +394,7 @@ const Monedero: FC = () => {
<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">Equivalente</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>
@@ -275,11 +405,20 @@ const Monedero: FC = () => {
<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">
${w.equivalent_mxn}
<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"
@@ -313,10 +452,13 @@ const Monedero: FC = () => {
subtitle={w.phone}
rows={[
{ label: 'Puntos', value: `${w.points} pts` },
{ label: 'Equivalente', value: `$${w.equivalent_mxn}${rate ? ` (${fmtUsd(w.equivalent_mxn)} USD)` : ''}` },
{ 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>
@@ -396,6 +538,186 @@ const Monedero: FC = () => {
</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>