diff --git a/frontend/src/components/TicketPrint.tsx b/frontend/src/components/TicketPrint.tsx new file mode 100644 index 0000000..9c52e84 --- /dev/null +++ b/frontend/src/components/TicketPrint.tsx @@ -0,0 +1,164 @@ +import type { FC, ReactNode } from 'react'; +import { Printer } from 'lucide-react'; +import { Button, Modal } from './ui'; + +// Ticket térmico (~80mm) imprimible, reutilizado por: +// - POS: se ofrece automáticamente tras un cobro exitoso (modal de éxito) +// - Ventas: botón "Reimprimir ticket" por venta +// Sigue el patrón de impresión existente (RecetaPrint / EstadoCuentaPrint): +// contenido en un div.print-ticket + window.print(); el CSS de @media print +// en index.css oculta el resto de la página. + +export interface TicketLinePrint { + name: string; + qty: number; + price: number; + subtotal: number; +} + +export interface TicketPrintData { + folio: string; + fecha: string; + paciente: string; + lines: TicketLinePrint[]; + subtotal: number; + discount: number; + total: number; + metodo?: string | null; + puntos_usados?: number; + puntos_ganados?: number; + wallet_points?: number | null; + cambio?: number; + cambio_usd?: number; + recibido_usd?: number; + rate_used?: number | null; + // Reimpresión desde Ventas (no hay método/puntos en el detalle de venta) + amount_paid?: number; + amount_due?: number; +} + +interface ClinicSettings { + name: string; + phone: string; + address: string; +} + +// Misma fuente de config que EstadoCuentaPrint (ajustes guardados en el navegador) +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: 'Playas de Rosarito, B.C.' }; +}; + +const fmtMoney = (n: number) => + (n || 0).toLocaleString('es-MX', { style: 'currency', currency: 'MXN', minimumFractionDigits: 2 }); + +const fmtUsd = (n: number) => + `$${(n || 0).toLocaleString('es-MX', { minimumFractionDigits: 2, maximumFractionDigits: 2 })} USD`; + +interface TicketPrintProps { + isOpen: boolean; + onClose: () => void; + ticket: TicketPrintData | null; + // Acciones extra en el pie (ej. "Nueva venta" en el POS) + footerExtra?: ReactNode; +} + +export const TicketPrint: FC = ({ isOpen, onClose, ticket, footerExtra }) => { + if (!ticket) return null; + const clinic = clinicSettings(); + return ( + + + {footerExtra} + + + } + > +
+ {/* Encabezado de la clínica */} +
+

{clinic.name}

+ {clinic.address &&

{clinic.address}

} + {clinic.phone &&

Tel. {clinic.phone}

} +
+
+ {ticket.folio} + {ticket.fecha} +
+

Paciente: {ticket.paciente}

+ + {/* Líneas del ticket */} + + + {ticket.lines.map((l, i) => ( + + + + + ))} + +
{l.qty} × {l.name}{fmtMoney(l.subtotal)}
+ +
+
Subtotal{fmtMoney(ticket.subtotal)}
+ {ticket.discount > 0 && ( +
Descuento-{fmtMoney(ticket.discount)}
+ )} + {(ticket.puntos_usados ?? 0) > 0 && ( +
Puntos canjeados-{ticket.puntos_usados} pts
+ )} +
Total{fmtMoney(ticket.total)}
+ {ticket.rate_used ? ( +
Equiv. USD (TC {ticket.rate_used.toFixed(2)}){fmtUsd(ticket.total / ticket.rate_used)}
+ ) : null} + {ticket.metodo && ( +
Método{ticket.metodo}
+ )} + {(ticket.recibido_usd ?? 0) > 0 && ticket.rate_used ? ( +
Recibido USD{fmtUsd(ticket.recibido_usd!)}
+ ) : null} + {(ticket.cambio ?? 0) > 0 && ( +
+ Cambio + + {fmtMoney(ticket.cambio!)} + {(ticket.cambio_usd ?? 0) > 0 && ` · ${fmtUsd(ticket.cambio_usd!)}`} + +
+ )} + {(ticket.amount_paid ?? 0) > 0 && ( +
Pagado{fmtMoney(ticket.amount_paid!)}
+ )} + {(ticket.amount_due ?? 0) > 0 && ( +
Saldo pendiente{fmtMoney(ticket.amount_due!)}
+ )} + {(ticket.puntos_ganados ?? 0) > 0 && ( +
Puntos ganados+{ticket.puntos_ganados} pts
+ )} + {ticket.wallet_points !== null && ticket.wallet_points !== undefined && ( +
Saldo monedero{ticket.wallet_points} pts
+ )} +
+

¡Gracias por su visita!

