- backend: GET/PUT /skeen/frontend/v1/brand leyendo ir.config_parameter skeen.clinic.brand (JSON: nombre, logo_claro, logo_oscuro, pie) con defaults SKEEN; GET publico (el login lo necesita sin sesion), PUT admin con auditoria. frontend_users.xml: 'Administrador SKEEN' -> 'Administrador' - frontend: ClinicBrand + BRAND_DEFAULT en odoo.ts; brand y setBrand en el contexto de auth (fetch al boot + cache localStorage + document.title) - Login/layouts/mensajes/exports leen el brand en vez de strings SKEEN: Login.tsx, SkeenLayout, HomeNestLayout, Cumpleanos (wa + export), Pacientes/Ventas/Cortes/Reportes/Kpis/Alertas/AgendaPrint (titulos), useClinicaPrint (fallback nombre), Login hace GET publico al montar - Configuracion > Clinica: tarjeta 'Identidad de la clinica' (solo admin) para editar nombre/pie/logos con preview - no renombrado (invisible al usuario): rutas API /skeen/*, claves localStorage, nombres de modulos, parametros skeen.* Verificacion API: 11/11 tests OK (defaults, PUT merge, campo invalido ignorado, restore, admin neutral). Deploy: build + rsync + Odoo reiniciado
920 lines
38 KiB
TypeScript
920 lines
38 KiB
TypeScript
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<Sale[]>([]);
|
|
const [services, setServices] = useState<Service[]>([]);
|
|
const [doctors, setDoctors] = useState<Doctor[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [createOpen, setCreateOpen] = useState(false);
|
|
const [payOpen, setPayOpen] = useState<Sale | null>(null);
|
|
const [patientId, setPatientId] = useState('');
|
|
const [lines, setLines] = useState<LineForm[]>([
|
|
emptyLine(),
|
|
]);
|
|
const [submitting, setSubmitting] = useState(false);
|
|
const [paying, setPaying] = useState(false);
|
|
const [payMethod, setPayMethod] = useState('Efectivo');
|
|
const [payAmount, setPayAmount] = useState('');
|
|
const [refundOpen, setRefundOpen] = useState<Sale | null>(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<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('');
|
|
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<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,
|
|
...(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<SaleLine, 'subtotal'>[] = lines
|
|
.filter((l) => l.service_id)
|
|
.map((l): Omit<SaleLine, 'subtotal'> | 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<SaleLine, 'subtotal'> => 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 (
|
|
<Layout title="Ventas" subtitle="Órdenes de venta">
|
|
<PageHeader title="Ventas" subtitle="Crea ventas y registra pagos">
|
|
<Button variant="outline" onClick={exportSales} disabled={sales.length === 0}>
|
|
<Download size={16} className="mr-2" />
|
|
Exportar CSV
|
|
</Button>
|
|
<Button onClick={() => setCreateOpen(true)}>
|
|
<Plus size={16} className="mr-2" />
|
|
Nueva venta
|
|
</Button>
|
|
</PageHeader>
|
|
|
|
<Card>
|
|
<Card.Body>
|
|
<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
|
|
placeholder="Buscar por referencia o paciente..."
|
|
value={search}
|
|
onChange={(e) => setSearch(e.target.value)}
|
|
className="pl-10"
|
|
/>
|
|
</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 ? (
|
|
<Skeleton count={6} className="h-12 w-full" />
|
|
) : sales.length === 0 ? (
|
|
<EmptyState
|
|
title="Sin ventas"
|
|
subtitle="No hay ventas registradas."
|
|
actionLabel="Nueva venta"
|
|
onAction={() => setCreateOpen(true)}
|
|
icon={<ShoppingCart size={28} />}
|
|
/>
|
|
) : (
|
|
<>
|
|
<div className="hidden sm:block overflow-x-auto">
|
|
<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>
|
|
<th className="text-left p-3 text-xs font-medium text-theme-muted uppercase hidden md:table-cell">Pagado</th>
|
|
<th className="text-left p-3 text-xs font-medium text-theme-muted uppercase">Estado</th>
|
|
<th className="text-left p-3 text-xs font-medium text-theme-muted uppercase">Acciones</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody className="divide-y">
|
|
{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={(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 ${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 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={(e) => { e.stopPropagation(); setPrintSale(s); }} title="Reimprimir ticket">
|
|
<Printer size={16} className="text-theme-heading" />
|
|
</Button>
|
|
{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 && !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>
|
|
|
|
<div className="sm:hidden space-y-3">
|
|
{sales.map((s) => (
|
|
<MobileCard
|
|
key={s.id}
|
|
title={
|
|
<button
|
|
type="button"
|
|
onClick={() => openPatient(s.patient_id)}
|
|
className="hover:underline hover:text-theme-accent text-left"
|
|
title="Abrir ficha del paciente"
|
|
>
|
|
{s.patient}
|
|
</button>
|
|
}
|
|
subtitle={s.name}
|
|
rows={[
|
|
{ label: 'Total', value: `$${s.total}` },
|
|
{ label: 'Pagado', value: `$${s.amount_paid}` },
|
|
{
|
|
label: 'Estado',
|
|
value: (
|
|
<div className="flex items-center gap-2">
|
|
<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.state !== 'cancelled' && (
|
|
<Button variant="ghost" size="sm" onClick={() => openPay(s)}>
|
|
<CreditCard size={16} className="text-theme-heading" />
|
|
</Button>
|
|
)}
|
|
{s.amount_paid > 0 && s.state !== 'cancelled' && (
|
|
<Button variant="ghost" size="sm" onClick={() => openRefund(s)} title="Devolución">
|
|
<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>
|
|
}
|
|
/>
|
|
))}
|
|
</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} ventas · 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={createOpen}
|
|
onClose={() => setCreateOpen(false)}
|
|
title="Nueva venta"
|
|
maxWidth="2xl"
|
|
footer={
|
|
<>
|
|
<Button variant="outline" onClick={() => setCreateOpen(false)}>Cancelar</Button>
|
|
<Button onClick={create} loading={submitting}>Crear venta</Button>
|
|
</>
|
|
}
|
|
>
|
|
<div className="space-y-4">
|
|
<PatientCombobox
|
|
label="Paciente *"
|
|
value={patientId}
|
|
placeholder="Escribe nombre, apellidos o teléfono..."
|
|
emptyHint="Sin resultados. Da de alta al paciente en Pacientes y vuelve a intentar."
|
|
onChange={(id) => setPatientId(id)}
|
|
/>
|
|
|
|
<div>
|
|
<div className="flex items-center justify-between mb-2">
|
|
<label className="text-xs font-medium text-theme-muted">Líneas</label>
|
|
<Button variant="outline" size="sm" onClick={addLine}>
|
|
<Plus size={14} className="mr-1" />
|
|
Agregar
|
|
</Button>
|
|
</div>
|
|
<div className="space-y-2">
|
|
{lines.map((line, i) => (
|
|
<div key={i} className="space-y-2">
|
|
<div className="grid grid-cols-12 gap-2 items-start">
|
|
<div className="col-span-12 sm:col-span-4">
|
|
<Select
|
|
options={serviceOptions}
|
|
value={line.service_id}
|
|
onChange={(e) => {
|
|
const sid = e.target.value;
|
|
const svc = services.find((s) => String(s.id) === sid);
|
|
updateLine(i, 'service_id', sid);
|
|
if (svc) {
|
|
updateLine(i, 'price_unit', String(svc.price));
|
|
updateLine(i, 'description', svc.name);
|
|
}
|
|
}}
|
|
/>
|
|
</div>
|
|
<Input
|
|
placeholder="Descripción"
|
|
value={line.description}
|
|
onChange={(e) => updateLine(i, 'description', e.target.value)}
|
|
className="col-span-12 sm:col-span-4"
|
|
/>
|
|
<Input
|
|
type="number"
|
|
placeholder="Cant"
|
|
value={line.quantity}
|
|
onChange={(e) => updateLine(i, 'quantity', e.target.value)}
|
|
className="col-span-4 sm:col-span-1"
|
|
/>
|
|
<Input
|
|
type="number"
|
|
placeholder="Precio"
|
|
value={line.price_unit}
|
|
onChange={(e) => updateLine(i, 'price_unit', e.target.value)}
|
|
className="col-span-6 sm:col-span-2"
|
|
/>
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => removeLine(i)}
|
|
className="col-span-2 sm:col-span-1 h-[42px]"
|
|
title="Eliminar línea"
|
|
>
|
|
<Trash2 size={16} className="text-rose-600" />
|
|
</Button>
|
|
</div>
|
|
<div className="flex flex-col sm:flex-row sm:items-center gap-2 pl-1">
|
|
<label className="inline-flex items-center gap-2 text-sm text-theme-muted cursor-pointer">
|
|
<input
|
|
type="checkbox"
|
|
checked={line.is_prescribed}
|
|
onChange={(e) => updateLine(i, 'is_prescribed', e.target.checked)}
|
|
className="rounded border-theme-border-strong"
|
|
/>
|
|
Artículo recetado
|
|
</label>
|
|
{line.is_prescribed && (
|
|
<Select
|
|
options={doctorOptions}
|
|
value={line.prescribed_by_id}
|
|
onChange={(e) => updateLine(i, 'prescribed_by_id', e.target.value)}
|
|
className="sm:max-w-xs"
|
|
/>
|
|
)}
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</Modal>
|
|
|
|
<Modal
|
|
isOpen={!!payOpen}
|
|
onClose={() => setPayOpen(null)}
|
|
title={`Pagar ${payOpen?.name || ''}`}
|
|
maxWidth="sm"
|
|
footer={
|
|
<>
|
|
<Button variant="outline" onClick={() => setPayOpen(null)}>Cancelar</Button>
|
|
<Button onClick={pay} loading={paying}>Registrar pago</Button>
|
|
</>
|
|
}
|
|
>
|
|
{payOpen && (
|
|
<div className="space-y-4">
|
|
<p className="text-sm text-theme-muted">
|
|
Paciente: <span className="font-medium text-theme-heading">{payOpen.patient}</span>
|
|
</p>
|
|
<p className="text-sm text-theme-muted">
|
|
Total: <span className="font-medium text-theme-heading">${payOpen.total}</span>
|
|
{' · '}
|
|
Por pagar: <span className="font-medium text-theme-heading">${payOpen.amount_due}</span>
|
|
</p>
|
|
<Select
|
|
label="Método de pago"
|
|
options={payMethodOptions}
|
|
value={payMethod}
|
|
onChange={(e) => setPayMethod(e.target.value)}
|
|
/>
|
|
<Input
|
|
label="Monto"
|
|
type="number"
|
|
min={0.01}
|
|
step={0.01}
|
|
value={payAmount}
|
|
onChange={(e) => setPayAmount(e.target.value)}
|
|
/>
|
|
</div>
|
|
)}
|
|
</Modal>
|
|
|
|
<Modal
|
|
isOpen={!!refundOpen}
|
|
onClose={() => setRefundOpen(null)}
|
|
title={`Devolución ${refundOpen?.name || ''}`}
|
|
maxWidth="sm"
|
|
footer={
|
|
<>
|
|
<Button variant="outline" onClick={() => setRefundOpen(null)}>Cancelar</Button>
|
|
<Button variant="danger" onClick={submitRefund} loading={refunding}>Confirmar devolución</Button>
|
|
</>
|
|
}
|
|
>
|
|
{refundOpen && (
|
|
<div className="space-y-4">
|
|
<p className="text-sm text-theme-muted">
|
|
Paciente: <span className="font-medium text-theme-heading">{refundOpen.patient}</span>
|
|
</p>
|
|
<p className="text-sm text-theme-muted">
|
|
Total: <span className="font-medium text-theme-heading">${refundOpen.total}</span>
|
|
{' · '}
|
|
Pagado: <span className="font-medium text-theme-heading">${refundOpen.amount_paid}</span>
|
|
</p>
|
|
<div className="rounded-lg bg-amber-50 border border-theme-bg px-3 py-2 text-xs text-amber-800">
|
|
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.
|
|
</div>
|
|
<Input
|
|
label="Monto a devolver"
|
|
type="number"
|
|
min={0.01}
|
|
max={refundOpen.amount_paid}
|
|
step={0.01}
|
|
value={refundAmount}
|
|
onChange={(e) => setRefundAmount(e.target.value)}
|
|
/>
|
|
<TextArea
|
|
label="Motivo"
|
|
placeholder="Motivo de la devolución..."
|
|
value={refundReason}
|
|
onChange={(e) => setRefundReason(e.target.value)}
|
|
/>
|
|
</div>
|
|
)}
|
|
</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}
|
|
onClose={() => setPrintSale(null)}
|
|
ticket={printSale && {
|
|
folio: printSale.name,
|
|
fecha: printSale.date,
|
|
paciente: printSale.patient,
|
|
lines: printSale.lines.map((l) => ({
|
|
name: l.service || l.item || l.description,
|
|
qty: l.quantity,
|
|
price: l.price_unit,
|
|
subtotal: l.subtotal,
|
|
})),
|
|
subtotal: printSale.subtotal,
|
|
discount: printSale.discount,
|
|
total: printSale.total,
|
|
amount_paid: printSale.amount_paid,
|
|
amount_due: printSale.amount_due,
|
|
cajero: printSale.user_login,
|
|
doctor: printSale.doctor,
|
|
telefono_paciente: printSale.patient_phone,
|
|
notas: printSale.notes,
|
|
}}
|
|
/>
|
|
</Layout>
|
|
);
|
|
};
|
|
|
|
export default Ventas;
|