diff --git a/frontend/src/components/CortePrint.tsx b/frontend/src/components/CortePrint.tsx index 5ef17fd..d7c2c4e 100644 --- a/frontend/src/components/CortePrint.tsx +++ b/frontend/src/components/CortePrint.tsx @@ -2,34 +2,20 @@ import type { FC } from 'react'; import { Printer } from 'lucide-react'; import { Button, Modal } from './ui'; import type { CashClosing } from '../services/odoo'; +import { useClinicaPrint } from '../lib/ticketConfig'; // Corte de caja imprimible. Sigue el patrón de impresión existente // (RecetaPrint / EstadoCuentaPrint / TicketPrint): contenido en un // div.print-ticket + window.print(); el CSS de @media print en index.css // oculta el resto de la página. -interface ClinicSettings { - name: string; - phone: string; - address: string; -} - -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: '' }; -}; const money = (v: number) => '$' + (v ?? 0).toLocaleString('es-MX', { minimumFractionDigits: 2, maximumFractionDigits: 2 }); export const CortePrint: FC<{ corte: CashClosing | null; onClose: () => void }> = ({ corte, onClose }) => { if (!corte) return null; - const clinic = clinicSettings(); + const clinic = useClinicaPrint(); const fila = (label: string, valor: number) => ( @@ -53,9 +39,9 @@ export const CortePrint: FC<{ corte: CashClosing | null; onClose: () => void }>
-

{clinic.name}

- {clinic.address &&

{clinic.address}

} - {clinic.phone &&

{clinic.phone}

} +

{clinic.nombre}

+ {clinic.direccion &&

{clinic.direccion}

} + {clinic.telefono &&

{clinic.telefono}

}

CORTE DE CAJA {corte.name}

Fecha: {corte.date} · Cerró: {corte.user || '—'}

