Initial commit: SKEEN Derma Experts - Sistema Integral de Gestión Clínica
- Frontend React (SKEEN Brand) con Vite, TypeScript, Tailwind - Frontend Homenest (versión alternativa) - Módulos Odoo 17 custom (citas, pacientes, monedero, pagos, ventas, inventario, whatsapp) - WACRM fork (Next.js 16 + Supabase) - Hermes + Bridge + Skills (Qwen3.6 via Nan Builders) - Scripts de migración y operación - Documentación extensiva en docs/
This commit is contained in:
265
frontend/src/pages/Monedero.tsx
Normal file
265
frontend/src/pages/Monedero.tsx
Normal file
@@ -0,0 +1,265 @@
|
||||
import type { FC } from 'react';
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
import { Search, Plus, Minus, Wallet } from 'lucide-react';
|
||||
import Layout from '../components/Layout';
|
||||
import {
|
||||
Card,
|
||||
Button,
|
||||
Input,
|
||||
TextArea,
|
||||
Modal,
|
||||
EmptyState,
|
||||
PageHeader,
|
||||
Skeleton,
|
||||
MobileCard,
|
||||
toast,
|
||||
} from '../components/ui';
|
||||
import { odooApi, type Wallet as WalletType } from '../services/odoo';
|
||||
|
||||
type TxType = 'accrual' | 'redemption';
|
||||
|
||||
const Monedero: FC = () => {
|
||||
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 load = useCallback(async () => {
|
||||
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]);
|
||||
|
||||
useEffect(() => {
|
||||
setPage(1);
|
||||
}, [search]);
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, [load]);
|
||||
|
||||
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);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Layout title="Monedero" subtitle="Puntos y recompensas">
|
||||
<PageHeader title="Monedero" subtitle="Busca por teléfono y gestiona puntos" />
|
||||
|
||||
<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">Equivalente MXN</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">${w.equivalent_mxn}</td>
|
||||
<td className="p-3">
|
||||
<div className="flex items-center gap-1">
|
||||
<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: 'Equivalente', value: `$${w.equivalent_mxn}` },
|
||||
]}
|
||||
actions={
|
||||
<>
|
||||
<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>
|
||||
</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>
|
||||
</Layout>
|
||||
);
|
||||
};
|
||||
|
||||
export default Monedero;
|
||||
Reference in New Issue
Block a user