+
+
+ ); +}; + +export default TicketPrint; diff --git a/frontend/src/index.css b/frontend/src/index.css index 28d4542..ccfd93b 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -267,4 +267,14 @@ p { left: 0; width: 100%; } + /* Ticket térmico ~80mm, monoespaciado */ + .print-ticket { + width: 76mm; + max-width: 76mm; + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: 11px; + border: none; + border-radius: 0; + padding: 0; + } } diff --git a/frontend/src/pages/Agenda.tsx b/frontend/src/pages/Agenda.tsx index dd73e4d..f449223 100644 --- a/frontend/src/pages/Agenda.tsx +++ b/frontend/src/pages/Agenda.tsx @@ -1,6 +1,6 @@ import type { FC } from 'react'; import { useEffect, useState, useCallback, useRef } from 'react'; -import { Clock, CheckCircle, XCircle, CheckCheck, UserCheck, UserX, Plus, PackageCheck, Sparkles, RotateCcw, LayoutGrid, List, Rows3, CalendarRange, CalendarClock, ChevronLeft, ChevronRight, Ban, Trash2, Phone, Cake } from 'lucide-react'; +import { Clock, CheckCircle, XCircle, CheckCheck, UserCheck, UserX, Plus, PackageCheck, Sparkles, RotateCcw, LayoutGrid, List, Rows3, CalendarRange, CalendarClock, ChevronLeft, ChevronRight, Ban, Trash2, Phone, Cake, CreditCard } from 'lucide-react'; import Layout from '../components/Layout'; import { usePatientDrawer } from '../components/PatientDrawer'; import { @@ -18,7 +18,7 @@ import { toast, badgeForAppointmentState, } from '../components/ui'; -import { useSearchParams } from 'react-router-dom'; +import { useSearchParams, useNavigate } from 'react-router-dom'; import { odooApi, type Appointment, type Patient, type Service, type Doctor, type Bloqueo } from '../services/odoo'; import { useAuth } from '../lib/auth'; @@ -44,6 +44,7 @@ const QUARTER_TIME_OPTIONS = (() => { const Agenda: FC = () => { const [searchParams] = useSearchParams(); + const navigate = useNavigate(); const { user } = useAuth(); const { openPatient } = usePatientDrawer(); // Rol médico: agenda restringida a su propio hr.employee (filtro también en servidor) @@ -2032,6 +2033,25 @@ const Agenda: FC = () => { + {/* Cobrar a la cita: abre el POS con paciente/servicio/médico precargados. + Solo en estados cobrables (oculto en cancelada/no show). */} + {!['cancelled', 'no_show'].includes(detailApt.state) && ( + + )} )} diff --git a/frontend/src/pages/Pos.tsx b/frontend/src/pages/Pos.tsx index 08e1629..5c150e8 100644 --- a/frontend/src/pages/Pos.tsx +++ b/frontend/src/pages/Pos.tsx @@ -1,11 +1,12 @@ import type { FC } from 'react'; import { useEffect, useState, useCallback, useMemo } from 'react'; -import { Link } from 'react-router-dom'; +import { Link, useSearchParams } from 'react-router-dom'; import { - Search, Plus, Minus, X, Store, AlertTriangle, ShoppingBag, Star, Printer, Wallet, Trash2, Package, + Search, Plus, Minus, X, Store, AlertTriangle, ShoppingBag, Star, Wallet, Trash2, Package, } from 'lucide-react'; import Layout from '../components/Layout'; import { usePatientDrawer } from '../components/PatientDrawer'; +import TicketPrint from '../components/TicketPrint'; import { Card, Button, @@ -46,6 +47,7 @@ interface TicketLine { const Pos: FC = () => { const { openPatient } = usePatientDrawer(); + const [searchParams] = useSearchParams(); // Catálogo const [catMode, setCatMode] = useState<'servicios' | 'articulos'>('servicios'); const [services, setServices] = useState([]); @@ -138,6 +140,44 @@ const Pos: FC = () => { .catch(() => {}); }, []); + // Pre-carga "Cobrar a la cita" desde Agenda: + // /pos?patient_id=X&service_id=Y&doctor_id=Z&appointment_id=N + useEffect(() => { + const pid = searchParams.get('patient_id'); + if (!pid) return; + const sid = searchParams.get('service_id'); + const did = searchParams.get('doctor_id'); + (async () => { + try { + const res = await odooApi.getPatient(parseInt(pid, 10)); + if (res.status === 'success') selectPaciente(res.patient); + } catch { + toast.error('No se pudo cargar el paciente de la cita'); + } + if (did) setDoctorId(did); + if (sid) { + try { + const sRes = await odooApi.getServices({ page_size: 200 }); + const svc = (sRes.services || []).find((s) => s.id === parseInt(sid, 10)); + if (svc) { + setLines((prev) => { + const key = `s-${svc.id}`; + if (prev.some((l) => l.key === key)) return prev; + return [...prev, { key, name: svc.name, qty: 1, price: svc.price, service_id: svc.id }]; + }); + } else { + toast.error('El servicio de la cita no está activo en el catálogo'); + } + } catch { + toast.error('No se pudo cargar el servicio de la cita'); + } + } + toast.success('Cita precargada en el ticket'); + })(); + // Solo al montar: los query params son la fuente inicial + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + // Tipo de cambio USD/MXN: al montar y refresco cada 5 min useEffect(() => { const fetchTc = () => { @@ -797,75 +837,34 @@ const Pos: FC = () => { - {/* Ticket de éxito */} - - - - - } - > - {resultado && ( -
-
-

SKEEN Derma Experts

-

Playas de Rosarito, B.C.

-
-
- {resultado.sale.name} - {resultado.sale.date} -
-

Paciente: {resultado.sale.patient}

- - - {resultado.sale.lines.map((l) => ( - - - - - ))} - -
{l.quantity} × {l.service || l.item || l.description}{fmtMoney(l.subtotal)}
-
-
Subtotal{fmtMoney(resultado.sale.subtotal)}
- {resultado.sale.discount > 0 && ( -
Descuento-{fmtMoney(resultado.sale.discount)}
- )} - {resultado.puntos_usados > 0 && ( -
Puntos canjeados-{resultado.puntos_usados} pts
- )} -
Total{fmtMoney(resultado.sale.total)}
-
Método{resultado.payment ? metodoLabel(resultado.payment.payment_method) : 'Puntos'}
- {resultado.recibido_usd && resultado.recibido_usd > 0 && resultado.rate_used && ( -
Recibido USD{fmtUsd(resultado.recibido_usd)} (TC {resultado.rate_used.toFixed(2)})
- )} - {resultado.cambio > 0 && ( -
- Cambio - - {fmtMoney(resultado.cambio)} - {resultado.cambio_usd && resultado.cambio_usd > 0 && ( - · {fmtUsd(resultado.cambio_usd)} - )} - -
- )} - {resultado.puntos_ganados > 0 && ( -
Puntos ganados+{resultado.puntos_ganados} pts
- )} -
Saldo monedero{resultado.wallet_points} pts
-
-

¡Gracias por su visita!

-
- )} -
+ footerExtra={} + ticket={resultado && { + folio: resultado.sale.name, + fecha: `${resultado.sale.date} ${new Date().toLocaleTimeString('es-MX', { hour: '2-digit', minute: '2-digit' })}`, + paciente: resultado.sale.patient, + lines: resultado.sale.lines.map((l) => ({ + name: l.service || l.item || l.description, + qty: l.quantity, + price: l.price_unit, + subtotal: l.subtotal, + })), + subtotal: resultado.sale.subtotal, + discount: resultado.sale.discount, + total: resultado.sale.total, + metodo: resultado.payment ? metodoLabel(resultado.payment.payment_method) : 'Puntos', + puntos_usados: resultado.puntos_usados, + puntos_ganados: resultado.puntos_ganados, + wallet_points: resultado.wallet_points, + cambio: resultado.cambio, + cambio_usd: resultado.cambio_usd, + recibido_usd: resultado.recibido_usd, + rate_used: resultado.rate_used, + }} + /> ); }; diff --git a/frontend/src/pages/Ventas.tsx b/frontend/src/pages/Ventas.tsx index deba4c3..31458e9 100644 --- a/frontend/src/pages/Ventas.tsx +++ b/frontend/src/pages/Ventas.tsx @@ -1,8 +1,9 @@ import type { FC } from 'react'; import { useEffect, useState, useCallback } from 'react'; -import { Plus, Trash2, CreditCard, ShoppingCart, Search, Undo2, Download } from 'lucide-react'; +import { Plus, Trash2, CreditCard, ShoppingCart, Search, Undo2, Download, Printer } from 'lucide-react'; import Layout from '../components/Layout'; import { usePatientDrawer } from '../components/PatientDrawer'; +import TicketPrint from '../components/TicketPrint'; import { Card, Button, @@ -59,6 +60,8 @@ const Ventas: FC = () => { const [refunding, setRefunding] = useState(false); const [refundAmount, setRefundAmount] = useState(''); const [refundReason, setRefundReason] = useState(''); + // 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); @@ -378,6 +381,9 @@ const Ventas: FC = () => {
+ {s.amount_due > 0 && ( {s.amount_due > 0 && (
)} + + {/* Reimprimir ticket de la venta (formato térmico ~80mm) */} + 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, + }} + /> ); };