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:
150
frontend/src/components/ReciboPrint.tsx
Normal file
150
frontend/src/components/ReciboPrint.tsx
Normal file
@@ -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<string, string> = {
|
||||
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 (
|
||||
<Modal isOpen={!!receipt} onClose={onClose} title={`Recibo de pago ${receipt.folio}`} maxWidth="md">
|
||||
<div className="print-recibo bg-white text-[#222] p-6 rounded-xl border border-theme-border text-[13px] leading-snug">
|
||||
<div className="text-center mb-4 border-b border-[#ccc] pb-3">
|
||||
<p className="font-bold text-[15px]">{clinic.name}</p>
|
||||
{clinic.address && <p className="text-[11px] text-[#555]">{clinic.address}</p>}
|
||||
{clinic.phone && <p className="text-[11px] text-[#555]">{clinic.phone}</p>}
|
||||
<p className="font-semibold mt-2 tracking-wide">RECIBO DE PAGO</p>
|
||||
<p className="text-[11px] text-[#555]">Folio {receipt.folio} · {receipt.date}</p>
|
||||
</div>
|
||||
|
||||
<table className="w-full text-[12px] mb-3">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td className="py-0.5 pr-2 text-[#555] w-32">Paciente</td>
|
||||
<td className="py-0.5 font-medium">{receipt.patient}</td>
|
||||
</tr>
|
||||
{receipt.patient_code && (
|
||||
<tr>
|
||||
<td className="py-0.5 pr-2 text-[#555]">Expediente</td>
|
||||
<td className="py-0.5">{receipt.patient_code}</td>
|
||||
</tr>
|
||||
)}
|
||||
{receipt.phone && (
|
||||
<tr>
|
||||
<td className="py-0.5 pr-2 text-[#555]">Teléfono</td>
|
||||
<td className="py-0.5">{receipt.phone}</td>
|
||||
</tr>
|
||||
)}
|
||||
<tr>
|
||||
<td className="py-0.5 pr-2 text-[#555]">Método de pago</td>
|
||||
<td className="py-0.5">{METODO_LABELS[receipt.payment_method] || receipt.payment_method}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="py-0.5 pr-2 text-[#555]">Registró</td>
|
||||
<td className="py-0.5">{receipt.operator || '—'}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{receipt.aplicado.length > 0 && (
|
||||
<table className="w-full text-[11px] mb-3">
|
||||
<thead>
|
||||
<tr className="border-y border-[#ccc] text-[10px] uppercase text-[#666]">
|
||||
<th className="text-left py-1">Venta</th>
|
||||
<th className="text-left py-1">Fecha</th>
|
||||
<th className="text-right py-1">Aplicado</th>
|
||||
<th className="text-right py-1">Saldo venta</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{receipt.aplicado.map((a) => (
|
||||
<tr key={a.id} className="border-b border-[#eee]">
|
||||
<td className="py-1">{a.name}</td>
|
||||
<td className="py-1 text-[#555]">{a.date || '—'}</td>
|
||||
<td className="py-1 text-right">{money(a.applied)}</td>
|
||||
<td className="py-1 text-right text-[#555]">{money(a.amount_due)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
|
||||
<table className="w-full text-[13px]">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td className="py-1 pr-2">Adeudo anterior</td>
|
||||
<td className="py-1 text-right">{money(receipt.adeudo_anterior)}</td>
|
||||
</tr>
|
||||
<tr className="font-bold border-y border-[#1A1A1A]">
|
||||
<td className="py-1.5 pr-2">Total recibido</td>
|
||||
<td className="py-1.5 text-right">{money(receipt.amount)}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="py-1 pr-2">Saldo restante</td>
|
||||
<td className={`py-1 text-right font-semibold ${receipt.saldo_restante > 0 ? 'text-rose-700' : 'text-emerald-700'}`}>
|
||||
{money(receipt.saldo_restante)}
|
||||
</td>
|
||||
</tr>
|
||||
{receipt.wallet_points > 0 && (
|
||||
<tr>
|
||||
<td className="py-1 pr-2 text-[#555]">Puntos monedero</td>
|
||||
<td className="py-1 text-right text-[#555]">{receipt.wallet_points} pts</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<p className="mt-4 text-[11px] text-[#555]">
|
||||
{receipt.saldo_restante > 0
|
||||
? 'El paciente mantiene saldo pendiente. Este recibo ampara el pago parcial recibido.'
|
||||
: 'El adeudo queda liquidado. Gracias por su pago.'}
|
||||
</p>
|
||||
|
||||
<div className="mt-6 flex justify-between text-[11px] text-[#555]">
|
||||
<span>_____________________<br />Recibió (caja)</span>
|
||||
<span>_____________________<br />Paciente</span>
|
||||
</div>
|
||||
<p className="mt-4 text-center text-[10px] text-[#888]">Impreso el {new Date().toLocaleString('es-MX')}</p>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end mt-3">
|
||||
<Button onClick={() => window.print()}>
|
||||
<Printer size={14} className="mr-1.5" />
|
||||
Imprimir
|
||||
</Button>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default ReciboPrint;
|
||||
@@ -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 = () => {
|
||||
<th className="text-right px-4 py-3">Existencia</th>
|
||||
<th className="text-left px-4 py-3">Caduca</th>
|
||||
<th className="text-left px-4 py-3">Estado</th>
|
||||
<th className="text-right px-4 py-3">Acción</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-theme-border">
|
||||
@@ -107,6 +111,21 @@ const AlertasPanel: FC = () => {
|
||||
<td className="px-4 py-3 text-right text-theme-muted">{c.qty} {c.unit}</td>
|
||||
<td className="px-4 py-3 text-theme-heading">{c.expiry_date}</td>
|
||||
<td className="px-4 py-3"><Badge variant={b.variant}>{b.label}</Badge></td>
|
||||
<td className="px-4 py-3 text-right">
|
||||
{c.vencido && c.qty > 0 ? (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => setMovModal({ tipo: 'baja', itemId: c.id, qty: c.qty })}
|
||||
title="Dar de baja el artículo caducado"
|
||||
>
|
||||
<ArrowUpFromLine size={13} className="mr-1.5" />
|
||||
Dar de baja
|
||||
</Button>
|
||||
) : (
|
||||
<span className="text-xs text-theme-muted">—</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
@@ -144,7 +163,8 @@ const AlertasPanel: FC = () => {
|
||||
<th className="text-right px-4 py-3 hidden sm:table-cell">Mínimo</th>
|
||||
<th className="text-right px-4 py-3 hidden md:table-cell">Óptimo</th>
|
||||
<th className="text-right px-4 py-3">Sugerido</th>
|
||||
<th className="text-right px-4 py-3">Costo est.</th>
|
||||
<th className="text-right px-4 py-3 hidden md:table-cell">Costo est.</th>
|
||||
<th className="text-right px-4 py-3">Acción</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-theme-border">
|
||||
@@ -155,7 +175,18 @@ const AlertasPanel: FC = () => {
|
||||
<td className="px-4 py-3 text-right text-theme-muted hidden sm:table-cell">{s.qty_min || '-'}</td>
|
||||
<td className="px-4 py-3 text-right text-theme-muted hidden md:table-cell">{s.qty_optimal || '-'}</td>
|
||||
<td className="px-4 py-3 text-right font-medium text-theme-heading">+{s.sugerido} {s.unit}</td>
|
||||
<td className="px-4 py-3 text-right text-theme-heading">{fmtMoney(s.costo_estimado)}</td>
|
||||
<td className="px-4 py-3 text-right text-theme-heading hidden md:table-cell">{fmtMoney(s.costo_estimado)}</td>
|
||||
<td className="px-4 py-3 text-right">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => setMovModal({ tipo: 'compra', itemId: s.id, qty: s.sugerido })}
|
||||
title="Registrar la compra sugerida de este artículo"
|
||||
>
|
||||
<ShoppingCart size={13} className="mr-1.5" />
|
||||
Registrar compra
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
@@ -171,6 +202,14 @@ const AlertasPanel: FC = () => {
|
||||
)}
|
||||
</Card.Body>
|
||||
</Card>
|
||||
<MovimientoModal
|
||||
isOpen={!!movModal}
|
||||
tipo={movModal?.tipo || 'compra'}
|
||||
initialItemId={movModal?.itemId ?? null}
|
||||
initialQty={movModal?.qty ?? null}
|
||||
onClose={() => setMovModal(null)}
|
||||
onSaved={() => { load(); }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
204
frontend/src/components/inventario/ConteoFisicoModal.tsx
Normal file
204
frontend/src/components/inventario/ConteoFisicoModal.tsx
Normal file
@@ -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<ConteoFisicoModalProps> = ({ isOpen, onClose, onSaved }) => {
|
||||
const [items, setItems] = useState<InventoryItem[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [search, setSearch] = useState('');
|
||||
const [contados, setContados] = useState<Record<number, string>>({});
|
||||
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 (
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
title="Conteo físico de inventario"
|
||||
maxWidth="2xl"
|
||||
footer={
|
||||
<div className="flex flex-wrap items-center justify-between gap-2 w-full">
|
||||
<div className="text-xs text-theme-muted">
|
||||
{resumen.dif} con diferencia · faltante <span className="text-rose-600 font-medium">{money(resumen.faltante)}</span> ·
|
||||
sobrante <span className="text-emerald-700 font-medium">{money(resumen.sobrante)}</span> · neto{' '}
|
||||
<span className="font-medium text-theme-heading">{money(resumen.neto)}</span>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" onClick={onClose}>Cancelar</Button>
|
||||
<Button onClick={guardar} loading={saving}>
|
||||
<ClipboardCheck size={14} className="mr-1.5" />
|
||||
Guardar conteo
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<p className="text-xs text-theme-muted">
|
||||
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 <span className="font-medium">ajuste</span> por
|
||||
cada diferencia y queda el historial del conteo.
|
||||
</p>
|
||||
<div className="relative">
|
||||
<Search size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-theme-muted" />
|
||||
<Input
|
||||
placeholder="Filtrar artículos por nombre, SKU o categoría..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="pl-10"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<Skeleton count={8} className="h-10 w-full" />
|
||||
) : items.length === 0 ? (
|
||||
<p className="text-sm text-theme-muted py-6 text-center">No hay artículos activos en el inventario.</p>
|
||||
) : (
|
||||
<div className="max-h-[45vh] overflow-y-auto border border-theme-border rounded-xl">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-theme-bg text-theme-muted text-xs uppercase sticky top-0">
|
||||
<tr>
|
||||
<th className="text-left px-3 py-2">Artículo</th>
|
||||
<th className="text-left px-3 py-2 hidden sm:table-cell">Tipo</th>
|
||||
<th className="text-right px-3 py-2">Sistema</th>
|
||||
<th className="text-right px-3 py-2 w-32">Contado</th>
|
||||
<th className="text-right px-3 py-2">Diferencia</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-theme-border">
|
||||
{visibles.map((it) => {
|
||||
const diff = valorContado(it) - it.qty;
|
||||
return (
|
||||
<tr key={it.id} className="hover:bg-theme-bg">
|
||||
<td className="px-3 py-2">
|
||||
<p className="font-medium text-theme-heading">{it.name}</p>
|
||||
<p className="text-xs text-theme-muted">{[it.sku, it.category].filter(Boolean).join(' · ') || '—'}</p>
|
||||
</td>
|
||||
<td className="px-3 py-2 hidden sm:table-cell">
|
||||
<span className={`text-xs px-2 py-0.5 rounded-full ${it.kind === 'consumible' ? 'bg-sky-100 text-sky-700' : 'bg-violet-100 text-violet-700'}`}>
|
||||
{it.kind === 'consumible' ? 'Consumible' : 'Producto'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-3 py-2 text-right text-theme-muted">{it.qty} {it.unit}</td>
|
||||
<td className="px-3 py-2 text-right">
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
step={0.01}
|
||||
aria-label={`Contado ${it.name}`}
|
||||
value={contados[it.id] ?? String(it.qty)}
|
||||
onChange={(e) => 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'
|
||||
}`}
|
||||
/>
|
||||
</td>
|
||||
<td className={`px-3 py-2 text-right font-medium ${
|
||||
diff < 0 ? 'text-rose-600' : diff > 0 ? 'text-emerald-700' : 'text-theme-muted'
|
||||
}`}>
|
||||
{Math.abs(diff) < 1e-9 ? '—' : `${diff > 0 ? '+' : ''}${Math.round(diff * 100) / 100}`}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Input
|
||||
label="Notas del conteo"
|
||||
value={notes}
|
||||
onChange={(e) => setNotes(e.target.value)}
|
||||
placeholder="Ej. conteo mensual de consumibles"
|
||||
/>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default ConteoFisicoModal;
|
||||
118
frontend/src/components/inventario/ConteoPrint.tsx
Normal file
118
frontend/src/components/inventario/ConteoPrint.tsx
Normal file
@@ -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 (
|
||||
<Modal isOpen={!!conteo} onClose={onClose} title={`Conteo físico ${conteo.name}`} maxWidth="lg">
|
||||
<div className="print-recibo bg-white text-[#222] p-6 rounded-xl border border-theme-border text-[13px] leading-snug">
|
||||
<div className="text-center mb-4 border-b border-[#ccc] pb-3">
|
||||
<p className="font-bold text-[15px]">{clinic.name}</p>
|
||||
<p className="font-semibold mt-2 tracking-wide">CONTEO FÍSICO DE INVENTARIO</p>
|
||||
<p className="text-[11px] text-[#555]">
|
||||
{conteo.name} · {conteo.date || '—'} · Realizó: {conteo.user_name || '—'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<table className="w-full text-[11px]">
|
||||
<thead>
|
||||
<tr className="border-y border-[#ccc] text-[10px] uppercase text-[#666]">
|
||||
<th className="text-left py-1">Artículo</th>
|
||||
<th className="text-right py-1 w-20">Sistema</th>
|
||||
<th className="text-right py-1 w-20">Contado</th>
|
||||
<th className="text-right py-1 w-24">Diferencia</th>
|
||||
<th className="text-right py-1 w-24">Valor</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{lineas.map((l) => (
|
||||
<tr key={l.id} className="border-b border-[#eee]">
|
||||
<td className="py-1">{l.item}</td>
|
||||
<td className="py-1 text-right text-[#555]">{l.qty_sistema} {l.unit}</td>
|
||||
<td className="py-1 text-right">{l.qty_contado} {l.unit}</td>
|
||||
<td className={`py-1 text-right font-semibold ${l.difference < 0 ? 'text-rose-700' : l.difference > 0 ? 'text-emerald-700' : 'text-[#555]'}`}>
|
||||
{l.difference > 0 ? '+' : ''}{l.difference}
|
||||
</td>
|
||||
<td className="py-1 text-right text-[#555]">{money(l.value_diff)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<table className="w-full text-[12px] mt-3">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td className="py-0.5 pr-2">Artículos contados</td>
|
||||
<td className="py-0.5 text-right font-medium">{conteo.total_items}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="py-0.5 pr-2">Artículos con diferencia</td>
|
||||
<td className="py-0.5 text-right font-medium">{conteo.total_diferencias}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="py-0.5 pr-2">Faltante valorizado</td>
|
||||
<td className="py-0.5 text-right font-medium text-rose-700">{money(conteo.total_faltante)}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="py-0.5 pr-2">Sobrante valorizado</td>
|
||||
<td className="py-0.5 text-right font-medium text-emerald-700">{money(conteo.total_sobrante)}</td>
|
||||
</tr>
|
||||
<tr className="border-t border-[#1A1A1A] font-bold">
|
||||
<td className="py-1 pr-2">Diferencia neta</td>
|
||||
<td className="py-1 text-right">{money(conteo.total_value_diff)}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{conteo.notes && (
|
||||
<div className="mt-3 text-[12px]">
|
||||
<p className="font-semibold">Notas:</p>
|
||||
<p className="whitespace-pre-wrap">{conteo.notes}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-6 flex justify-between text-[11px] text-[#555]">
|
||||
<span>_____________________<br />Contó: {conteo.user_name || ''}</span>
|
||||
<span>_____________________<br />Revisó (administración)</span>
|
||||
</div>
|
||||
<p className="mt-4 text-center text-[10px] text-[#888]">Impreso el {new Date().toLocaleString('es-MX')}</p>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end mt-3">
|
||||
<Button onClick={() => window.print()}>
|
||||
<Printer size={14} className="mr-1.5" />
|
||||
Imprimir
|
||||
</Button>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default ConteoPrint;
|
||||
171
frontend/src/components/inventario/MovimientoModal.tsx
Normal file
171
frontend/src/components/inventario/MovimientoModal.tsx
Normal file
@@ -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<MovimientoModalProps> = ({
|
||||
isOpen,
|
||||
tipo,
|
||||
initialItemId = null,
|
||||
initialQty = null,
|
||||
onClose,
|
||||
onSaved,
|
||||
}) => {
|
||||
const esCompra = tipo === 'compra';
|
||||
const [items, setItems] = useState<InventoryItem[]>([]);
|
||||
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 (
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
title={esCompra ? 'Registrar compra' : 'Registrar baja / merma'}
|
||||
maxWidth="md"
|
||||
footer={
|
||||
<>
|
||||
<Button variant="outline" onClick={onClose}>Cancelar</Button>
|
||||
<Button onClick={guardar} loading={saving}>{esCompra ? 'Registrar compra' : 'Registrar baja'}</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<Input
|
||||
placeholder="Buscar artículo..."
|
||||
value={itemSearch}
|
||||
onChange={(e) => setItemSearch(e.target.value)}
|
||||
/>
|
||||
<Select
|
||||
label="Artículo *"
|
||||
options={[
|
||||
{ value: '', label: 'Selecciona artículo...' },
|
||||
...items.map((i) => ({ value: String(i.id), label: `${i.name} (${i.qty} ${i.unit})` })),
|
||||
]}
|
||||
value={form.item_id}
|
||||
onChange={(e) => setForm({ ...form, item_id: e.target.value })}
|
||||
/>
|
||||
<Input
|
||||
label="Cantidad *"
|
||||
type="number"
|
||||
min="0.5"
|
||||
step="0.5"
|
||||
value={form.qty}
|
||||
onChange={(e) => setForm({ ...form, qty: e.target.value })}
|
||||
/>
|
||||
{esCompra && (
|
||||
<Input
|
||||
label="Costo unitario (actualiza el costo del artículo)"
|
||||
type="number"
|
||||
min="0"
|
||||
step="0.01"
|
||||
value={form.cost}
|
||||
onChange={(e) => setForm({ ...form, cost: e.target.value })}
|
||||
placeholder="Opcional"
|
||||
/>
|
||||
)}
|
||||
<Input
|
||||
label={esCompra ? 'Referencia (proveedor / factura)' : 'Referencia'}
|
||||
value={form.reference}
|
||||
onChange={(e) => setForm({ ...form, reference: e.target.value })}
|
||||
/>
|
||||
<Input
|
||||
label={esCompra ? 'Notas' : 'Motivo'}
|
||||
value={form.notes}
|
||||
onChange={(e) => setForm({ ...form, notes: e.target.value })}
|
||||
placeholder={esCompra ? undefined : 'Ej. caducado, dañado, merma'}
|
||||
/>
|
||||
{esCompra && form.cost !== '' && (
|
||||
<p className="text-xs text-theme-muted">
|
||||
Costo total estimado: {fmtMoney((parseFloat(form.qty) || 0) * (parseFloat(form.cost) || 0))}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default MovimientoModal;
|
||||
@@ -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<MovimientosPanelProps> = ({ tipo }) => {
|
||||
const [total, setTotal] = useState(0);
|
||||
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [items, setItems] = useState<InventoryItem[]>([]);
|
||||
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<MovimientosPanelProps> = ({ 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<MovimientosPanelProps> = ({ tipo }) => {
|
||||
</Card.Body>
|
||||
</Card>
|
||||
|
||||
{/* Modal registrar movimiento */}
|
||||
<Modal
|
||||
<MovimientoModal
|
||||
isOpen={modalOpen}
|
||||
tipo={tipo}
|
||||
onClose={() => setModalOpen(false)}
|
||||
title={esCompra ? 'Registrar compra' : 'Registrar baja / merma'}
|
||||
maxWidth="md"
|
||||
footer={
|
||||
<>
|
||||
<Button variant="outline" onClick={() => setModalOpen(false)}>Cancelar</Button>
|
||||
<Button onClick={guardar} loading={saving}>{esCompra ? 'Registrar compra' : 'Registrar baja'}</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<Input
|
||||
placeholder="Buscar artículo..."
|
||||
value={itemSearch}
|
||||
onChange={(e) => setItemSearch(e.target.value)}
|
||||
/>
|
||||
<Select
|
||||
label="Artículo *"
|
||||
options={[
|
||||
{ value: '', label: 'Selecciona artículo...' },
|
||||
...items.map((i) => ({ value: String(i.id), label: `${i.name} (${i.qty} ${i.unit})` })),
|
||||
]}
|
||||
value={form.item_id}
|
||||
onChange={(e) => setForm({ ...form, item_id: e.target.value })}
|
||||
/>
|
||||
<Input
|
||||
label="Cantidad *"
|
||||
type="number"
|
||||
min="0.5"
|
||||
step="0.5"
|
||||
value={form.qty}
|
||||
onChange={(e) => setForm({ ...form, qty: e.target.value })}
|
||||
/>
|
||||
{esCompra && (
|
||||
<Input
|
||||
label="Costo unitario (actualiza el costo del artículo)"
|
||||
type="number"
|
||||
min="0"
|
||||
step="0.01"
|
||||
value={form.cost}
|
||||
onChange={(e) => setForm({ ...form, cost: e.target.value })}
|
||||
placeholder="Opcional"
|
||||
/>
|
||||
)}
|
||||
<Input
|
||||
label={esCompra ? 'Referencia (proveedor / factura)' : 'Referencia'}
|
||||
value={form.reference}
|
||||
onChange={(e) => setForm({ ...form, reference: e.target.value })}
|
||||
/>
|
||||
<Input
|
||||
label={esCompra ? 'Notas' : 'Motivo'}
|
||||
value={form.notes}
|
||||
onChange={(e) => setForm({ ...form, notes: e.target.value })}
|
||||
/>
|
||||
{esCompra && form.cost !== '' && (
|
||||
<p className="text-xs text-theme-muted">
|
||||
Costo total estimado: {fmtMoney((parseFloat(form.qty) || 0) * (parseFloat(form.cost) || 0))}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</Modal>
|
||||
onSaved={() => { load(); }}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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<AdeudoRow[]>([]);
|
||||
const [totalCartera, setTotalCartera] = useState(0);
|
||||
const [totalPacientes, setTotalPacientes] = useState(0);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
// Cobro del adeudo
|
||||
const [pagoAdeudo, setPagoAdeudo] = useState<AdeudoRow | null>(null);
|
||||
const [monto, setMonto] = useState('');
|
||||
const [metodo, setMetodo] = useState('cash');
|
||||
const [cobrando, setCobrando] = useState(false);
|
||||
const [recibo, setRecibo] = useState<ReceiptData | null>(null);
|
||||
|
||||
// Plan de pagos
|
||||
const [planAdeudo, setPlanAdeudo] = useState<AdeudoRow | null>(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 (
|
||||
<div className="space-y-4 sm:space-y-6">
|
||||
<div className="flex justify-end">
|
||||
@@ -94,9 +234,10 @@ const AdeudosReport: FC = () => {
|
||||
<tr>
|
||||
<th className="text-left px-4 py-3">Paciente</th>
|
||||
<th className="text-left px-4 py-3 hidden sm:table-cell">Teléfono</th>
|
||||
<th className="text-right px-4 py-3"># Ventas</th>
|
||||
<th className="text-right px-4 py-3">Adeudo</th>
|
||||
<th className="text-left px-4 py-3 hidden lg:table-cell">Plan</th>
|
||||
<th className="text-left px-4 py-3 hidden md:table-cell">Última venta</th>
|
||||
<th className="text-right px-4 py-3">Acciones</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-theme-border">
|
||||
@@ -112,9 +253,39 @@ const AdeudosReport: FC = () => {
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-theme-muted hidden sm:table-cell">{a.phone || '-'}</td>
|
||||
<td className="px-4 py-3 text-right text-theme-muted">{a.num_ventas}</td>
|
||||
<td className="px-4 py-3 text-right font-semibold text-rose-600">{formatCurrency(a.total_adeudo)}</td>
|
||||
<td className="px-4 py-3 hidden lg:table-cell">
|
||||
{a.plan ? (
|
||||
<span
|
||||
className="inline-flex items-center gap-1.5 rounded-full border border-violet-300 bg-violet-50 px-2.5 py-1 text-[11px] font-medium text-violet-800"
|
||||
title={a.plan.note || `${a.plan.paid_count} de ${a.plan.count} pagos cubiertos`}
|
||||
>
|
||||
<CalendarClock size={11} />
|
||||
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)}` : ''}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-xs text-theme-muted">—</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-theme-muted hidden md:table-cell">{a.ultima_venta || '-'}</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
<Button size="sm" onClick={() => abrirPago(a)} title="Registrar pago del adeudo">
|
||||
<CreditCard size={14} className="mr-1.5" />
|
||||
Registrar pago
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => abrirPlan(a)}
|
||||
title="Acordar plan de pagos"
|
||||
>
|
||||
<CalendarClock size={14} />
|
||||
</Button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
@@ -123,6 +294,138 @@ const AdeudosReport: FC = () => {
|
||||
)}
|
||||
</Card.Body>
|
||||
</Card>
|
||||
|
||||
{/* Modal registrar pago del adeudo */}
|
||||
<Modal
|
||||
isOpen={!!pagoAdeudo}
|
||||
onClose={() => setPagoAdeudo(null)}
|
||||
title={pagoAdeudo ? `Registrar pago · ${pagoAdeudo.name}` : 'Registrar pago'}
|
||||
maxWidth="sm"
|
||||
footer={
|
||||
<>
|
||||
<Button variant="outline" onClick={() => setPagoAdeudo(null)}>Cancelar</Button>
|
||||
<Button onClick={cobrar} loading={cobrando}>Registrar pago</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
{pagoAdeudo && (
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="p-3 bg-theme-bg rounded-xl">
|
||||
<p className="text-xs text-theme-muted mb-1">Adeudo pendiente</p>
|
||||
<p className="text-lg font-semibold text-rose-600">{formatCurrency2(pagoAdeudo.total_adeudo)}</p>
|
||||
</div>
|
||||
<div className="p-3 bg-theme-bg rounded-xl">
|
||||
<p className="text-xs text-theme-muted mb-1">Ventas con saldo</p>
|
||||
<p className="text-lg font-semibold text-theme-heading">{pagoAdeudo.num_ventas}</p>
|
||||
</div>
|
||||
</div>
|
||||
<Input
|
||||
label="Monto a pagar"
|
||||
type="number"
|
||||
min={0.01}
|
||||
step={0.01}
|
||||
max={pagoAdeudo.total_adeudo}
|
||||
value={monto}
|
||||
onChange={(e) => setMonto(e.target.value)}
|
||||
/>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{[0.25, 0.5, 1].map((f) => (
|
||||
<Button
|
||||
key={f}
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => setMonto(String(Math.round(pagoAdeudo.total_adeudo * f * 100) / 100))}
|
||||
>
|
||||
{f === 1 ? 'Liquidar todo' : `${f * 100}%`}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
<Select label="Método de pago" options={METODOS} value={metodo} onChange={(e) => setMetodo(e.target.value)} />
|
||||
{pagoAdeudo.plan && (
|
||||
<p className="text-xs text-violet-800 bg-violet-50 border border-violet-200 rounded-lg p-2.5">
|
||||
Plan vigente: {pagoAdeudo.plan.count} pagos de {formatCurrency2(pagoAdeudo.plan.amount)}
|
||||
{pagoAdeudo.plan.next_payment_date ? ` · siguiente ${pagoAdeudo.plan.next_payment_date}` : ''}
|
||||
{` · ${pagoAdeudo.plan.remaining_count} por cubrir`}
|
||||
</p>
|
||||
)}
|
||||
<p className="text-xs text-theme-muted">
|
||||
El pago se aplica a las ventas con saldo, de la más antigua a la más reciente. Se puede pagar parcial.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
|
||||
{/* Modal plan de pagos */}
|
||||
<Modal
|
||||
isOpen={!!planAdeudo}
|
||||
onClose={() => setPlanAdeudo(null)}
|
||||
title={planAdeudo ? `Plan de pagos · ${planAdeudo.name}` : 'Plan de pagos'}
|
||||
maxWidth="sm"
|
||||
footer={
|
||||
<>
|
||||
{planAdeudo?.plan && (
|
||||
<Button variant="ghost" onClick={quitarPlan} loading={guardandoPlan}>
|
||||
<X size={14} className="mr-1.5" />
|
||||
Quitar plan
|
||||
</Button>
|
||||
)}
|
||||
<Button variant="outline" onClick={() => setPlanAdeudo(null)}>Cancelar</Button>
|
||||
<Button onClick={guardarPlan} loading={guardandoPlan}>Guardar plan</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
{planAdeudo && (
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-theme-muted">
|
||||
Adeudo actual: <span className="font-semibold text-rose-600">{formatCurrency2(planAdeudo.total_adeudo)}</span>
|
||||
</p>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Input
|
||||
label="Pagos acordados *"
|
||||
type="number"
|
||||
min={1}
|
||||
step={1}
|
||||
value={planCount}
|
||||
onChange={(e) => recalcularMonto(e.target.value)}
|
||||
/>
|
||||
<Input
|
||||
label="Monto por pago *"
|
||||
type="number"
|
||||
min={0.01}
|
||||
step={0.01}
|
||||
value={planAmount}
|
||||
onChange={(e) => setPlanAmount(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<Input
|
||||
label="Fecha del siguiente pago"
|
||||
type="date"
|
||||
value={planNext}
|
||||
onChange={(e) => setPlanNext(e.target.value)}
|
||||
/>
|
||||
<TextArea
|
||||
label="Nota del acuerdo"
|
||||
rows={2}
|
||||
value={planNote}
|
||||
onChange={(e) => setPlanNote(e.target.value)}
|
||||
placeholder="Ej. acordado por teléfono, paga los días 15"
|
||||
/>
|
||||
{planAdeudo.plan && (
|
||||
<p className="text-xs text-theme-muted">
|
||||
Plan actual: {planAdeudo.plan.count} pagos de {formatCurrency2(planAdeudo.plan.amount)} ·{' '}
|
||||
{planAdeudo.plan.paid_count} cubiertos, {planAdeudo.plan.remaining_count} por cubrir
|
||||
{planAdeudo.plan.by ? ` · registrado por ${planAdeudo.plan.by}` : ''}
|
||||
</p>
|
||||
)}
|
||||
<p className="text-xs text-theme-muted">
|
||||
Solo registro y seguimiento: el sistema no envía recordatorios automáticos.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
|
||||
<ReciboPrint receipt={recibo} onClose={() => setRecibo(null)} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user