import type { FC } from 'react'; import { Fragment, useEffect, useState, useCallback } from 'react'; import { Plus, Trash2, CreditCard, ShoppingCart, Search, Undo2, Download, Printer, ChevronDown, ChevronRight, Ban } from 'lucide-react'; import Layout from '../components/Layout'; import { usePatientDrawer } from '../components/PatientDrawer'; import PatientCombobox from '../components/PatientCombobox'; import TicketPrint from '../components/TicketPrint'; import { Card, Button, Select, Input, TextArea, Modal, Badge, EmptyState, PageHeader, Skeleton, MobileCard, toast, badgeForSaleState, } from '../components/ui'; import { odooApi, type Sale, type SaleLine, type Service, type Doctor } from '../services/odoo'; import { exportToExcel } from '../lib/exporter'; import { useAuth } from '../lib/auth'; import { DATE_PRESETS, rangeForPreset, readDateFilterPrefs, writeDateFilterPrefs, type DatePreset } from '../lib/dateRange'; // Rango de fechas recordado entre visitas (igual que los presets de la agenda) const FILTERS_KEY = 'skeen_ventas_filtros'; const ROLES_CANCELAN = ['admin', 'gerencia']; interface LineForm { service_id: string; description: string; quantity: string; price_unit: string; is_prescribed: boolean; prescribed_by_id: string; } const emptyLine = (): LineForm => ({ service_id: '', description: '', quantity: '1', price_unit: '', is_prescribed: false, prescribed_by_id: '', }); const paymentMethods = ['Efectivo', 'Tarjeta', 'Transferencia', 'MercadoPago']; const Ventas: FC = () => { const { openPatient } = usePatientDrawer(); const { user, brand } = useAuth(); const puedeCancelar = ROLES_CANCELAN.includes(user?.role || ''); const [sales, setSales] = useState([]); const [services, setServices] = useState([]); const [doctors, setDoctors] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [createOpen, setCreateOpen] = useState(false); const [payOpen, setPayOpen] = useState(null); const [patientId, setPatientId] = useState(''); const [lines, setLines] = useState([ emptyLine(), ]); const [submitting, setSubmitting] = useState(false); const [paying, setPaying] = useState(false); const [payMethod, setPayMethod] = useState('Efectivo'); const [payAmount, setPayAmount] = useState(''); const [refundOpen, setRefundOpen] = useState(null); const [refunding, setRefunding] = useState(false); const [refundAmount, setRefundAmount] = useState(''); const [refundReason, setRefundReason] = useState(''); // Cancelación con nota de crédito (admin/gerencia) const [cancelOpen, setCancelOpen] = useState(null); const [cancelReason, setCancelReason] = useState(''); const [cancelling, setCancelling] = useState(false); // Detalle expandido en línea (varias ventas a la vez, sin modales) const [expandedId, setExpandedId] = useState(null); // Reimprimir ticket de una venta ya registrada const [printSale, setPrintSale] = useState(null); const [search, setSearch] = useState(''); const [page, setPage] = useState(1); const [pageSize, setPageSize] = useState(50); const [total, setTotal] = useState(0); const [totalPages, setTotalPages] = useState(0); // ---- Rango de fechas recordado (presets) ---- const saved = readDateFilterPrefs(FILTERS_KEY); const [preset, setPreset] = useState(saved?.preset || 'mes'); const [start, setStart] = useState(saved?.start || rangeForPreset('mes').start); const [end, setEnd] = useState(saved?.end || rangeForPreset('mes').end); const aplicarPreset = (p: DatePreset) => { setPreset(p); if (p !== 'custom') { const r = rangeForPreset(p); setStart(r.start); setEnd(r.end); writeDateFilterPrefs(FILTERS_KEY, { preset: p, ...r }); } else { writeDateFilterPrefs(FILTERS_KEY, { preset: p, start, end }); } }; useEffect(() => { writeDateFilterPrefs(FILTERS_KEY, { preset, start, end }); }, [preset, start, end]); const loadSales = useCallback(async () => { try { setLoading(true); setError(null); const res = await odooApi.getSales({ search, page, page_size: pageSize, ...(preset !== 'custom' || (start && end) ? { date_from: start, date_to: end } : {}), }); if (res.status === 'success') { setSales(res.sales); setTotal(res.total ?? 0); setTotalPages(res.total_pages ?? 0); } } catch (err) { setError('Error al cargar ventas'); toast.error('Error al cargar ventas'); console.error(err); } finally { setLoading(false); } }, [search, page, pageSize, start, end, preset]); const loadRefs = useCallback(async () => { try { const servicesRes = await odooApi.getServices(); if (servicesRes.status === 'success') setServices(servicesRes.services); } catch (err) { console.error(err); } try { const doctorsRes = await odooApi.getDoctors(); if (doctorsRes.status === 'success') setDoctors(doctorsRes.doctors); } catch (err) { console.error(err); } }, []); useEffect(() => { loadRefs(); }, [loadRefs]); useEffect(() => { loadSales(); }, [loadSales]); useEffect(() => { setPage(1); }, [search]); const addLine = () => setLines([...lines, emptyLine()]); const removeLine = (index: number) => setLines(lines.filter((_, i) => i !== index)); const updateLine = (index: number, field: keyof LineForm, value: string | boolean) => { const next = [...lines]; next[index] = { ...next[index], [field]: value }; setLines(next); }; const create = async () => { if (!patientId) { toast.error('Selecciona un paciente'); return; } const parsedLines: Omit[] = lines .filter((l) => l.service_id) .map((l): Omit | null => { const qty = parseFloat(l.quantity); const price = parseFloat(l.price_unit); if (Number.isNaN(qty) || qty <= 0 || Number.isNaN(price) || price < 0) { return null; } return { service_id: parseInt(l.service_id, 10), description: l.description, quantity: qty, price_unit: price, is_prescribed: l.is_prescribed, prescribed_by_id: l.is_prescribed && l.prescribed_by_id ? parseInt(l.prescribed_by_id, 10) : null, }; }) .filter((l): l is Omit => l !== null); if (parsedLines.some((l) => l === null)) { toast.error('Revisa cantidades y precios de las líneas'); return; } if (parsedLines.length === 0) { toast.error('Agrega al menos una línea'); return; } try { setSubmitting(true); await odooApi.createSale({ patient_id: parseInt(patientId, 10), lines: parsedLines, }); toast.success('Venta creada'); setCreateOpen(false); setPatientId(''); setLines([emptyLine()]); await loadSales(); } catch (err) { toast.error('Error al crear venta'); console.error(err); } finally { setSubmitting(false); } }; const openPay = (sale: Sale) => { setPayOpen(sale); setPayMethod('Efectivo'); setPayAmount(String(sale.amount_due)); }; const pay = async () => { if (!payOpen) return; const amount = payAmount ? parseFloat(payAmount) : undefined; if (amount !== undefined && (Number.isNaN(amount) || amount <= 0)) { toast.error('Monto inválido'); return; } try { setPaying(true); await odooApi.paySale(payOpen.id, { payment_method: payMethod, amount }); toast.success('Pago registrado'); setPayOpen(null); await loadSales(); } catch (err) { toast.error('Error al pagar venta'); console.error(err); } finally { setPaying(false); } }; const openRefund = (sale: Sale) => { setRefundOpen(sale); setRefundAmount(String(sale.amount_paid)); setRefundReason(''); }; const submitRefund = async () => { if (!refundOpen) return; const amount = refundAmount ? parseFloat(refundAmount) : undefined; if (amount !== undefined && (Number.isNaN(amount) || amount <= 0)) { toast.error('Monto inválido'); return; } if (amount !== undefined && amount > refundOpen.amount_paid) { toast.error(`El monto no puede exceder lo pagado ($${refundOpen.amount_paid})`); return; } try { setRefunding(true); const res = await odooApi.refundSale(refundOpen.id, { amount, reason: refundReason || undefined }); const pts = res.result?.points_reversed ?? 0; toast.success(`Devolución registrada${pts > 0 ? ` · ${pts} pts revertidos` : ''}`); setRefundOpen(null); await loadSales(); } catch (err) { toast.error('Error al registrar devolución'); console.error(err); } finally { setRefunding(false); } }; // ---- Cancelación con nota de crédito ---- const openCancel = (sale: Sale) => { setCancelOpen(sale); setCancelReason(''); }; const submitCancel = async () => { if (!cancelOpen) return; if (!cancelReason.trim()) { toast.error('El motivo de cancelación es obligatorio'); return; } try { setCancelling(true); const res = await odooApi.cancelSale(cancelOpen.id, { reason: cancelReason.trim() }); const r = res.result; const partes = [ r?.points_reversed ? `${r.points_reversed} pts revertidos` : '', r?.points_restored ? `${r.points_restored} pts reintegrados` : '', r?.stock_moves ? `${r.stock_moves} artículos devueltos a inventario` : '', ].filter(Boolean); toast.success(`Venta ${cancelOpen.name} cancelada${partes.length ? ` · ${partes.join(' · ')}` : ''}`); setCancelOpen(null); setCancelReason(''); await loadSales(); } catch (err) { const msg = (err as { response?: { data?: { message?: string } } })?.response?.data?.message; toast.error(msg || 'Error al cancelar la venta'); console.error(err); } finally { setCancelling(false); } }; const toggleExpand = (id: number) => setExpandedId((prev) => (prev === id ? null : id)); const exportSales = async () => { try { await exportToExcel({ filename: `ventas-${new Date().toISOString().split('T')[0]}`, sheetName: 'Ventas', title: `Ventas ${brand.nombre}`, subtitle: `${sales.length} ventas de la vista actual`, columns: [ { header: 'Referencia', key: 'name' }, { header: 'Paciente', key: 'patient' }, { header: 'Fecha', key: 'date', format: 'date' }, { header: 'Subtotal', key: 'subtotal', format: 'currency' }, { header: 'Descuento', key: 'discount', format: 'currency' }, { header: 'Impuesto', key: 'tax', format: 'currency' }, { header: 'Total', key: 'total', format: 'currency' }, { header: 'Pagado', key: 'amount_paid', format: 'currency' }, { header: 'Por cobrar', key: 'amount_due', format: 'currency' }, { header: 'Estado', key: 'state' }, { header: 'Devuelta', key: 'devuelta' }, { header: 'Monto devuelto', key: 'refund_amount', format: 'currency' }, ], rows: sales.map((s) => ({ ...s, devuelta: s.refunded ? 'Sí' : 'No', refund_amount: s.refund_amount || 0 })), totals: { name: '', total: sales.reduce((a, s) => a + s.total, 0), amount_paid: sales.reduce((a, s) => a + s.amount_paid, 0), amount_due: sales.reduce((a, s) => a + s.amount_due, 0), refund_amount: sales.reduce((a, s) => a + (s.refund_amount || 0), 0), }, }); toast.success(`Excel descargado (${sales.length} ventas de la vista actual)`); } catch (err) { toast.error('Error al exportar'); console.error(err); } }; const serviceOptions = [ { value: '', label: 'Servicio...' }, ...services.map((s) => ({ value: String(s.id), label: `${s.name} — $${s.price}` })), ]; const doctorOptions = [ { value: '', label: 'Médico que recetó...' }, ...doctors.map((d) => ({ value: String(d.id), label: `${d.name} (${d.commission_pct ?? 0}%)` })), ]; const payMethodOptions = paymentMethods.map((m) => ({ value: m, label: m })); return (
setSearch(e.target.value)} className="pl-10" />
{/* Presets de fecha (se recuerdan al volver) */}
{DATE_PRESETS.map((p) => ( ))}
{ setStart(e.target.value); setPreset('custom'); }} className="sm:max-w-[160px]" /> { setEnd(e.target.value); setPreset('custom'); }} className="sm:max-w-[160px]" />
{error &&

{error}

} {loading ? ( ) : sales.length === 0 ? ( setCreateOpen(true)} icon={} /> ) : ( <>
{sales.map((s) => { const cancelada = s.state === 'cancelled'; const abierta = expandedId === s.id; return ( toggleExpand(s.id)} title="Click para ver las líneas de la venta" > {abierta && ( )} ); })}
Referencia Paciente Total Pagado Estado Acciones
{abierta ? : } {s.name} ${s.total} ${s.amount_paid}
{cancelada ? 'Cancelada' : s.state} {s.refunded && Devuelta}
{cancelada && s.cancel_reason && (

Motivo: {s.cancel_reason} {s.cancelled_by ? ` · ${s.cancelled_by}` : ''}

)}
{s.amount_due > 0 && !cancelada && ( )} {s.amount_paid > 0 && !cancelada && ( )} {puedeCancelar && !cancelada && ( )}