diff --git a/frontend/src/components/EstadoCuentaPrint.tsx b/frontend/src/components/EstadoCuentaPrint.tsx index 843a3f9..977ab4a 100644 --- a/frontend/src/components/EstadoCuentaPrint.tsx +++ b/frontend/src/components/EstadoCuentaPrint.tsx @@ -3,22 +3,8 @@ import { useEffect, useState } from 'react'; import { Printer, Wallet, AlertTriangle } from 'lucide-react'; import { Button, Modal, Skeleton, toast } from './ui'; import { odooApi, type Patient, type Appointment, type Sale, type Visita, type EstadoCuenta } from '../services/odoo'; +import { useClinicaPrint } from '../lib/ticketConfig'; -interface ClinicSettings { - name: string; - phone: string; - address: string; -} - -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: '' }; -}; const fmtMoney = (n: number) => (n || 0).toLocaleString('es-MX', { style: 'currency', currency: 'MXN', maximumFractionDigits: 2 }); @@ -92,7 +78,7 @@ export const EstadoCuentaPrint: FC = ({ isOpen, onClose, if (!paciente) return null; - const clinic = clinicSettings(); + const clinic = useClinicaPrint(); const fechaEmision = new Date().toLocaleDateString('es-MX', { day: '2-digit', month: 'long', year: 'numeric' }); const totalVentas = ventas.reduce((a, v) => a + v.total, 0); const totalPagado = ventas.reduce((a, v) => a + v.amount_paid, 0); @@ -131,9 +117,9 @@ export const EstadoCuentaPrint: FC = ({ isOpen, onClose, {/* Encabezado */}
-

{clinic.name}

- {clinic.address &&

{clinic.address}

} - {clinic.phone &&

Tel: {clinic.phone}

} +

{clinic.nombre}

+ {clinic.direccion &&

{clinic.direccion}

} + {clinic.telefono &&

Tel: {clinic.telefono}

}

Estado de Cuenta

@@ -272,7 +258,7 @@ export const EstadoCuentaPrint: FC = ({ isOpen, onClose, {wallet && (

Monedero: {wallet.points} puntos disponibles ({fmtMoney(wallet.equivalent_mxn)}).

)} -

Documento informativo generado por {clinic.name}.

+

Documento informativo generado por {clinic.nombre}.

)} diff --git a/frontend/src/components/RecetaPrint.tsx b/frontend/src/components/RecetaPrint.tsx index 4a3df4e..9083757 100644 --- a/frontend/src/components/RecetaPrint.tsx +++ b/frontend/src/components/RecetaPrint.tsx @@ -3,22 +3,8 @@ import { useEffect, useState } from 'react'; import { Printer } from 'lucide-react'; import { Button, Modal, Select, TextArea } from './ui'; import { odooApi, type Receta } from '../services/odoo'; +import { useClinicaPrint } from '../lib/ticketConfig'; -interface ClinicSettings { - name: string; - phone: string; - address: string; -} - -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: '' }; -}; interface RecetaSectionProps { value: string; @@ -82,7 +68,7 @@ interface RecetaPrintProps { /** Vista de impresión de la receta médica (formato imprimible) */ export const RecetaPrint: FC = ({ isOpen, onClose, paciente, doctor, contenido }) => { - const clinic = clinicSettings(); + const clinic = useClinicaPrint(); const fecha = new Date().toLocaleDateString('es-MX', { day: '2-digit', month: 'long', year: 'numeric' }); return ( @@ -105,9 +91,9 @@ export const RecetaPrint: FC = ({ isOpen, onClose, paciente, d {/* Encabezado clínica */}
-

{clinic.name}

- {clinic.address &&

{clinic.address}

} - {clinic.phone &&

Tel: {clinic.phone}

} +

{clinic.nombre}

+ {clinic.direccion &&

{clinic.direccion}

} + {clinic.telefono &&

Tel: {clinic.telefono}

}

{fecha}

@@ -128,7 +114,7 @@ export const RecetaPrint: FC = ({ isOpen, onClose, paciente, d

{doctor || 'Médico tratante'}

-

{clinic.name}

+

{clinic.nombre}

diff --git a/frontend/src/components/ReciboPrint.tsx b/frontend/src/components/ReciboPrint.tsx index a9695c6..5ab93e5 100644 --- a/frontend/src/components/ReciboPrint.tsx +++ b/frontend/src/components/ReciboPrint.tsx @@ -2,6 +2,7 @@ import type { FC } from 'react'; import { Printer } from 'lucide-react'; import { Button, Modal } from './ui'; import type { ReceiptData } from '../services/odoo'; +import { useClinicaPrint } from '../lib/ticketConfig'; // Recibo de pago imprimible. Sigue el patrón de impresión existente // (TicketPrint / CortePrint / EstadoCuentaPrint): contenido en un @@ -16,25 +17,10 @@ const METODO_LABELS: Record = { const money = (v: number) => '$' + (v ?? 0).toLocaleString('es-MX', { minimumFractionDigits: 2, maximumFractionDigits: 2 }); -interface ClinicSettings { - name: string; - phone: string; - address: string; -} - -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: '' }; -}; export const ReciboPrint: FC<{ receipt: ReceiptData | null; onClose: () => void }> = ({ receipt, onClose }) => { if (!receipt) return null; - const clinic = clinicSettings(); + const clinic = useClinicaPrint(); // Recibo de recarga de saldo a favor (anticipo): mismo formato, sin el // desglose de adeudo y mostrando el saldo a favor resultante. const esDeposito = receipt.tipo === 'deposito'; @@ -43,9 +29,9 @@ export const ReciboPrint: FC<{ receipt: ReceiptData | null; onClose: () => void
-

{clinic.name}

- {clinic.address &&

{clinic.address}

} - {clinic.phone &&

{clinic.phone}

} +

{clinic.nombre}

+ {clinic.direccion &&

{clinic.direccion}

} + {clinic.telefono &&

{clinic.telefono}

}

{esDeposito ? 'RECIBO DE SALDO A FAVOR' : 'RECIBO DE PAGO'}

Folio {receipt.folio} · {receipt.date}

diff --git a/frontend/src/components/TicketDesigner.tsx b/frontend/src/components/TicketDesigner.tsx new file mode 100644 index 0000000..5a46a2b --- /dev/null +++ b/frontend/src/components/TicketDesigner.tsx @@ -0,0 +1,518 @@ +import type { FC } from 'react'; +import { useEffect, useMemo, useRef, useState } from 'react'; +import { ArrowDown, ArrowUp, Image as ImageIcon, RotateCcw, Save, GripVertical } from 'lucide-react'; +import { Button, Card, Input, Select, TextArea, toast } from './ui'; +import TicketDoc, { type TicketData } from './TicketDoc'; +import { + cargarTicketConfig, + recargarTicketConfig, + useTicketConfig, + type TicketConfig, + type TicketLayout, +} from '../lib/ticketConfig'; +import { odooApi } from '../services/odoo'; +import { useAuth } from '../lib/auth'; + +// Editor del diseño del ticket (Configuración › Tickets). +// +// Todo se ve reflejado en la vista previa en vivo: es un papel térmico, así que +// la forma más segura de editar es ver el resultado mientras se mueve cada +// opción. Al guardar se escribe en el servidor (ir.config_parameter) y las +// demás cajas lo toman con la siguiente impresión. + +const SECCIONES: { bloque: string; label: string; claves: { key: string; label: string }[] }[] = [ + { + bloque: 'encabezado', + label: 'Encabezado', + claves: [ + { key: 'nombre_clinica', label: 'Nombre de la clínica' }, + { key: 'direccion', label: 'Dirección' }, + { key: 'telefono_clinica', label: 'Teléfono' }, + { key: 'sucursal', label: 'Sucursal' }, + { key: 'rfc', label: 'RFC' }, + { key: 'mensaje_encabezado', label: 'Mensaje de encabezado' }, + ], + }, + { + bloque: 'cliente', + label: 'Datos del paciente', + claves: [ + { key: 'folio', label: 'Folio' }, + { key: 'fecha', label: 'Fecha' }, + { key: 'paciente', label: 'Paciente' }, + { key: 'telefono_paciente', label: 'Teléfono del paciente' }, + { key: 'doctor', label: 'Doctor que atendió' }, + { key: 'cajero', label: 'Quién cobró' }, + ], + }, + { + bloque: 'conceptos', + label: 'Conceptos', + claves: [ + { key: 'conceptos', label: 'Lista de conceptos' }, + { key: 'detalle_conceptos', label: 'Cantidad × precio en cada línea' }, + ], + }, + { + bloque: 'totales', + label: 'Totales', + claves: [ + { key: 'subtotal', label: 'Subtotal' }, + { key: 'descuento', label: 'Descuento' }, + { key: 'puntos_canjeados', label: 'Puntos canjeados' }, + { key: 'total', label: 'Total' }, + { key: 'total_usd', label: 'Equivalente en dólares' }, + ], + }, + { + bloque: 'pago', + label: 'Pago', + claves: [ + { key: 'metodo_pago', label: 'Método de pago' }, + { key: 'recibido', label: 'Recibido' }, + { key: 'cambio', label: 'Cambio' }, + { key: 'pagado', label: 'Pagado' }, + { key: 'saldo_pendiente', label: 'Saldo pendiente' }, + ], + }, + { + bloque: 'monedero', + label: 'Monedero', + claves: [ + { key: 'puntos_ganados', label: 'Puntos ganados' }, + { key: 'monedero', label: 'Saldo del monedero' }, + ], + }, + { + bloque: 'pie', + label: 'Pie', + claves: [ + { key: 'notas', label: 'Notas del ticket' }, + { key: 'politica', label: 'Aviso de validez fiscal' }, + { key: 'mensaje_pie', label: 'Mensaje de despedida' }, + ], + }, +]; + + +const MUESTRA: TicketData = { + folio: 'VENTA-00123', + fecha: '24/09/2026 12:35', + paciente: 'María Fernanda Ruiz', + telefono_paciente: '661 112 3456', + doctor: 'Dra. Aranza Beltrán', + cajero: 'recepcion', + lines: [ + { name: 'Limpieza facial profunda', qty: 1, price: 1250, subtotal: 1250 }, + { name: 'Peel químico', qty: 1, price: 1500, subtotal: 1500 }, + { name: 'Pantalla mineral SPF 50', qty: 2, price: 385, subtotal: 770 }, + ], + subtotal: 3520, + discount: 320, + total: 3200, + metodo: 'Mixto', + recibido: 3500, + cambio: 300, + amount_paid: 3200, + amount_due: 0, + puntos_usados: 200, + puntos_ganados: 320, + wallet_points: 1480, + rate_used: 17.25, + notas: 'Segunda sesión de peel en 4 semanas.', + sucursal: 'Rosarito Centro', +}; + +const CLINICA_CAMPOS: { key: string; label: string; placeholder?: string }[] = [ + { key: 'nombre', label: 'Nombre de la clínica' }, + { key: 'direccion', label: 'Dirección' }, + { key: 'telefono', label: 'Teléfono' }, + { key: 'email', label: 'Correo' }, + { key: 'sitio', label: 'Sitio web' }, + { key: 'rfc', label: 'RFC (opcional)' }, + { key: 'sucursal', label: 'Sucursal (opcional)' }, +]; + +export const TicketDesigner: FC = () => { + const config = useTicketConfig(); + const { user } = useAuth(); + // Restaurar el formato original queda solo para administración del sistema; + // gerencia puede editar y guardar su propio diseño. + const esAdmin = user?.role === 'admin'; + const [draft, setDraft] = useState(config); + const [guardando, setGuardando] = useState(false); + const [nota, setNota] = useState(''); + const [logoError, setLogoError] = useState(''); + const archivoRef = useRef(null); + + // Al terminar de cargar del servidor, sincroniza el borrador (la primera + // renderización puede venir del cache local). + useEffect(() => { + cargarTicketConfig().then((c) => setDraft(c)); + }, []); + + const sucio = useMemo( + () => JSON.stringify(draft) !== JSON.stringify(config), + [draft, config], + ); + + const setLayout = (parche: Partial) => + setDraft((d) => ({ ...d, layout: { ...d.layout, ...parche } })); + + const toggleSeccion = (key: string) => + setDraft((d) => ({ + ...d, + layout: { + ...d.layout, + secciones: { ...d.layout.secciones, [key]: d.layout.secciones[key] === false }, + }, + })); + + const setTexto = (key: keyof TicketLayout['textos'], valor: string) => + setDraft((d) => ({ + ...d, + layout: { ...d.layout, textos: { ...d.layout.textos, [key]: valor } }, + })); + + const setClinica = (key: string, valor: string) => + setDraft((d) => ({ ...d, clinica: { ...d.clinica, [key]: valor } })); + + const moverBloque = (i: number, dir: -1 | 1) => + setDraft((d) => { + const orden = [...d.layout.bloques]; + const j = i + dir; + if (j < 0 || j >= orden.length) return d; + [orden[i], orden[j]] = [orden[j], orden[i]]; + return { ...d, layout: { ...d.layout, bloques: orden } }; + }); + + const elegirLogo = (file?: File) => { + setLogoError(''); + if (!file) return; + if (file.size > 250 * 1024) { + setLogoError('La imagen pesa más de 250 KB. Use una versión pequeña.'); + return; + } + const lector = new FileReader(); + lector.onload = () => { + const url = String(lector.result || ''); + if (!url.startsWith('data:image/')) { + setLogoError('Solo se admiten imágenes (PNG, JPG o SVG).'); + return; + } + setDraft((d) => ({ + ...d, + logo: url, + clinica: { ...d.clinica, tiene_logo: true }, + })); + }; + lector.readAsDataURL(file); + }; + + const guardar = async () => { + try { + setGuardando(true); + const motivo = nota.trim() || 'actualizado desde Configuración'; + const resLay = await odooApi.putTicketConfig(draft.layout, motivo); + if (resLay.status !== 'success') { + toast.error(resLay.message || 'No se pudo guardar el diseño'); + return; + } + const clinica: Record = {}; + CLINICA_CAMPOS.forEach((c) => { + clinica[c.key] = String(draft.clinica[c.key as keyof typeof draft.clinica] ?? ''); + }); + const resCli = await odooApi.putTicketClinica({ + clinica, + logo: draft.clinica.tiene_logo ? draft.logo : '', + motivo, + }); + if (resCli.status !== 'success') { + toast.error(resCli.message || 'No se pudieron guardar los datos de la clínica'); + return; + } + await recargarTicketConfig(); + setNota(''); + toast.success('Diseño del ticket guardado. Las cajas lo toman al imprimir.'); + } catch (err) { + const msg = (err as { response?: { data?: { message?: string } } })?.response?.data?.message; + toast.error(msg || 'No se pudo guardar el diseño'); + } finally { + setGuardando(false); + } + }; + + const restaurar = async () => { + if (!window.confirm('¿Restaurar el diseño original del ticket? Se pierden los cambios guardados.')) { + return; + } + try { + setGuardando(true); + const res = await odooApi.resetTicketConfig(); + if (res.status !== 'success') { + toast.error(res.message || 'No se pudo restaurar'); + return; + } + const nuevo = await recargarTicketConfig(); + setDraft(nuevo); + toast.success('Diseño restaurado al formato original'); + } catch { + toast.error('No se pudo restaurar el diseño'); + } finally { + setGuardando(false); + } + }; + + const previewConfig: TicketConfig = draft; + + return ( +
+
+ + +
+

Formato del papel

+

+ Ancho de la térmica, tipografía y densidad del texto. +

+
+
+ setLayout({ fuente: e.target.value as TicketLayout['fuente'] })} + options={[ + { value: 'monoespaciada', label: 'Monoespaciada' }, + { value: 'sans', label: 'Ariel / sans' }, + ]} + /> + setLayout({ separador: e.target.value as TicketLayout['separador'] })} + options={[ + { value: 'guiones', label: 'Guiones (---)' }, + { value: 'solida', label: 'Línea sólida' }, + { value: 'ninguna', label: 'Sin línea' }, + ]} + /> +
+ +
+
+ + + +
+

Rubros del ticket

+

+ Marque lo que debe salir impreso. Use las flechas para cambiar el orden de las secciones. +

+
+
+ {[...SECCIONES].sort( + (a, b) => draft.layout.bloques.indexOf(a.bloque) - draft.layout.bloques.indexOf(b.bloque), + ).map((grupo) => { + const i = draft.layout.bloques.indexOf(grupo.bloque); + return ( +
+
+

+ + {grupo.label} +

+
+ + +
+
+
+ {grupo.claves.map((c) => ( + + ))} +
+
+ ); + })} +
+
+
+ + + +
+

Textos impresos

+

+ Mensajes que salen en el ticket además de los datos de la venta. +

+
+