Oleada UX 3: POS (repetir ultima venta, aviso de paquetes, atajos, caja desde POS), Ventas (cancelacion auditada con reversion de monedero y stock, detalle inline, presets de fecha), Cortes (historial comparativo y export)
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
import type { FC } from 'react';
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
import { Plus, Trash2, CreditCard, ShoppingCart, Search, Undo2, Download, Printer } from 'lucide-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 TicketPrint from '../components/TicketPrint';
|
||||
@@ -21,6 +21,12 @@ import {
|
||||
} from '../components/ui';
|
||||
import { odooApi, type Sale, type SaleLine, type Service, type Patient, 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;
|
||||
@@ -40,6 +46,8 @@ const paymentMethods = ['Efectivo', 'Tarjeta', 'Transferencia', 'MercadoPago'];
|
||||
|
||||
const Ventas: FC = () => {
|
||||
const { openPatient } = usePatientDrawer();
|
||||
const { user } = useAuth();
|
||||
const puedeCancelar = ROLES_CANCELAN.includes(user?.role || '');
|
||||
const [sales, setSales] = useState<Sale[]>([]);
|
||||
const [services, setServices] = useState<Service[]>([]);
|
||||
const [patients, setPatients] = useState<Patient[]>([]);
|
||||
@@ -60,6 +68,12 @@ const Ventas: FC = () => {
|
||||
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<Sale | null>(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<number | null>(null);
|
||||
// Reimprimir ticket de una venta ya registrada
|
||||
const [printSale, setPrintSale] = useState<Sale | null>(null);
|
||||
const [search, setSearch] = useState('');
|
||||
@@ -69,11 +83,36 @@ const Ventas: FC = () => {
|
||||
const [totalPages, setTotalPages] = useState(0);
|
||||
const [patientSearch, setPatientSearch] = useState('');
|
||||
|
||||
// ---- Rango de fechas recordado (presets) ----
|
||||
const saved = readDateFilterPrefs(FILTERS_KEY);
|
||||
const [preset, setPreset] = useState<DatePreset>(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 });
|
||||
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);
|
||||
@@ -86,7 +125,7 @@ const Ventas: FC = () => {
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [search, page, pageSize]);
|
||||
}, [search, page, pageSize, start, end, preset]);
|
||||
|
||||
const loadRefs = useCallback(async () => {
|
||||
try {
|
||||
@@ -251,6 +290,42 @@ const Ventas: FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
// ---- 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({
|
||||
@@ -319,7 +394,7 @@ const Ventas: FC = () => {
|
||||
|
||||
<Card>
|
||||
<Card.Body>
|
||||
<div className="flex flex-col sm:flex-row sm:items-center gap-3 mb-4 sm:mb-6">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center gap-3 mb-3">
|
||||
<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
|
||||
@@ -331,6 +406,40 @@ const Ventas: FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Presets de fecha (se recuerdan al volver) */}
|
||||
<div className="flex flex-wrap items-center gap-1.5 mb-4">
|
||||
{DATE_PRESETS.map((p) => (
|
||||
<button
|
||||
key={p.value}
|
||||
type="button"
|
||||
onClick={() => aplicarPreset(p.value)}
|
||||
className={`px-3 py-1.5 rounded-full text-xs font-medium border transition-all ${
|
||||
preset === 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 className="flex items-center gap-2 sm:ml-auto">
|
||||
<Input
|
||||
label="Desde"
|
||||
type="date"
|
||||
value={start}
|
||||
onChange={(e) => { setStart(e.target.value); setPreset('custom'); }}
|
||||
className="sm:max-w-[160px]"
|
||||
/>
|
||||
<Input
|
||||
label="Hasta"
|
||||
type="date"
|
||||
value={end}
|
||||
onChange={(e) => { setEnd(e.target.value); setPreset('custom'); }}
|
||||
className="sm:max-w-[160px]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && <p className="text-rose-600 text-sm mb-4">{error}</p>}
|
||||
|
||||
{loading ? (
|
||||
@@ -349,6 +458,7 @@ const Ventas: FC = () => {
|
||||
<table className="w-full">
|
||||
<thead className="bg-theme-bg">
|
||||
<tr>
|
||||
<th className="w-8 p-3" />
|
||||
<th className="text-left p-3 text-xs font-medium text-theme-muted uppercase">Referencia</th>
|
||||
<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">Total</th>
|
||||
@@ -358,48 +468,129 @@ const Ventas: FC = () => {
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y">
|
||||
{sales.map((s) => (
|
||||
<tr key={s.id} className="hover:bg-theme-bg">
|
||||
<td className="p-3 text-sm text-theme-muted">{s.name}</td>
|
||||
{sales.map((s) => {
|
||||
const cancelada = s.state === 'cancelled';
|
||||
const abierta = expandedId === s.id;
|
||||
return (
|
||||
<Fragment key={s.id}>
|
||||
<tr
|
||||
className={`hover:bg-theme-bg cursor-pointer ${abierta ? 'bg-theme-bg' : ''}`}
|
||||
onClick={() => toggleExpand(s.id)}
|
||||
title="Click para ver las líneas de la venta"
|
||||
>
|
||||
<td className="p-3 text-theme-muted">
|
||||
{abierta ? <ChevronDown size={15} /> : <ChevronRight size={15} />}
|
||||
</td>
|
||||
<td className={`p-3 text-sm text-theme-muted ${cancelada ? 'line-through' : ''}`}>{s.name}</td>
|
||||
<td className="p-3 text-sm font-medium text-theme-heading">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => openPatient(s.patient_id)}
|
||||
onClick={(e) => { e.stopPropagation(); openPatient(s.patient_id); }}
|
||||
className="hover:underline hover:text-theme-accent text-left"
|
||||
title="Abrir ficha del paciente"
|
||||
>
|
||||
{s.patient}
|
||||
</button>
|
||||
</td>
|
||||
<td className="p-3 text-sm font-semibold text-theme-heading">${s.total}</td>
|
||||
<td className={`p-3 text-sm font-semibold text-theme-heading ${cancelada ? 'line-through text-theme-muted' : ''}`}>${s.total}</td>
|
||||
<td className="p-3 text-sm text-theme-muted hidden md:table-cell">${s.amount_paid}</td>
|
||||
<td className="p-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant={badgeForSaleState(s.state)}>{s.state}</Badge>
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<Badge variant={badgeForSaleState(s.state)}>{cancelada ? 'Cancelada' : s.state}</Badge>
|
||||
{s.refunded && <Badge variant="warning">Devuelta</Badge>}
|
||||
</div>
|
||||
{cancelada && s.cancel_reason && (
|
||||
<p className="text-[11px] text-rose-700 mt-1 max-w-[220px]" title={s.cancel_reason}>
|
||||
Motivo: {s.cancel_reason}
|
||||
{s.cancelled_by ? ` · ${s.cancelled_by}` : ''}
|
||||
</p>
|
||||
)}
|
||||
</td>
|
||||
<td className="p-3">
|
||||
<div className="flex items-center gap-1">
|
||||
<Button variant="ghost" size="sm" onClick={() => setPrintSale(s)} title="Reimprimir ticket">
|
||||
<Button variant="ghost" size="sm" onClick={(e) => { e.stopPropagation(); setPrintSale(s); }} title="Reimprimir ticket">
|
||||
<Printer size={16} className="text-theme-heading" />
|
||||
</Button>
|
||||
{s.amount_due > 0 && (
|
||||
<Button variant="ghost" size="sm" onClick={() => openPay(s)} title="Pagar">
|
||||
{s.amount_due > 0 && !cancelada && (
|
||||
<Button variant="ghost" size="sm" onClick={(e) => { e.stopPropagation(); openPay(s); }} title="Pagar">
|
||||
<CreditCard size={16} className="text-theme-heading mr-1.5" />
|
||||
Pagar
|
||||
</Button>
|
||||
)}
|
||||
{s.amount_paid > 0 && s.state !== 'cancelled' && (
|
||||
<Button variant="ghost" size="sm" onClick={() => openRefund(s)} title="Devolución">
|
||||
{s.amount_paid > 0 && !cancelada && (
|
||||
<Button variant="ghost" size="sm" onClick={(e) => { e.stopPropagation(); openRefund(s); }} title="Devolución">
|
||||
<Undo2 size={16} className="text-amber-600 mr-1.5" />
|
||||
Devolución
|
||||
</Button>
|
||||
)}
|
||||
{puedeCancelar && !cancelada && (
|
||||
<Button variant="ghost" size="sm" onClick={(e) => { e.stopPropagation(); openCancel(s); }} title="Cancelar venta (nota de crédito)">
|
||||
<Ban size={16} className="text-rose-600 mr-1.5" />
|
||||
Cancelar venta
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{abierta && (
|
||||
<tr className="bg-theme-bg">
|
||||
<td />
|
||||
<td colSpan={6} className="p-3 pt-0">
|
||||
<div className="rounded-xl border border-theme-border bg-theme-surface p-3">
|
||||
<p className="text-xs font-medium text-theme-muted uppercase mb-2">
|
||||
Líneas de {s.name} · {s.date}
|
||||
</p>
|
||||
{s.lines.length === 0 ? (
|
||||
<p className="text-sm text-theme-muted">Sin líneas registradas.</p>
|
||||
) : (
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="text-[11px] uppercase text-theme-muted">
|
||||
<th className="text-left py-1">Concepto</th>
|
||||
<th className="text-right py-1 w-16">Cant</th>
|
||||
<th className="text-right py-1 w-24">Precio</th>
|
||||
<th className="text-right py-1 w-24">Subtotal</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-theme-border">
|
||||
{s.lines.map((l, i) => (
|
||||
<tr key={l.id ?? i}>
|
||||
<td className="py-1 text-theme-heading">
|
||||
{l.service || l.item || l.description}
|
||||
{l.is_prescribed && l.prescribed_by && (
|
||||
<span className="text-[11px] text-theme-muted"> · recetado por {l.prescribed_by}</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="py-1 text-right text-theme-muted">{l.quantity}</td>
|
||||
<td className="py-1 text-right text-theme-muted">${l.price_unit}</td>
|
||||
<td className="py-1 text-right font-medium text-theme-heading">${l.subtotal}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
<div className="flex flex-wrap gap-x-5 gap-y-1 mt-2 text-xs text-theme-muted">
|
||||
<span>Subtotal ${s.subtotal}</span>
|
||||
<span>Descuento ${s.discount}</span>
|
||||
<span>Total ${s.total}</span>
|
||||
<span>Pagado ${s.amount_paid}</span>
|
||||
<span>Por cobrar ${s.amount_due}</span>
|
||||
</div>
|
||||
{cancelada && (
|
||||
<p className="mt-2 text-xs text-rose-700">
|
||||
Cancelada{s.cancelled_at ? ` el ${s.cancelled_at}` : ''}
|
||||
{s.cancelled_by ? ` por ${s.cancelled_by}` : ''}
|
||||
{s.cancel_reason ? ` · Motivo: ${s.cancel_reason}` : ''}
|
||||
</p>
|
||||
)}
|
||||
{s.notes && <p className="mt-1 text-xs text-theme-muted">Notas: {s.notes}</p>}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
@@ -426,18 +617,21 @@ const Ventas: FC = () => {
|
||||
label: 'Estado',
|
||||
value: (
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant={badgeForSaleState(s.state)}>{s.state}</Badge>
|
||||
<Badge variant={badgeForSaleState(s.state)}>{s.state === 'cancelled' ? 'Cancelada' : s.state}</Badge>
|
||||
{s.refunded && <Badge variant="warning">Devuelta</Badge>}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
...(s.state === 'cancelled' && s.cancel_reason
|
||||
? [{ label: 'Motivo', value: s.cancel_reason }]
|
||||
: []),
|
||||
]}
|
||||
actions={
|
||||
<div className="flex items-center gap-1">
|
||||
<Button variant="ghost" size="sm" onClick={() => setPrintSale(s)} title="Reimprimir ticket">
|
||||
<Printer size={16} className="text-theme-heading" />
|
||||
</Button>
|
||||
{s.amount_due > 0 && (
|
||||
{s.amount_due > 0 && s.state !== 'cancelled' && (
|
||||
<Button variant="ghost" size="sm" onClick={() => openPay(s)}>
|
||||
<CreditCard size={16} className="text-theme-heading" />
|
||||
</Button>
|
||||
@@ -447,6 +641,11 @@ const Ventas: FC = () => {
|
||||
<Undo2 size={16} className="text-amber-600" />
|
||||
</Button>
|
||||
)}
|
||||
{puedeCancelar && s.state !== 'cancelled' && (
|
||||
<Button variant="ghost" size="sm" onClick={() => openCancel(s)} title="Cancelar venta">
|
||||
<Ban size={16} className="text-rose-600" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
@@ -675,6 +874,44 @@ const Ventas: FC = () => {
|
||||
)}
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
isOpen={!!cancelOpen}
|
||||
onClose={() => setCancelOpen(null)}
|
||||
title={`Cancelar venta ${cancelOpen?.name || ''}`}
|
||||
maxWidth="sm"
|
||||
footer={
|
||||
<>
|
||||
<Button variant="outline" onClick={() => setCancelOpen(null)}>Volver</Button>
|
||||
<Button variant="danger" onClick={submitCancel} loading={cancelling}>Cancelar venta</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
{cancelOpen && (
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-theme-muted">
|
||||
Paciente: <span className="font-medium text-theme-heading">{cancelOpen.patient}</span>
|
||||
</p>
|
||||
<p className="text-sm text-theme-muted">
|
||||
Total: <span className="font-medium text-theme-heading">${cancelOpen.total}</span>
|
||||
{' · '}
|
||||
Pagado: <span className="font-medium text-theme-heading">${cancelOpen.amount_paid}</span>
|
||||
</p>
|
||||
<div className="rounded-lg bg-rose-50 border border-rose-200 px-3 py-2 text-xs text-rose-800">
|
||||
Se emite la nota de crédito: la venta se marca como cancelada y deja de contar en
|
||||
ingresos, comisiones y reportes. Se revierten los puntos del monedero otorgados por
|
||||
esta venta (y se reintegran los canjeados) y los artículos vuelven al inventario.
|
||||
La venta no se borra: queda en el historial con su motivo.
|
||||
</div>
|
||||
<TextArea
|
||||
label="Motivo de cancelación *"
|
||||
placeholder="Ej. cobro duplicado, error de captura, servicio no realizado..."
|
||||
value={cancelReason}
|
||||
onChange={(e) => setCancelReason(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
|
||||
{/* Reimprimir ticket de la venta (formato térmico ~80mm) */}
|
||||
<TicketPrint
|
||||
isOpen={!!printSale}
|
||||
|
||||
Reference in New Issue
Block a user