Initial commit: SKEEN Derma Experts - Sistema Integral de Gestión Clínica
- Frontend React (SKEEN Brand) con Vite, TypeScript, Tailwind - Frontend Homenest (versión alternativa) - Módulos Odoo 17 custom (citas, pacientes, monedero, pagos, ventas, inventario, whatsapp) - WACRM fork (Next.js 16 + Supabase) - Hermes + Bridge + Skills (Qwen3.6 via Nan Builders) - Scripts de migración y operación - Documentación extensiva en docs/
This commit is contained in:
631
frontend/src/pages/Ventas.tsx
Normal file
631
frontend/src/pages/Ventas.tsx
Normal file
@@ -0,0 +1,631 @@
|
||||
import type { FC } from 'react';
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
import { Plus, Trash2, CreditCard, ShoppingCart, Search, Undo2, Download } from 'lucide-react';
|
||||
import Layout from '../components/Layout';
|
||||
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 Patient, type Doctor } from '../services/odoo';
|
||||
import { downloadCsv } from '../lib/utils';
|
||||
|
||||
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 [sales, setSales] = useState<Sale[]>([]);
|
||||
const [services, setServices] = useState<Service[]>([]);
|
||||
const [patients, setPatients] = useState<Patient[]>([]);
|
||||
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('');
|
||||
const [search, setSearch] = useState('');
|
||||
const [page, setPage] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(50);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [totalPages, setTotalPages] = useState(0);
|
||||
const [patientSearch, setPatientSearch] = useState('');
|
||||
|
||||
const loadSales = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const res = await odooApi.getSales({ search, page, page_size: pageSize });
|
||||
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]);
|
||||
|
||||
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]);
|
||||
|
||||
// Cargar pacientes para el selector (búsqueda server-side)
|
||||
useEffect(() => {
|
||||
if (!createOpen) return;
|
||||
const t = setTimeout(async () => {
|
||||
try {
|
||||
const res = await odooApi.getPatients({ search: patientSearch, page_size: 50 });
|
||||
if (res.status === 'success') setPatients(res.patients);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
}
|
||||
}, 250);
|
||||
return () => clearTimeout(t);
|
||||
}, [createOpen, patientSearch]);
|
||||
|
||||
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);
|
||||
}
|
||||
};
|
||||
|
||||
const exportSales = () => {
|
||||
const rows: (string | number)[][] = [
|
||||
['Referencia', 'Paciente', 'Fecha', 'Subtotal', 'Descuento', 'Impuesto', 'Total', 'Pagado', 'Por cobrar', 'Estado', 'Devuelta', 'Monto devuelto'],
|
||||
];
|
||||
sales.forEach((s) =>
|
||||
rows.push([
|
||||
s.name, s.patient, s.date, s.subtotal, s.discount, s.tax, s.total,
|
||||
s.amount_paid, s.amount_due, s.state, s.refunded ? 'Sí' : 'No', s.refund_amount || 0,
|
||||
])
|
||||
);
|
||||
rows.push(['', '', 'Totales', '', '', '', sales.reduce((a, s) => a + s.total, 0), sales.reduce((a, s) => a + s.amount_paid, 0), sales.reduce((a, s) => a + s.amount_due, 0), '', '', sales.reduce((a, s) => a + (s.refund_amount || 0), 0)]);
|
||||
downloadCsv(`ventas-skeen-${new Date().toISOString().split('T')[0]}.csv`, rows);
|
||||
toast.success(`CSV descargado (${sales.length} ventas de la vista actual)`);
|
||||
};
|
||||
|
||||
const patientOptions = [
|
||||
{ value: '', label: 'Seleccionar paciente...' },
|
||||
...patients.map((p) => ({ value: String(p.id), label: `${p.name} (${p.phone})` })),
|
||||
];
|
||||
|
||||
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-4 sm:mb-6">
|
||||
<div className="relative w-full sm:max-w-sm">
|
||||
<Search size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-theme-muted" />
|
||||
<Input
|
||||
placeholder="Buscar por referencia o paciente..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="pl-10"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && <p className="text-rose-600 text-sm mb-4">{error}</p>}
|
||||
|
||||
{loading ? (
|
||||
<Skeleton count={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="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) => (
|
||||
<tr key={s.id} className="hover:bg-theme-bg">
|
||||
<td className="p-3 text-sm text-theme-muted">{s.name}</td>
|
||||
<td className="p-3 text-sm font-medium text-theme-heading">{s.patient}</td>
|
||||
<td className="p-3 text-sm font-semibold text-theme-heading">${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>
|
||||
{s.refunded && <Badge variant="warning">Devuelta</Badge>}
|
||||
</div>
|
||||
</td>
|
||||
<td className="p-3">
|
||||
<div className="flex items-center gap-1">
|
||||
{s.amount_due > 0 && (
|
||||
<Button variant="ghost" size="sm" onClick={() => 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">
|
||||
<Undo2 size={16} className="text-amber-600 mr-1.5" />
|
||||
Devolución
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div className="sm:hidden space-y-3">
|
||||
{sales.map((s) => (
|
||||
<MobileCard
|
||||
key={s.id}
|
||||
title={s.patient}
|
||||
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}</Badge>
|
||||
{s.refunded && <Badge variant="warning">Devuelta</Badge>}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
]}
|
||||
actions={
|
||||
<div className="flex items-center gap-1">
|
||||
{s.amount_due > 0 && (
|
||||
<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>
|
||||
)}
|
||||
</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">
|
||||
<Input
|
||||
label="Buscar paciente"
|
||||
placeholder="Escribe nombre o teléfono..."
|
||||
value={patientSearch}
|
||||
onChange={(e) => setPatientSearch(e.target.value)}
|
||||
/>
|
||||
<Select
|
||||
label="Paciente *"
|
||||
options={patientOptions}
|
||||
value={patientId}
|
||||
onChange={(e) => setPatientId(e.target.value)}
|
||||
/>
|
||||
|
||||
<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>
|
||||
</Layout>
|
||||
);
|
||||
};
|
||||
|
||||
export default Ventas;
|
||||
Reference in New Issue
Block a user