Líneas de {s.name} · {s.date}

{s.lines.length === 0 ? (

Sin líneas registradas.

) : ( {s.lines.map((l, i) => ( ))}
Concepto Cant Precio Subtotal
{l.service || l.item || l.description} {l.is_prescribed && l.prescribed_by && ( · recetado por {l.prescribed_by} )} {l.quantity} ${l.price_unit} ${l.subtotal}
)}
Subtotal ${s.subtotal} Descuento ${s.discount} Total ${s.total} Pagado ${s.amount_paid} Por cobrar ${s.amount_due}
{cancelada && (

Cancelada{s.cancelled_at ? ` el ${s.cancelled_at}` : ''} {s.cancelled_by ? ` por ${s.cancelled_by}` : ''} {s.cancel_reason ? ` · Motivo: ${s.cancel_reason}` : ''}

)} {s.notes &&

Notas: {s.notes}

}
{sales.map((s) => ( openPatient(s.patient_id)} className="hover:underline hover:text-theme-accent text-left" title="Abrir ficha del paciente" > {s.patient} } subtitle={s.name} rows={[ { label: 'Total', value: `$${s.total}` }, { label: 'Pagado', value: `$${s.amount_paid}` }, { label: 'Estado', value: (
{s.state === 'cancelled' ? 'Cancelada' : s.state} {s.refunded && Devuelta}
), }, ...(s.state === 'cancelled' && s.cancel_reason ? [{ label: 'Motivo', value: s.cancel_reason }] : []), ]} actions={
{s.amount_due > 0 && s.state !== 'cancelled' && ( )} {s.amount_paid > 0 && s.state !== 'cancelled' && ( )} {puedeCancelar && s.state !== 'cancelled' && ( )}
} /> ))}
{/* Paginación */}

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

)}
setCreateOpen(false)} title="Nueva venta" maxWidth="2xl" footer={ <> } >
setPatientId(id)} />
{lines.map((line, i) => (
updateLine(i, 'description', e.target.value)} className="col-span-12 sm:col-span-4" /> updateLine(i, 'quantity', e.target.value)} className="col-span-4 sm:col-span-1" /> updateLine(i, 'price_unit', e.target.value)} className="col-span-6 sm:col-span-2" />
{line.is_prescribed && ( setPayMethod(e.target.value)} /> setPayAmount(e.target.value)} />
)} setRefundOpen(null)} title={`Devolución ${refundOpen?.name || ''}`} maxWidth="sm" footer={ <> } > {refundOpen && (

Paciente: {refundOpen.patient}

Total: ${refundOpen.total} {' · '} Pagado: ${refundOpen.amount_paid}

La devolución reduce el monto pagado y revierte los puntos del monedero en proporción ($10 = 1 pt). Deja el monto vacío o igual a lo pagado para devolución total.
setRefundAmount(e.target.value)} />