Punto 11: editor de diseno de tickets en Configuracion > Tickets
- Nuevo modelo skeen.ticket.config (AbstractModel) con layout, datos de clinica y logo en ir.config_parameter; _mezclar() hace que layouts viejos hereden las secciones nuevas; _validar() rechaza valores imposibles y textos largos. - Endpoints GET/PUT /ticket_config, PUT /ticket_config/clinica, POST /ticket_config/reset (reset solo admin) con auditoria en skeen_config_audit. - Los datos de la clinica pasan de localStorage (cada caja imprimia distinto) a configuracion compartida en el servidor. - TicketDoc.tsx renderiza segun el diseno: ancho 58/80mm, fuente, tamano de letra, separador, mayusculas, orden de bloques y 29 secciones activables. - TicketDesigner.tsx: editor completo con vista previa en vivo, reorden de bloques, textos personalizables y carga de logo. - TicketPrint.tsx ahora es un wrapper de TicketDoc; ReciboPrint, EstadoCuentaPrint, RecetaPrint, CortePrint y ConteoPrint usan useClinicaPrint(). - _sale_to_dict expone telefono del paciente, medico que atendio y datos de paquete; pos_checkout devuelve recibido/cambio en MXN y USD para imprimirlos.
This commit is contained in:
@@ -2,34 +2,20 @@ import type { FC } from 'react';
|
|||||||
import { Printer } from 'lucide-react';
|
import { Printer } from 'lucide-react';
|
||||||
import { Button, Modal } from './ui';
|
import { Button, Modal } from './ui';
|
||||||
import type { CashClosing } from '../services/odoo';
|
import type { CashClosing } from '../services/odoo';
|
||||||
|
import { useClinicaPrint } from '../lib/ticketConfig';
|
||||||
|
|
||||||
// Corte de caja imprimible. Sigue el patrón de impresión existente
|
// Corte de caja imprimible. Sigue el patrón de impresión existente
|
||||||
// (RecetaPrint / EstadoCuentaPrint / TicketPrint): contenido en un
|
// (RecetaPrint / EstadoCuentaPrint / TicketPrint): contenido en un
|
||||||
// div.print-ticket + window.print(); el CSS de @media print en index.css
|
// div.print-ticket + window.print(); el CSS de @media print en index.css
|
||||||
// oculta el resto de la página.
|
// 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) =>
|
const money = (v: number) =>
|
||||||
'$' + (v ?? 0).toLocaleString('es-MX', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
'$' + (v ?? 0).toLocaleString('es-MX', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
||||||
|
|
||||||
export const CortePrint: FC<{ corte: CashClosing | null; onClose: () => void }> = ({ corte, onClose }) => {
|
export const CortePrint: FC<{ corte: CashClosing | null; onClose: () => void }> = ({ corte, onClose }) => {
|
||||||
if (!corte) return null;
|
if (!corte) return null;
|
||||||
const clinic = clinicSettings();
|
const clinic = useClinicaPrint();
|
||||||
|
|
||||||
const fila = (label: string, valor: number) => (
|
const fila = (label: string, valor: number) => (
|
||||||
<tr>
|
<tr>
|
||||||
@@ -53,9 +39,9 @@ export const CortePrint: FC<{ corte: CashClosing | null; onClose: () => void }>
|
|||||||
<Modal isOpen={!!corte} onClose={onClose} title={`Corte de caja ${corte.name}`} maxWidth="lg">
|
<Modal isOpen={!!corte} onClose={onClose} title={`Corte de caja ${corte.name}`} maxWidth="lg">
|
||||||
<div className="print-ticket bg-white text-[#222] p-5 rounded-xl border border-theme-border text-[13px] leading-snug">
|
<div className="print-ticket bg-white text-[#222] p-5 rounded-xl border border-theme-border text-[13px] leading-snug">
|
||||||
<div className="text-center mb-3">
|
<div className="text-center mb-3">
|
||||||
<p className="font-bold text-[15px]">{clinic.name}</p>
|
<p className="font-bold text-[15px]">{clinic.nombre}</p>
|
||||||
{clinic.address && <p className="text-[11px] text-[#555]">{clinic.address}</p>}
|
{clinic.direccion && <p className="text-[11px] text-[#555]">{clinic.direccion}</p>}
|
||||||
{clinic.phone && <p className="text-[11px] text-[#555]">{clinic.phone}</p>}
|
{clinic.telefono && <p className="text-[11px] text-[#555]">{clinic.telefono}</p>}
|
||||||
<p className="font-semibold mt-1">CORTE DE CAJA {corte.name}</p>
|
<p className="font-semibold mt-1">CORTE DE CAJA {corte.name}</p>
|
||||||
<p className="text-[11px] text-[#555]">Fecha: {corte.date} · Cerró: {corte.user || '—'}</p>
|
<p className="text-[11px] text-[#555]">Fecha: {corte.date} · Cerró: {corte.user || '—'}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -3,22 +3,8 @@ import { useEffect, useState } from 'react';
|
|||||||
import { Printer, Wallet, AlertTriangle } from 'lucide-react';
|
import { Printer, Wallet, AlertTriangle } from 'lucide-react';
|
||||||
import { Button, Modal, Skeleton, toast } from './ui';
|
import { Button, Modal, Skeleton, toast } from './ui';
|
||||||
import { odooApi, type Patient, type Appointment, type Sale, type Visita, type EstadoCuenta } from '../services/odoo';
|
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) =>
|
const fmtMoney = (n: number) =>
|
||||||
(n || 0).toLocaleString('es-MX', { style: 'currency', currency: 'MXN', maximumFractionDigits: 2 });
|
(n || 0).toLocaleString('es-MX', { style: 'currency', currency: 'MXN', maximumFractionDigits: 2 });
|
||||||
@@ -92,7 +78,7 @@ export const EstadoCuentaPrint: FC<EstadoCuentaPrintProps> = ({ isOpen, onClose,
|
|||||||
|
|
||||||
if (!paciente) return null;
|
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 fechaEmision = new Date().toLocaleDateString('es-MX', { day: '2-digit', month: 'long', year: 'numeric' });
|
||||||
const totalVentas = ventas.reduce((a, v) => a + v.total, 0);
|
const totalVentas = ventas.reduce((a, v) => a + v.total, 0);
|
||||||
const totalPagado = ventas.reduce((a, v) => a + v.amount_paid, 0);
|
const totalPagado = ventas.reduce((a, v) => a + v.amount_paid, 0);
|
||||||
@@ -131,9 +117,9 @@ export const EstadoCuentaPrint: FC<EstadoCuentaPrintProps> = ({ isOpen, onClose,
|
|||||||
{/* Encabezado */}
|
{/* Encabezado */}
|
||||||
<div className="flex items-start justify-between border-b-2 border-[#1abc9c] pb-3 mb-4">
|
<div className="flex items-start justify-between border-b-2 border-[#1abc9c] pb-3 mb-4">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xl font-bold">{clinic.name}</p>
|
<p className="text-xl font-bold">{clinic.nombre}</p>
|
||||||
{clinic.address && <p className="text-xs text-[#555]">{clinic.address}</p>}
|
{clinic.direccion && <p className="text-xs text-[#555]">{clinic.direccion}</p>}
|
||||||
{clinic.phone && <p className="text-xs text-[#555]">Tel: {clinic.phone}</p>}
|
{clinic.telefono && <p className="text-xs text-[#555]">Tel: {clinic.telefono}</p>}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-right">
|
<div className="text-right">
|
||||||
<p className="text-lg font-bold">Estado de Cuenta</p>
|
<p className="text-lg font-bold">Estado de Cuenta</p>
|
||||||
@@ -272,7 +258,7 @@ export const EstadoCuentaPrint: FC<EstadoCuentaPrintProps> = ({ isOpen, onClose,
|
|||||||
{wallet && (
|
{wallet && (
|
||||||
<p className="text-xs text-[#555] mt-1">Monedero: {wallet.points} puntos disponibles ({fmtMoney(wallet.equivalent_mxn)}).</p>
|
<p className="text-xs text-[#555] mt-1">Monedero: {wallet.points} puntos disponibles ({fmtMoney(wallet.equivalent_mxn)}).</p>
|
||||||
)}
|
)}
|
||||||
<p className="text-[10px] text-[#777] mt-3 text-center">Documento informativo generado por {clinic.name}.</p>
|
<p className="text-[10px] text-[#777] mt-3 text-center">Documento informativo generado por {clinic.nombre}.</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -3,22 +3,8 @@ import { useEffect, useState } from 'react';
|
|||||||
import { Printer } from 'lucide-react';
|
import { Printer } from 'lucide-react';
|
||||||
import { Button, Modal, Select, TextArea } from './ui';
|
import { Button, Modal, Select, TextArea } from './ui';
|
||||||
import { odooApi, type Receta } from '../services/odoo';
|
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 {
|
interface RecetaSectionProps {
|
||||||
value: string;
|
value: string;
|
||||||
@@ -82,7 +68,7 @@ interface RecetaPrintProps {
|
|||||||
|
|
||||||
/** Vista de impresión de la receta médica (formato imprimible) */
|
/** Vista de impresión de la receta médica (formato imprimible) */
|
||||||
export const RecetaPrint: FC<RecetaPrintProps> = ({ isOpen, onClose, paciente, doctor, contenido }) => {
|
export const RecetaPrint: FC<RecetaPrintProps> = ({ 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' });
|
const fecha = new Date().toLocaleDateString('es-MX', { day: '2-digit', month: 'long', year: 'numeric' });
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -105,9 +91,9 @@ export const RecetaPrint: FC<RecetaPrintProps> = ({ isOpen, onClose, paciente, d
|
|||||||
{/* Encabezado clínica */}
|
{/* Encabezado clínica */}
|
||||||
<div className="flex items-start justify-between border-b-2 border-[#1abc9c] pb-3 mb-4">
|
<div className="flex items-start justify-between border-b-2 border-[#1abc9c] pb-3 mb-4">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xl font-bold">{clinic.name}</p>
|
<p className="text-xl font-bold">{clinic.nombre}</p>
|
||||||
{clinic.address && <p className="text-xs text-[#555]">{clinic.address}</p>}
|
{clinic.direccion && <p className="text-xs text-[#555]">{clinic.direccion}</p>}
|
||||||
{clinic.phone && <p className="text-xs text-[#555]">Tel: {clinic.phone}</p>}
|
{clinic.telefono && <p className="text-xs text-[#555]">Tel: {clinic.telefono}</p>}
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-[#555]">{fecha}</p>
|
<p className="text-sm text-[#555]">{fecha}</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -128,7 +114,7 @@ export const RecetaPrint: FC<RecetaPrintProps> = ({ isOpen, onClose, paciente, d
|
|||||||
<div className="mt-10 text-center">
|
<div className="mt-10 text-center">
|
||||||
<div className="inline-block border-t border-[#333] pt-1.5 px-8">
|
<div className="inline-block border-t border-[#333] pt-1.5 px-8">
|
||||||
<p className="text-sm font-semibold">{doctor || 'Médico tratante'}</p>
|
<p className="text-sm font-semibold">{doctor || 'Médico tratante'}</p>
|
||||||
<p className="text-xs text-[#555]">{clinic.name}</p>
|
<p className="text-xs text-[#555]">{clinic.nombre}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import type { FC } from 'react';
|
|||||||
import { Printer } from 'lucide-react';
|
import { Printer } from 'lucide-react';
|
||||||
import { Button, Modal } from './ui';
|
import { Button, Modal } from './ui';
|
||||||
import type { ReceiptData } from '../services/odoo';
|
import type { ReceiptData } from '../services/odoo';
|
||||||
|
import { useClinicaPrint } from '../lib/ticketConfig';
|
||||||
|
|
||||||
// Recibo de pago imprimible. Sigue el patrón de impresión existente
|
// Recibo de pago imprimible. Sigue el patrón de impresión existente
|
||||||
// (TicketPrint / CortePrint / EstadoCuentaPrint): contenido en un
|
// (TicketPrint / CortePrint / EstadoCuentaPrint): contenido en un
|
||||||
@@ -16,25 +17,10 @@ const METODO_LABELS: Record<string, string> = {
|
|||||||
const money = (v: number) =>
|
const money = (v: number) =>
|
||||||
'$' + (v ?? 0).toLocaleString('es-MX', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
'$' + (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 }) => {
|
export const ReciboPrint: FC<{ receipt: ReceiptData | null; onClose: () => void }> = ({ receipt, onClose }) => {
|
||||||
if (!receipt) return null;
|
if (!receipt) return null;
|
||||||
const clinic = clinicSettings();
|
const clinic = useClinicaPrint();
|
||||||
// Recibo de recarga de saldo a favor (anticipo): mismo formato, sin el
|
// Recibo de recarga de saldo a favor (anticipo): mismo formato, sin el
|
||||||
// desglose de adeudo y mostrando el saldo a favor resultante.
|
// desglose de adeudo y mostrando el saldo a favor resultante.
|
||||||
const esDeposito = receipt.tipo === 'deposito';
|
const esDeposito = receipt.tipo === 'deposito';
|
||||||
@@ -43,9 +29,9 @@ export const ReciboPrint: FC<{ receipt: ReceiptData | null; onClose: () => void
|
|||||||
<Modal isOpen={!!receipt} onClose={onClose} title={`Recibo de pago ${receipt.folio}`} maxWidth="md">
|
<Modal isOpen={!!receipt} onClose={onClose} title={`Recibo de pago ${receipt.folio}`} maxWidth="md">
|
||||||
<div className="print-recibo bg-white text-[#222] p-6 rounded-xl border border-theme-border text-[13px] leading-snug">
|
<div className="print-recibo bg-white text-[#222] p-6 rounded-xl border border-theme-border text-[13px] leading-snug">
|
||||||
<div className="text-center mb-4 border-b border-[#ccc] pb-3">
|
<div className="text-center mb-4 border-b border-[#ccc] pb-3">
|
||||||
<p className="font-bold text-[15px]">{clinic.name}</p>
|
<p className="font-bold text-[15px]">{clinic.nombre}</p>
|
||||||
{clinic.address && <p className="text-[11px] text-[#555]">{clinic.address}</p>}
|
{clinic.direccion && <p className="text-[11px] text-[#555]">{clinic.direccion}</p>}
|
||||||
{clinic.phone && <p className="text-[11px] text-[#555]">{clinic.phone}</p>}
|
{clinic.telefono && <p className="text-[11px] text-[#555]">{clinic.telefono}</p>}
|
||||||
<p className="font-semibold mt-2 tracking-wide">{esDeposito ? 'RECIBO DE SALDO A FAVOR' : 'RECIBO DE PAGO'}</p>
|
<p className="font-semibold mt-2 tracking-wide">{esDeposito ? 'RECIBO DE SALDO A FAVOR' : 'RECIBO DE PAGO'}</p>
|
||||||
<p className="text-[11px] text-[#555]">Folio {receipt.folio} · {receipt.date}</p>
|
<p className="text-[11px] text-[#555]">Folio {receipt.folio} · {receipt.date}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
518
frontend/src/components/TicketDesigner.tsx
Normal file
518
frontend/src/components/TicketDesigner.tsx
Normal file
@@ -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<TicketConfig>(config);
|
||||||
|
const [guardando, setGuardando] = useState(false);
|
||||||
|
const [nota, setNota] = useState('');
|
||||||
|
const [logoError, setLogoError] = useState('');
|
||||||
|
const archivoRef = useRef<HTMLInputElement>(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<TicketLayout>) =>
|
||||||
|
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<string, string> = {};
|
||||||
|
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 (
|
||||||
|
<div className="grid grid-cols-1 xl:grid-cols-[1fr_340px] gap-4 items-start">
|
||||||
|
<div className="space-y-4">
|
||||||
|
<Card>
|
||||||
|
<Card.Body className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<h3 className="font-heading font-semibold text-theme-heading">Formato del papel</h3>
|
||||||
|
<p className="text-xs text-theme-muted mt-0.5">
|
||||||
|
Ancho de la térmica, tipografía y densidad del texto.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3">
|
||||||
|
<Select
|
||||||
|
label="Ancho"
|
||||||
|
value={draft.layout.ancho}
|
||||||
|
onChange={(e) => setLayout({ ancho: e.target.value as TicketLayout['ancho'] })}
|
||||||
|
options={[
|
||||||
|
{ value: '80mm', label: '80 mm (estándar)' },
|
||||||
|
{ value: '58mm', label: '58 mm (estrecha)' },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
label="Fuente"
|
||||||
|
value={draft.layout.fuente}
|
||||||
|
onChange={(e) => setLayout({ fuente: e.target.value as TicketLayout['fuente'] })}
|
||||||
|
options={[
|
||||||
|
{ value: 'monoespaciada', label: 'Monoespaciada' },
|
||||||
|
{ value: 'sans', label: 'Ariel / sans' },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
label="Tamaño de letra"
|
||||||
|
value={draft.layout.letra}
|
||||||
|
onChange={(e) => setLayout({ letra: e.target.value as TicketLayout['letra'] })}
|
||||||
|
options={[
|
||||||
|
{ value: 'compacta', label: 'Compacta (cabe más)' },
|
||||||
|
{ value: 'normal', label: 'Normal' },
|
||||||
|
{ value: 'grande', label: 'Grande (más legible)' },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
label="Separadores"
|
||||||
|
value={draft.layout.separador}
|
||||||
|
onChange={(e) => 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' },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<label className="flex items-center gap-2 text-sm text-theme-heading cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
className="rounded border-theme-border-strong"
|
||||||
|
checked={draft.layout.mayusculas}
|
||||||
|
onChange={(e) => setLayout({ mayusculas: e.target.checked })}
|
||||||
|
/>
|
||||||
|
Escribir todo en MAYÚSCULAS
|
||||||
|
</label>
|
||||||
|
</Card.Body>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<Card.Body className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<h3 className="font-heading font-semibold text-theme-heading">Rubros del ticket</h3>
|
||||||
|
<p className="text-xs text-theme-muted mt-0.5">
|
||||||
|
Marque lo que debe salir impreso. Use las flechas para cambiar el orden de las secciones.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-3">
|
||||||
|
{[...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 (
|
||||||
|
<div key={grupo.bloque} className="rounded-xl border border-theme-border p-3">
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<p className="text-sm font-medium text-theme-heading flex items-center gap-1.5">
|
||||||
|
<GripVertical size={13} className="text-theme-muted" />
|
||||||
|
{grupo.label}
|
||||||
|
</p>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
title="Subir sección"
|
||||||
|
disabled={i <= 0}
|
||||||
|
onClick={() => moverBloque(i, -1)}
|
||||||
|
>
|
||||||
|
<ArrowUp size={14} />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
title="Bajar sección"
|
||||||
|
disabled={i >= draft.layout.bloques.length - 1}
|
||||||
|
onClick={() => moverBloque(i, 1)}
|
||||||
|
>
|
||||||
|
<ArrowDown size={14} />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-x-4 gap-y-1.5">
|
||||||
|
{grupo.claves.map((c) => (
|
||||||
|
<label
|
||||||
|
key={c.key}
|
||||||
|
className="flex items-center gap-2 text-[13px] text-theme-muted cursor-pointer"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
className="rounded border-theme-border-strong"
|
||||||
|
checked={draft.layout.secciones[c.key] !== false}
|
||||||
|
onChange={() => toggleSeccion(c.key)}
|
||||||
|
/>
|
||||||
|
{c.label}
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</Card.Body>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<Card.Body className="space-y-3">
|
||||||
|
<div>
|
||||||
|
<h3 className="font-heading font-semibold text-theme-heading">Textos impresos</h3>
|
||||||
|
<p className="text-xs text-theme-muted mt-0.5">
|
||||||
|
Mensajes que salen en el ticket además de los datos de la venta.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<TextArea
|
||||||
|
label="Mensaje de encabezado"
|
||||||
|
rows={2}
|
||||||
|
maxLength={240}
|
||||||
|
placeholder="Ej. Consulta dermatológica y estética"
|
||||||
|
value={draft.layout.textos.encabezado}
|
||||||
|
onChange={(e) => setTexto('encabezado', e.target.value)}
|
||||||
|
/>
|
||||||
|
<TextArea
|
||||||
|
label="Mensaje de despedida (pie)"
|
||||||
|
rows={2}
|
||||||
|
maxLength={240}
|
||||||
|
value={draft.layout.textos.pie}
|
||||||
|
onChange={(e) => setTexto('pie', e.target.value)}
|
||||||
|
/>
|
||||||
|
<TextArea
|
||||||
|
label="Aviso de validez / política"
|
||||||
|
rows={2}
|
||||||
|
maxLength={240}
|
||||||
|
value={draft.layout.textos.politica}
|
||||||
|
onChange={(e) => setTexto('politica', e.target.value)}
|
||||||
|
/>
|
||||||
|
</Card.Body>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<Card.Body className="space-y-3">
|
||||||
|
<div>
|
||||||
|
<h3 className="font-heading font-semibold text-theme-heading">Datos de la clínica</h3>
|
||||||
|
<p className="text-xs text-theme-muted mt-0.5">
|
||||||
|
Antes se guardaban en cada computadora; ahora son compartidos para todas las cajas.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||||
|
{CLINICA_CAMPOS.map((c) => (
|
||||||
|
<Input
|
||||||
|
key={c.key}
|
||||||
|
label={c.label}
|
||||||
|
value={String(draft.clinica[c.key as keyof typeof draft.clinica] ?? '')}
|
||||||
|
maxLength={160}
|
||||||
|
onChange={(e) => setClinica(c.key, e.target.value)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap items-center gap-3 pt-1">
|
||||||
|
<input
|
||||||
|
ref={archivoRef}
|
||||||
|
type="file"
|
||||||
|
accept="image/png,image/jpeg,image/svg+xml"
|
||||||
|
className="hidden"
|
||||||
|
onChange={(e) => elegirLogo(e.target.files?.[0])}
|
||||||
|
/>
|
||||||
|
<Button variant="outline" size="sm" onClick={() => archivoRef.current?.click()}>
|
||||||
|
<ImageIcon size={14} className="mr-1.5" /> Cambiar logo
|
||||||
|
</Button>
|
||||||
|
{draft.clinica.tiene_logo && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setDraft((d) => ({
|
||||||
|
...d,
|
||||||
|
logo: '',
|
||||||
|
clinica: { ...d.clinica, tiene_logo: false },
|
||||||
|
}))}
|
||||||
|
>
|
||||||
|
Quitar logo
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<span className="text-xs text-theme-muted">PNG o JPG, máximo 250 KB</span>
|
||||||
|
</div>
|
||||||
|
{logoError && <p className="text-xs text-rose-600">{logoError}</p>}
|
||||||
|
</Card.Body>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<Card.Body className="space-y-3">
|
||||||
|
<Input
|
||||||
|
label="Nota del cambio (para la bitácora)"
|
||||||
|
placeholder="Ej. se quitó el RFC porque las cajas de 58 mm lo cortaban"
|
||||||
|
value={nota}
|
||||||
|
onChange={(e) => setNota(e.target.value)}
|
||||||
|
/>
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<Button onClick={guardar} loading={guardando} disabled={!sucio}>
|
||||||
|
<Save size={15} className="mr-2" /> Guardar diseño
|
||||||
|
</Button>
|
||||||
|
{esAdmin && (
|
||||||
|
<Button variant="outline" onClick={restaurar} disabled={guardando}>
|
||||||
|
<RotateCcw size={15} className="mr-2" /> Restaurar original
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{sucio && (
|
||||||
|
<span className="text-xs text-amber-700">
|
||||||
|
Hay cambios sin guardar.
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Card.Body>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="xl:sticky xl:top-4">
|
||||||
|
<p className="text-xs font-medium text-theme-muted uppercase mb-2">Vista previa</p>
|
||||||
|
<div className="rounded-2xl bg-theme-bg border border-theme-border p-4 overflow-auto max-h-[calc(100vh-8rem)]">
|
||||||
|
<TicketDoc data={MUESTRA} config={previewConfig} preview />
|
||||||
|
</div>
|
||||||
|
<p className="text-[11px] text-theme-muted mt-2">
|
||||||
|
La vista previa usa una venta de ejemplo con todos los rubros. Los datos reales se toman
|
||||||
|
de la venta al imprimir.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default TicketDesigner;
|
||||||
277
frontend/src/components/TicketDoc.tsx
Normal file
277
frontend/src/components/TicketDoc.tsx
Normal file
@@ -0,0 +1,277 @@
|
|||||||
|
import type { FC, ReactElement } from 'react';
|
||||||
|
import type { TicketConfig } from '../lib/ticketConfig';
|
||||||
|
|
||||||
|
// Documento ticket según el diseño guardado en Configuración › Tickets.
|
||||||
|
// Se renderiza igual en la vista previa del editor, en el modal del POS y en
|
||||||
|
// la impresión (la clase print-ticket es la que el CSS de @media print aísla).
|
||||||
|
|
||||||
|
export interface TicketLinea {
|
||||||
|
name: string;
|
||||||
|
qty: number;
|
||||||
|
price: number;
|
||||||
|
subtotal: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TicketData {
|
||||||
|
folio: string;
|
||||||
|
fecha: string;
|
||||||
|
paciente: string;
|
||||||
|
lines: TicketLinea[];
|
||||||
|
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?: number;
|
||||||
|
recibido_usd?: number;
|
||||||
|
rate_used?: number | null;
|
||||||
|
amount_paid?: number;
|
||||||
|
amount_due?: number;
|
||||||
|
// Rubros opcionales que el diseño puede mostrar
|
||||||
|
cajero?: string;
|
||||||
|
doctor?: string;
|
||||||
|
telefono_paciente?: string;
|
||||||
|
notas?: string;
|
||||||
|
sucursal?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ANCHO_PANTALLA: Record<string, number> = { '58mm': 210, '80mm': 300 };
|
||||||
|
const PX_LETRA: Record<string, number> = { compacta: 10, normal: 12, grande: 14 };
|
||||||
|
const FUENTE: Record<string, string> = {
|
||||||
|
monoespaciada: 'ui-monospace, SFMono-Regular, Menlo, monospace',
|
||||||
|
sans: 'ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, sans-serif',
|
||||||
|
};
|
||||||
|
|
||||||
|
const fmtMXN = (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 FilaProps {
|
||||||
|
izquierda: string;
|
||||||
|
derecha: string;
|
||||||
|
fuerte?: boolean;
|
||||||
|
pequena?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const Fila: FC<FilaProps> = ({ izquierda, derecha, fuerte, pequena }) => (
|
||||||
|
<div
|
||||||
|
className={`flex justify-between gap-2 ${fuerte ? 'font-bold' : ''} ${
|
||||||
|
pequena ? 'text-[0.82em] opacity-90' : ''
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span>{izquierda}</span>
|
||||||
|
<span className="whitespace-nowrap">{derecha}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
interface TicketDocProps {
|
||||||
|
data: TicketData;
|
||||||
|
config: TicketConfig;
|
||||||
|
/** true cuando se dibuja dentro del modal del editor (sin clase de impresión) */
|
||||||
|
preview?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TicketDoc: FC<TicketDocProps> = ({ data, config, preview }) => {
|
||||||
|
const lay = config.layout;
|
||||||
|
const on = (clave: string) => lay.secciones[clave] !== false;
|
||||||
|
const px = PX_LETRA[lay.letra] || 12;
|
||||||
|
const ancho = ANCHO_PANTALLA[lay.ancho] || 300;
|
||||||
|
|
||||||
|
const Sep: FC<{ className?: string }> = ({ className = '' }) => {
|
||||||
|
if (lay.separador === 'ninguna') return <div className={`my-1 ${className}`} />;
|
||||||
|
if (lay.separador === 'solida') {
|
||||||
|
return <div className={`border-t border-[#999] my-1 ${className}`} />;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div className={`overflow-hidden whitespace-nowrap my-1 ${className}`} aria-hidden>
|
||||||
|
{'-'.repeat(46)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const encabezado = (
|
||||||
|
<div key="encabezado" className="text-center">
|
||||||
|
{config.logo && (
|
||||||
|
<img
|
||||||
|
src={config.logo}
|
||||||
|
alt=""
|
||||||
|
className="mx-auto mb-1 max-h-[54px] w-auto object-contain"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{on('nombre_clinica') && <p className="font-bold text-[1.15em]">{config.clinica.nombre}</p>}
|
||||||
|
{on('direccion') && config.clinica.direccion && (
|
||||||
|
<p className="text-[0.92em]">{config.clinica.direccion}</p>
|
||||||
|
)}
|
||||||
|
{on('telefono_clinica') && config.clinica.telefono && (
|
||||||
|
<p className="text-[0.92em]">Tel. {config.clinica.telefono}</p>
|
||||||
|
)}
|
||||||
|
{on('rfc') && config.clinica.rfc && <p className="text-[0.92em]">RFC {config.clinica.rfc}</p>}
|
||||||
|
{on('sucursal') && config.clinica.sucursal && (
|
||||||
|
<p className="text-[0.92em]">Sucursal {config.clinica.sucursal}</p>
|
||||||
|
)}
|
||||||
|
{on('mensaje_encabezado') && lay.textos.encabezado && (
|
||||||
|
<p className="text-[0.92em] mt-1">{lay.textos.encabezado}</p>
|
||||||
|
)}
|
||||||
|
<Sep />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const cliente = (
|
||||||
|
<div key="cliente" className="mb-1">
|
||||||
|
{(on('folio') || on('fecha')) && (
|
||||||
|
<div className="flex justify-between gap-2 text-[0.92em]">
|
||||||
|
<span>{on('folio') ? data.folio : ''}</span>
|
||||||
|
<span>{on('fecha') ? data.fecha : ''}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{on('paciente') && <p>Paciente: {data.paciente}</p>}
|
||||||
|
{on('telefono_paciente') && data.telefono_paciente && (
|
||||||
|
<p className="text-[0.92em]">Tel. {data.telefono_paciente}</p>
|
||||||
|
)}
|
||||||
|
{on('doctor') && data.doctor && <p className="text-[0.92em]">Doctor: {data.doctor}</p>}
|
||||||
|
{on('cajero') && data.cajero && <p className="text-[0.92em]">Atendió: {data.cajero}</p>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const conceptos = (
|
||||||
|
<div key="conceptos">
|
||||||
|
{on('conceptos') && (
|
||||||
|
<table className="w-full mb-1">
|
||||||
|
<tbody>
|
||||||
|
{data.lines.map((l, i) => (
|
||||||
|
<tr key={i}>
|
||||||
|
<td className="py-0.5 align-top pr-1">
|
||||||
|
{on('detalle_conceptos') ? `${l.qty} × ${fmtMXN(l.price)} ` : ''}
|
||||||
|
{l.name}
|
||||||
|
</td>
|
||||||
|
<td className="py-0.5 text-right align-top whitespace-nowrap">
|
||||||
|
{fmtMXN(l.subtotal)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
)}
|
||||||
|
<Sep />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const totales = (
|
||||||
|
<div key="totales" className="space-y-0.5">
|
||||||
|
{on('subtotal') && <Fila izquierda="Subtotal" derecha={fmtMXN(data.subtotal)} />}
|
||||||
|
{on('descuento') && data.discount > 0 && (
|
||||||
|
<Fila izquierda="Descuento" derecha={`-${fmtMXN(data.discount)}`} />
|
||||||
|
)}
|
||||||
|
{on('puntos_canjeados') && (data.puntos_usados ?? 0) > 0 && (
|
||||||
|
<Fila izquierda="Puntos canjeados" derecha={`-${data.puntos_usados} pts`} />
|
||||||
|
)}
|
||||||
|
{on('total') && <Fila izquierda="Total" derecha={fmtMXN(data.total)} fuerte />}
|
||||||
|
{on('total_usd') && data.rate_used ? (
|
||||||
|
<Fila
|
||||||
|
izquierda={`Equiv. USD (TC ${data.rate_used.toFixed(2)})`}
|
||||||
|
derecha={fmtUSD(data.total / data.rate_used)}
|
||||||
|
pequena
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
<Sep />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const pago = (
|
||||||
|
<div key="pago" className="space-y-0.5">
|
||||||
|
{on('metodo_pago') && data.metodo && <Fila izquierda="Método" derecha={data.metodo} />}
|
||||||
|
{on('recibido') && (data.recibido ?? 0) > 0 && (
|
||||||
|
<Fila
|
||||||
|
izquierda="Recibido"
|
||||||
|
derecha={
|
||||||
|
(data.recibido_usd ?? 0) > 0 && data.rate_used
|
||||||
|
? `${fmtMXN(data.recibido!)} · ${fmtUSD(data.recibido_usd!)}`
|
||||||
|
: fmtMXN(data.recibido!)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{on('cambio') && (data.cambio ?? 0) > 0 && (
|
||||||
|
<Fila
|
||||||
|
izquierda="Cambio"
|
||||||
|
derecha={
|
||||||
|
(data.cambio_usd ?? 0) > 0
|
||||||
|
? `${fmtMXN(data.cambio!)} · ${fmtUSD(data.cambio_usd!)}`
|
||||||
|
: fmtMXN(data.cambio!)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{on('pagado') && (data.amount_paid ?? 0) > 0 && (
|
||||||
|
<Fila izquierda="Pagado" derecha={fmtMXN(data.amount_paid!)} />
|
||||||
|
)}
|
||||||
|
{on('saldo_pendiente') && (data.amount_due ?? 0) > 0 && (
|
||||||
|
<Fila izquierda="Saldo pendiente" derecha={fmtMXN(data.amount_due!)} fuerte />
|
||||||
|
)}
|
||||||
|
{(on('metodo_pago') || on('recibido') || on('cambio') || on('pagado') || on('saldo_pendiente')) && <Sep />}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const monedero = (
|
||||||
|
<div key="monedero" className="space-y-0.5">
|
||||||
|
{on('puntos_ganados') && (data.puntos_ganados ?? 0) > 0 && (
|
||||||
|
<Fila izquierda="Puntos ganados" derecha={`+${data.puntos_ganados} pts`} />
|
||||||
|
)}
|
||||||
|
{on('monedero') && data.wallet_points !== null && data.wallet_points !== undefined && (
|
||||||
|
<Fila izquierda="Saldo monedero" derecha={`${data.wallet_points} pts`} />
|
||||||
|
)}
|
||||||
|
{((on('puntos_ganados') && (data.puntos_ganados ?? 0) > 0)
|
||||||
|
|| (on('monedero') && data.wallet_points !== null && data.wallet_points !== undefined)) && <Sep />}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const pie = (
|
||||||
|
<div key="pie">
|
||||||
|
{on('notas') && data.notas && (
|
||||||
|
<p className="text-[0.92em] mt-1 whitespace-pre-wrap">Nota: {data.notas}</p>
|
||||||
|
)}
|
||||||
|
{on('politica') && lay.textos.politica && (
|
||||||
|
<p className="text-[0.85em] mt-1 opacity-80">{lay.textos.politica}</p>
|
||||||
|
)}
|
||||||
|
{on('mensaje_pie') && lay.textos.pie && (
|
||||||
|
<p className="text-center text-[0.95em] mt-2">{lay.textos.pie}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const partes: Record<string, ReactElement> = {
|
||||||
|
encabezado, cliente, conceptos, totales, pago, monedero, pie,
|
||||||
|
};
|
||||||
|
// Cualquier bloque nuevo en el backend se renderiza igual aunque el config
|
||||||
|
// guardado aún no lo mencione.
|
||||||
|
const orden = [...lay.bloques];
|
||||||
|
Object.keys(partes).forEach((b) => {
|
||||||
|
if (!orden.includes(b)) orden.push(b);
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={[
|
||||||
|
preview ? 'rounded-xl border border-theme-border' : 'print-ticket mx-auto rounded-xl border border-theme-border',
|
||||||
|
'bg-white text-[#222] p-4 leading-snug',
|
||||||
|
lay.mayusculas ? 'uppercase' : '',
|
||||||
|
].join(' ')}
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
maxWidth: `${ancho}px`,
|
||||||
|
fontFamily: FUENTE[lay.fuente] || FUENTE.monoespaciada,
|
||||||
|
fontSize: `${px}px`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{orden.map((b) => partes[b])}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default TicketDoc;
|
||||||
@@ -1,64 +1,19 @@
|
|||||||
import type { FC, ReactNode } from 'react';
|
import type { FC, ReactNode } from 'react';
|
||||||
import { Printer } from 'lucide-react';
|
import { Printer } from 'lucide-react';
|
||||||
import { Button, Modal } from './ui';
|
import { Button, Modal } from './ui';
|
||||||
|
import TicketDoc, { type TicketData } from './TicketDoc';
|
||||||
|
import { useTicketConfig } from '../lib/ticketConfig';
|
||||||
|
|
||||||
// Ticket térmico (~80mm) imprimible, reutilizado por:
|
// Ticket térmico imprimible, reutilizado por:
|
||||||
// - POS: se ofrece automáticamente tras un cobro exitoso (modal de éxito)
|
// - POS: se ofrece automáticamente tras un cobro exitoso (modal de éxito)
|
||||||
// - Ventas: botón "Reimprimir ticket" por venta
|
// - Ventas / Tickets: botón "Reimprimir ticket"
|
||||||
// Sigue el patrón de impresión existente (RecetaPrint / EstadoCuentaPrint):
|
// El diseño (ancho, letra, rubros visibles, textos y datos de la clínica) viene
|
||||||
// contenido en un div.print-ticket + window.print(); el CSS de @media print
|
// de Configuración › Tickets; ver TicketDoc.
|
||||||
// en index.css oculta el resto de la página.
|
// Patrón de impresión existente: div.print-ticket + window.print(); el CSS de
|
||||||
|
// @media print en index.css oculta el resto de la página.
|
||||||
|
|
||||||
export interface TicketLinePrint {
|
export type TicketPrintData = TicketData;
|
||||||
name: string;
|
export type { TicketLinea as TicketLinePrint } from './TicketDoc';
|
||||||
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 {
|
interface TicketPrintProps {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
@@ -69,8 +24,8 @@ interface TicketPrintProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const TicketPrint: FC<TicketPrintProps> = ({ isOpen, onClose, ticket, footerExtra }) => {
|
export const TicketPrint: FC<TicketPrintProps> = ({ isOpen, onClose, ticket, footerExtra }) => {
|
||||||
if (!ticket) return null;
|
const config = useTicketConfig();
|
||||||
const clinic = clinicSettings();
|
if (!ticket || !isOpen) return null;
|
||||||
return (
|
return (
|
||||||
<Modal
|
<Modal
|
||||||
isOpen={isOpen}
|
isOpen={isOpen}
|
||||||
@@ -87,76 +42,7 @@ export const TicketPrint: FC<TicketPrintProps> = ({ isOpen, onClose, ticket, foo
|
|||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<div
|
<TicketDoc data={ticket} config={config} />
|
||||||
className="print-ticket mx-auto bg-white text-[#222] p-4 rounded-xl border border-theme-border font-mono text-[12px] leading-snug"
|
|
||||||
style={{ maxWidth: '300px' }}
|
|
||||||
>
|
|
||||||
{/* Encabezado de la clínica */}
|
|
||||||
<div className="text-center border-b border-dashed border-neutral-300 pb-2 mb-2">
|
|
||||||
<p className="font-bold text-sm">{clinic.name}</p>
|
|
||||||
{clinic.address && <p className="text-[11px]">{clinic.address}</p>}
|
|
||||||
{clinic.phone && <p className="text-[11px]">Tel. {clinic.phone}</p>}
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-between text-[11px] mb-1">
|
|
||||||
<span>{ticket.folio}</span>
|
|
||||||
<span>{ticket.fecha}</span>
|
|
||||||
</div>
|
|
||||||
<p className="mb-2">Paciente: {ticket.paciente}</p>
|
|
||||||
|
|
||||||
{/* Líneas del ticket */}
|
|
||||||
<table className="w-full mb-2">
|
|
||||||
<tbody>
|
|
||||||
{ticket.lines.map((l, i) => (
|
|
||||||
<tr key={i}>
|
|
||||||
<td className="py-0.5 align-top">{l.qty} × {l.name}</td>
|
|
||||||
<td className="py-0.5 text-right align-top whitespace-nowrap">{fmtMoney(l.subtotal)}</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
|
|
||||||
<div className="border-t border-dashed border-neutral-300 pt-1.5 space-y-0.5">
|
|
||||||
<div className="flex justify-between"><span>Subtotal</span><span>{fmtMoney(ticket.subtotal)}</span></div>
|
|
||||||
{ticket.discount > 0 && (
|
|
||||||
<div className="flex justify-between"><span>Descuento</span><span>-{fmtMoney(ticket.discount)}</span></div>
|
|
||||||
)}
|
|
||||||
{(ticket.puntos_usados ?? 0) > 0 && (
|
|
||||||
<div className="flex justify-between"><span>Puntos canjeados</span><span>-{ticket.puntos_usados} pts</span></div>
|
|
||||||
)}
|
|
||||||
<div className="flex justify-between font-bold text-sm"><span>Total</span><span>{fmtMoney(ticket.total)}</span></div>
|
|
||||||
{ticket.rate_used ? (
|
|
||||||
<div className="flex justify-between text-[11px]"><span>Equiv. USD (TC {ticket.rate_used.toFixed(2)})</span><span>{fmtUsd(ticket.total / ticket.rate_used)}</span></div>
|
|
||||||
) : null}
|
|
||||||
{ticket.metodo && (
|
|
||||||
<div className="flex justify-between"><span>Método</span><span>{ticket.metodo}</span></div>
|
|
||||||
)}
|
|
||||||
{(ticket.recibido_usd ?? 0) > 0 && ticket.rate_used ? (
|
|
||||||
<div className="flex justify-between"><span>Recibido USD</span><span>{fmtUsd(ticket.recibido_usd!)}</span></div>
|
|
||||||
) : null}
|
|
||||||
{(ticket.cambio ?? 0) > 0 && (
|
|
||||||
<div className="flex justify-between">
|
|
||||||
<span>Cambio</span>
|
|
||||||
<span>
|
|
||||||
{fmtMoney(ticket.cambio!)}
|
|
||||||
{(ticket.cambio_usd ?? 0) > 0 && ` · ${fmtUsd(ticket.cambio_usd!)}`}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{(ticket.amount_paid ?? 0) > 0 && (
|
|
||||||
<div className="flex justify-between"><span>Pagado</span><span>{fmtMoney(ticket.amount_paid!)}</span></div>
|
|
||||||
)}
|
|
||||||
{(ticket.amount_due ?? 0) > 0 && (
|
|
||||||
<div className="flex justify-between font-bold"><span>Saldo pendiente</span><span>{fmtMoney(ticket.amount_due!)}</span></div>
|
|
||||||
)}
|
|
||||||
{(ticket.puntos_ganados ?? 0) > 0 && (
|
|
||||||
<div className="flex justify-between"><span>Puntos ganados</span><span>+{ticket.puntos_ganados} pts</span></div>
|
|
||||||
)}
|
|
||||||
{ticket.wallet_points !== null && ticket.wallet_points !== undefined && (
|
|
||||||
<div className="flex justify-between"><span>Saldo monedero</span><span>{ticket.wallet_points} pts</span></div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<p className="text-center text-[11px] mt-3">¡Gracias por su visita!</p>
|
|
||||||
</div>
|
|
||||||
</Modal>
|
</Modal>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import type { FC } from 'react';
|
|||||||
import { Printer } from 'lucide-react';
|
import { Printer } from 'lucide-react';
|
||||||
import { Button, Modal } from '../ui';
|
import { Button, Modal } from '../ui';
|
||||||
import type { InventoryConteo } from '../../services/odoo';
|
import type { InventoryConteo } from '../../services/odoo';
|
||||||
|
import { useClinicaPrint } from '../../lib/ticketConfig';
|
||||||
|
|
||||||
// Resumen imprimible del conteo físico. Sigue el patrón print-* existente
|
// Resumen imprimible del conteo físico. Sigue el patrón print-* existente
|
||||||
// (TicketPrint / CortePrint): div.print-recibo + window.print().
|
// (TicketPrint / CortePrint): div.print-recibo + window.print().
|
||||||
@@ -9,32 +10,17 @@ import type { InventoryConteo } from '../../services/odoo';
|
|||||||
const money = (v: number) =>
|
const money = (v: number) =>
|
||||||
'$' + (v ?? 0).toLocaleString('es-MX', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
'$' + (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 ConteoPrint: FC<{ conteo: InventoryConteo | null; onClose: () => void }> = ({ conteo, onClose }) => {
|
export const ConteoPrint: FC<{ conteo: InventoryConteo | null; onClose: () => void }> = ({ conteo, onClose }) => {
|
||||||
if (!conteo) return null;
|
if (!conteo) return null;
|
||||||
const clinic = clinicSettings();
|
const clinic = useClinicaPrint();
|
||||||
const lineas = conteo.lines || [];
|
const lineas = conteo.lines || [];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal isOpen={!!conteo} onClose={onClose} title={`Conteo físico ${conteo.name}`} maxWidth="lg">
|
<Modal isOpen={!!conteo} onClose={onClose} title={`Conteo físico ${conteo.name}`} maxWidth="lg">
|
||||||
<div className="print-recibo bg-white text-[#222] p-6 rounded-xl border border-theme-border text-[13px] leading-snug">
|
<div className="print-recibo bg-white text-[#222] p-6 rounded-xl border border-theme-border text-[13px] leading-snug">
|
||||||
<div className="text-center mb-4 border-b border-[#ccc] pb-3">
|
<div className="text-center mb-4 border-b border-[#ccc] pb-3">
|
||||||
<p className="font-bold text-[15px]">{clinic.name}</p>
|
<p className="font-bold text-[15px]">{clinic.nombre}</p>
|
||||||
<p className="font-semibold mt-2 tracking-wide">CONTEO FÍSICO DE INVENTARIO</p>
|
<p className="font-semibold mt-2 tracking-wide">CONTEO FÍSICO DE INVENTARIO</p>
|
||||||
<p className="text-[11px] text-[#555]">
|
<p className="text-[11px] text-[#555]">
|
||||||
{conteo.name} · {conteo.date || '—'} · Realizó: {conteo.user_name || '—'}
|
{conteo.name} · {conteo.date || '—'} · Realizó: {conteo.user_name || '—'}
|
||||||
|
|||||||
@@ -273,12 +273,11 @@ p {
|
|||||||
left: 0;
|
left: 0;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
/* Ticket térmico ~80mm, monoespaciado */
|
/* Ticket térmico: el ancho, la fuente y el tamaño los decide el diseño
|
||||||
|
guardado en Configuración › Tickets, que viajan como style en línea del
|
||||||
|
documento. Aquí solo se quitan adornos para el papel. */
|
||||||
.print-ticket {
|
.print-ticket {
|
||||||
width: 76mm;
|
width: auto;
|
||||||
max-width: 76mm;
|
|
||||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
|
||||||
font-size: 11px;
|
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: 0;
|
border-radius: 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
|
|||||||
197
frontend/src/lib/ticketConfig.ts
Normal file
197
frontend/src/lib/ticketConfig.ts
Normal file
@@ -0,0 +1,197 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { odooApi } from '../services/odoo';
|
||||||
|
|
||||||
|
// Configuración del ticket (diseño + datos de la clínica + logo).
|
||||||
|
//
|
||||||
|
// Vive en el servidor (ir.config_parameter) para que todas las cajas impriman
|
||||||
|
// igual. Antes los datos de la clínica se guardaban en el localStorage de cada
|
||||||
|
// navegador (clave skeen_clinic_settings), así que cada equipo imprimía una
|
||||||
|
// dirección distinta. Este módulo cachea la configuración en memoria, la
|
||||||
|
// comparte con quien la pida y deja el localStorage como respaldo de arranque.
|
||||||
|
|
||||||
|
export interface TicketClinica {
|
||||||
|
nombre: string;
|
||||||
|
direccion: string;
|
||||||
|
telefono: string;
|
||||||
|
email: string;
|
||||||
|
sitio: string;
|
||||||
|
rfc: string;
|
||||||
|
sucursal: string;
|
||||||
|
tiene_logo: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TicketLayout {
|
||||||
|
ancho: '58mm' | '80mm';
|
||||||
|
fuente: 'monoespaciada' | 'sans';
|
||||||
|
letra: 'compacta' | 'normal' | 'grande';
|
||||||
|
separador: 'guiones' | 'solida' | 'ninguna';
|
||||||
|
mayusculas: boolean;
|
||||||
|
bloques: string[];
|
||||||
|
secciones: Record<string, boolean>;
|
||||||
|
textos: { encabezado: string; pie: string; politica: string };
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TicketConfig {
|
||||||
|
layout: TicketLayout;
|
||||||
|
clinica: TicketClinica;
|
||||||
|
logo: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const CACHE_KEY = 'skeen_ticket_config';
|
||||||
|
|
||||||
|
// Respaldo por si el API no responde (sin sesión, red caída): el último config
|
||||||
|
// leído, o el formato histórico del ticket si nunca se leyó nada.
|
||||||
|
export const FALLBACK_CONFIG: TicketConfig = {
|
||||||
|
layout: {
|
||||||
|
ancho: '80mm',
|
||||||
|
fuente: 'monoespaciada',
|
||||||
|
letra: 'normal',
|
||||||
|
separador: 'guiones',
|
||||||
|
mayusculas: true,
|
||||||
|
bloques: ['encabezado', 'cliente', 'conceptos', 'totales', 'pago', 'monedero', 'pie'],
|
||||||
|
secciones: {
|
||||||
|
nombre_clinica: true,
|
||||||
|
direccion: true,
|
||||||
|
telefono_clinica: true,
|
||||||
|
sucursal: true,
|
||||||
|
rfc: false,
|
||||||
|
mensaje_encabezado: true,
|
||||||
|
folio: true,
|
||||||
|
fecha: true,
|
||||||
|
cajero: true,
|
||||||
|
paciente: true,
|
||||||
|
telefono_paciente: false,
|
||||||
|
doctor: true,
|
||||||
|
conceptos: true,
|
||||||
|
detalle_conceptos: true,
|
||||||
|
subtotal: true,
|
||||||
|
descuento: true,
|
||||||
|
puntos_canjeados: true,
|
||||||
|
total: true,
|
||||||
|
total_usd: true,
|
||||||
|
metodo_pago: true,
|
||||||
|
recibido: true,
|
||||||
|
cambio: true,
|
||||||
|
pagado: true,
|
||||||
|
saldo_pendiente: true,
|
||||||
|
puntos_ganados: true,
|
||||||
|
monedero: true,
|
||||||
|
notas: true,
|
||||||
|
politica: false,
|
||||||
|
mensaje_pie: true,
|
||||||
|
},
|
||||||
|
textos: {
|
||||||
|
encabezado: '',
|
||||||
|
pie: '¡Gracias por su visita!',
|
||||||
|
politica: 'Conserve este ticket. No tiene validez fiscal.',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
clinica: {
|
||||||
|
nombre: 'SKEEN Derma Experts',
|
||||||
|
direccion: 'Playas de Rosarito, Baja California',
|
||||||
|
telefono: '+52 (661) 100-2172',
|
||||||
|
email: 'hola@skeen.mx',
|
||||||
|
sitio: 'https://skeen.mx',
|
||||||
|
rfc: '',
|
||||||
|
sucursal: '',
|
||||||
|
tiene_logo: false,
|
||||||
|
},
|
||||||
|
logo: '',
|
||||||
|
};
|
||||||
|
|
||||||
|
const clonar = <T,>(v: T): T => JSON.parse(JSON.stringify(v)) as T;
|
||||||
|
|
||||||
|
const leerLocal = (): TicketConfig | null => {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(CACHE_KEY);
|
||||||
|
if (!raw) return null;
|
||||||
|
const parsed = JSON.parse(raw) as TicketConfig;
|
||||||
|
if (!parsed?.layout?.secciones || !parsed?.clinica) return null;
|
||||||
|
return parsed;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let actual: TicketConfig = leerLocal() || clonar(FALLBACK_CONFIG);
|
||||||
|
const oyentes = new Set<(c: TicketConfig) => void>();
|
||||||
|
let peticion: Promise<TicketConfig> | null = null;
|
||||||
|
let yaCargo = false;
|
||||||
|
|
||||||
|
const publicar = (c: TicketConfig) => {
|
||||||
|
actual = c;
|
||||||
|
try {
|
||||||
|
localStorage.setItem(CACHE_KEY, JSON.stringify(c));
|
||||||
|
} catch {
|
||||||
|
// localStorage lleno o bloqueado: seguimos en memoria
|
||||||
|
}
|
||||||
|
oyentes.forEach((f) => f(c));
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Fuerza la recarga (tras guardar en Configuración). */
|
||||||
|
export const recargarTicketConfig = async (): Promise<TicketConfig> => {
|
||||||
|
peticion = null;
|
||||||
|
yaCargo = false;
|
||||||
|
return cargarTicketConfig();
|
||||||
|
};
|
||||||
|
|
||||||
|
export const cargarTicketConfig = (): Promise<TicketConfig> => {
|
||||||
|
if (peticion) return peticion;
|
||||||
|
if (yaCargo) return Promise.resolve(actual);
|
||||||
|
peticion = odooApi.getTicketConfig()
|
||||||
|
.then((res) => {
|
||||||
|
if (res.status === 'success' && res.layout && res.clinica) {
|
||||||
|
publicar({
|
||||||
|
layout: res.layout as TicketConfig['layout'],
|
||||||
|
clinica: res.clinica as TicketConfig['clinica'],
|
||||||
|
logo: res.logo || '',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return actual;
|
||||||
|
})
|
||||||
|
.catch(() => actual)
|
||||||
|
.finally(() => {
|
||||||
|
peticion = null;
|
||||||
|
yaCargo = true;
|
||||||
|
});
|
||||||
|
return peticion;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Config actual dentro de React; se refresca sola cuando cambia. */
|
||||||
|
export const useTicketConfig = (): TicketConfig => {
|
||||||
|
const [cfg, setCfg] = useState<TicketConfig>(actual);
|
||||||
|
useEffect(() => {
|
||||||
|
const f = (c: TicketConfig) => setCfg(clonar(c));
|
||||||
|
oyentes.add(f);
|
||||||
|
cargarTicketConfig().then((c) => setCfg(clonar(c)));
|
||||||
|
return () => {
|
||||||
|
oyentes.delete(f);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
return cfg;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Versión no-React para componentes que solo leen (impresión puntual). */
|
||||||
|
export const ticketConfigActual = (): TicketConfig => actual;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Datos de la clínica para los documentos impresos (ticket, recibo, estado de
|
||||||
|
* cuenta, receta, corte, conteo). Sale de la configuración del servidor; si
|
||||||
|
* nunca se configuró, cae al skeen_clinic_settings de este navegador y de ahí
|
||||||
|
* a los valores históricos de SKEEN.
|
||||||
|
*/
|
||||||
|
export const useClinicaPrint = () => {
|
||||||
|
const c = useTicketConfig().clinica;
|
||||||
|
const local = leerLocal()?.clinica;
|
||||||
|
const mix = (campo: keyof TicketClinica, porDefecto = '') =>
|
||||||
|
String(c[campo] || local?.[campo] || porDefecto || '');
|
||||||
|
return {
|
||||||
|
nombre: mix('nombre', 'SKEEN Derma Experts'),
|
||||||
|
direccion: mix('direccion', 'Playas de Rosarito, Baja California'),
|
||||||
|
telefono: mix('telefono'),
|
||||||
|
email: mix('email'),
|
||||||
|
sitio: mix('sitio'),
|
||||||
|
rfc: mix('rfc'),
|
||||||
|
sucursal: mix('sucursal'),
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -8,6 +8,7 @@ import RolesPanel from '../components/RolesPanel';
|
|||||||
import RecetasPanel from '../components/RecetasPanel';
|
import RecetasPanel from '../components/RecetasPanel';
|
||||||
import CatalogoPanel from '../components/CatalogoPanel';
|
import CatalogoPanel from '../components/CatalogoPanel';
|
||||||
import AuditoriaPanel from '../components/AuditoriaPanel';
|
import AuditoriaPanel from '../components/AuditoriaPanel';
|
||||||
|
import TicketDesigner from '../components/TicketDesigner';
|
||||||
import ImpactoModal from '../components/ImpactoModal';
|
import ImpactoModal from '../components/ImpactoModal';
|
||||||
import { Card, Button, Input, PageHeader, toast } from '../components/ui';
|
import { Card, Button, Input, PageHeader, toast } from '../components/ui';
|
||||||
import { odooApi, type Doctor } from '../services/odoo';
|
import { odooApi, type Doctor } from '../services/odoo';
|
||||||
@@ -19,6 +20,7 @@ const TABS = [
|
|||||||
{ key: 'roles', label: 'Roles' },
|
{ key: 'roles', label: 'Roles' },
|
||||||
{ key: 'recetas', label: 'Recetas' },
|
{ key: 'recetas', label: 'Recetas' },
|
||||||
{ key: 'catalogos', label: 'Catálogos' },
|
{ key: 'catalogos', label: 'Catálogos' },
|
||||||
|
{ key: 'tickets', label: 'Tickets' },
|
||||||
{ key: 'auditoria', label: 'Auditoría' },
|
{ key: 'auditoria', label: 'Auditoría' },
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
@@ -51,7 +53,9 @@ const Configuracion: FC = () => {
|
|||||||
// comisiones) queda para administración del sistema.
|
// comisiones) queda para administración del sistema.
|
||||||
const esAdmin = user?.role === 'admin';
|
const esAdmin = user?.role === 'admin';
|
||||||
const tabsVisibles = useMemo(
|
const tabsVisibles = useMemo(
|
||||||
() => (esAdmin ? TABS : TABS.filter((t) => t.key === 'usuarios' || t.key === 'roles')),
|
() => (esAdmin
|
||||||
|
? TABS
|
||||||
|
: TABS.filter((t) => t.key === 'usuarios' || t.key === 'roles' || t.key === 'tickets')),
|
||||||
[esAdmin],
|
[esAdmin],
|
||||||
);
|
);
|
||||||
const [searchParams, setSearchParams] = useSearchParams();
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
@@ -228,6 +232,8 @@ const Configuracion: FC = () => {
|
|||||||
<RecetasPanel />
|
<RecetasPanel />
|
||||||
</Card.Body>
|
</Card.Body>
|
||||||
</Card>
|
</Card>
|
||||||
|
) : tab === 'tickets' ? (
|
||||||
|
<TicketDesigner />
|
||||||
) : tab === 'auditoria' ? (
|
) : tab === 'auditoria' ? (
|
||||||
<AuditoriaPanel />
|
<AuditoriaPanel />
|
||||||
) : tab === 'catalogos' ? (
|
) : tab === 'catalogos' ? (
|
||||||
|
|||||||
@@ -1408,9 +1408,14 @@ const Pos: FC = () => {
|
|||||||
puntos_ganados: resultado.puntos_ganados,
|
puntos_ganados: resultado.puntos_ganados,
|
||||||
wallet_points: resultado.wallet_points,
|
wallet_points: resultado.wallet_points,
|
||||||
cambio: resultado.cambio,
|
cambio: resultado.cambio,
|
||||||
|
recibido: resultado.recibido,
|
||||||
cambio_usd: resultado.cambio_usd,
|
cambio_usd: resultado.cambio_usd,
|
||||||
recibido_usd: resultado.recibido_usd,
|
recibido_usd: resultado.recibido_usd,
|
||||||
rate_used: resultado.rate_used,
|
rate_used: resultado.rate_used,
|
||||||
|
cajero: resultado.sale.user_login,
|
||||||
|
doctor: resultado.sale.doctor,
|
||||||
|
telefono_paciente: resultado.sale.patient_phone,
|
||||||
|
notas: resultado.sale.notes,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</Layout>
|
</Layout>
|
||||||
|
|||||||
@@ -513,6 +513,11 @@ const Tickets: FC = () => {
|
|||||||
total: reimprimir.total,
|
total: reimprimir.total,
|
||||||
amount_paid: reimprimir.amount_paid,
|
amount_paid: reimprimir.amount_paid,
|
||||||
amount_due: reimprimir.amount_due,
|
amount_due: reimprimir.amount_due,
|
||||||
|
metodo: METODO_LABELS[reimprimir.payment_method || ''] || null,
|
||||||
|
cajero: reimprimir.user_login,
|
||||||
|
doctor: reimprimir.doctor,
|
||||||
|
telefono_paciente: reimprimir.patient_phone,
|
||||||
|
notas: reimprimir.notes,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</Layout>
|
</Layout>
|
||||||
|
|||||||
@@ -906,6 +906,10 @@ const Ventas: FC = () => {
|
|||||||
total: printSale.total,
|
total: printSale.total,
|
||||||
amount_paid: printSale.amount_paid,
|
amount_paid: printSale.amount_paid,
|
||||||
amount_due: printSale.amount_due,
|
amount_due: printSale.amount_due,
|
||||||
|
cajero: printSale.user_login,
|
||||||
|
doctor: printSale.doctor,
|
||||||
|
telefono_paciente: printSale.patient_phone,
|
||||||
|
notas: printSale.notes,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</Layout>
|
</Layout>
|
||||||
|
|||||||
@@ -521,6 +521,9 @@ export interface Sale {
|
|||||||
name: string;
|
name: string;
|
||||||
patient_id: number;
|
patient_id: number;
|
||||||
patient: string;
|
patient: string;
|
||||||
|
/** Teléfono y médico del ticket: los muestra el diseño impreso si están activos */
|
||||||
|
patient_phone?: string;
|
||||||
|
doctor?: string;
|
||||||
date: string;
|
date: string;
|
||||||
subtotal: number;
|
subtotal: number;
|
||||||
discount: number;
|
discount: number;
|
||||||
@@ -1227,6 +1230,8 @@ export interface PosCheckoutResult {
|
|||||||
sale: Sale;
|
sale: Sale;
|
||||||
payment: Payment | null;
|
payment: Payment | null;
|
||||||
cambio: number;
|
cambio: number;
|
||||||
|
/** Efectivo recibido en caja (MXN), para imprimir "Recibido" en el ticket */
|
||||||
|
recibido?: number;
|
||||||
cambio_usd?: number;
|
cambio_usd?: number;
|
||||||
rate_used?: number | null;
|
rate_used?: number | null;
|
||||||
recibido_usd?: number;
|
recibido_usd?: number;
|
||||||
@@ -1814,6 +1819,34 @@ export const odooApi = {
|
|||||||
return data;
|
return data;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// ---- Diseño del ticket impreso (Configuración › Tickets) ----
|
||||||
|
async getTicketConfig(): Promise<{
|
||||||
|
status: string; layout?: unknown; clinica?: unknown; logo?: string; message?: string;
|
||||||
|
}> {
|
||||||
|
const { data } = await axios.get(`${ODOO_BASE}/skeen/frontend/v1/ticket_config`);
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
|
||||||
|
async putTicketConfig(layout: unknown, motivo?: string): Promise<{
|
||||||
|
status: string; layout?: unknown; message?: string;
|
||||||
|
}> {
|
||||||
|
const { data } = await axios.put(`${ODOO_BASE}/skeen/frontend/v1/ticket_config`,
|
||||||
|
{ layout, motivo });
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
|
||||||
|
async putTicketClinica(payload: {
|
||||||
|
clinica?: Record<string, string>; logo?: string; motivo?: string;
|
||||||
|
}): Promise<{ status: string; layout?: unknown; clinica?: unknown; logo?: string; message?: string }> {
|
||||||
|
const { data } = await axios.put(`${ODOO_BASE}/skeen/frontend/v1/ticket_config/clinica`, payload);
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
|
||||||
|
async resetTicketConfig(): Promise<{ status: string; layout?: unknown; message?: string }> {
|
||||||
|
const { data } = await axios.post(`${ODOO_BASE}/skeen/frontend/v1/ticket_config/reset`, {});
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
|
||||||
async getDoctorHistory(id: number): Promise<DoctorHistory> {
|
async getDoctorHistory(id: number): Promise<DoctorHistory> {
|
||||||
const { data } = await axios.get(`${ODOO_BASE}/skeen/frontend/v1/doctors/${id}/history`);
|
const { data } = await axios.get(`${ODOO_BASE}/skeen/frontend/v1/doctors/${id}/history`);
|
||||||
return data;
|
return data;
|
||||||
|
|||||||
@@ -4587,6 +4587,9 @@ class SkeenFrontendController(http.Controller):
|
|||||||
'name': v.name,
|
'name': v.name,
|
||||||
'patient_id': v.partner_id.id,
|
'patient_id': v.partner_id.id,
|
||||||
'patient': v.partner_id.name,
|
'patient': v.partner_id.name,
|
||||||
|
'patient_phone': v.partner_phone or '',
|
||||||
|
'doctor': next((l.prescribed_by_id.name for l in v.line_ids
|
||||||
|
if l.prescribed_by_id), ''),
|
||||||
'date': v.date.strftime('%Y-%m-%d') if v.date else None,
|
'date': v.date.strftime('%Y-%m-%d') if v.date else None,
|
||||||
'subtotal': v.subtotal,
|
'subtotal': v.subtotal,
|
||||||
'discount': v.discount,
|
'discount': v.discount,
|
||||||
@@ -4791,8 +4794,10 @@ class SkeenFrontendController(http.Controller):
|
|||||||
cambio = 0.0
|
cambio = 0.0
|
||||||
cambio_usd = 0.0
|
cambio_usd = 0.0
|
||||||
recibido_usd = 0.0
|
recibido_usd = 0.0
|
||||||
|
recibido = 0.0
|
||||||
if restante > 0:
|
if restante > 0:
|
||||||
received = float(data.get('amount_received', restante) or restante)
|
received = float(data.get('amount_received', restante) or restante)
|
||||||
|
recibido = received
|
||||||
reference = ''
|
reference = ''
|
||||||
if currency == 'USD':
|
if currency == 'USD':
|
||||||
# Recibido en dólares: convertir a MXN con el TC vigente (ya validado arriba)
|
# Recibido en dólares: convertir a MXN con el TC vigente (ya validado arriba)
|
||||||
@@ -4830,6 +4835,7 @@ class SkeenFrontendController(http.Controller):
|
|||||||
'sale': self._sale_to_dict(venta),
|
'sale': self._sale_to_dict(venta),
|
||||||
'payment': self._payment_to_dict(pago) if pago else None,
|
'payment': self._payment_to_dict(pago) if pago else None,
|
||||||
'cambio': cambio,
|
'cambio': cambio,
|
||||||
|
'recibido': recibido,
|
||||||
'cambio_usd': cambio_usd,
|
'cambio_usd': cambio_usd,
|
||||||
'rate_used': rate_used,
|
'rate_used': rate_used,
|
||||||
'recibido_usd': recibido_usd,
|
'recibido_usd': recibido_usd,
|
||||||
@@ -6156,6 +6162,105 @@ class SkeenFrontendController(http.Controller):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
return json_response({'status': 'error', 'message': str(e)}, 500)
|
return json_response({'status': 'error', 'message': str(e)}, 500)
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# Diseño de tickets (Configuración › Tickets)
|
||||||
|
# ============================================================
|
||||||
|
@http.route('/skeen/frontend/v1/ticket_config', type='http',
|
||||||
|
auth='none', methods=['GET', 'OPTIONS'], csrf=False)
|
||||||
|
@require_role('lectura')
|
||||||
|
def get_ticket_config(self, **kw):
|
||||||
|
"""Layout + perfil de la clínica + logo, para el impresor del POS.
|
||||||
|
|
||||||
|
Lectura abierta a cualquier rol autenticado: quien imprime (recepción,
|
||||||
|
farmacia, gerencia) necesita el mismo diseño sin depender de admin.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
return json_response({'status': 'success',
|
||||||
|
**request.env['skeen.ticket.config'].sudo().config_completa()})
|
||||||
|
except Exception as e:
|
||||||
|
return json_response({'status': 'error', 'message': str(e)}, 500)
|
||||||
|
|
||||||
|
@http.route('/skeen/frontend/v1/ticket_config', type='http',
|
||||||
|
auth='none', methods=['PUT', 'OPTIONS'], csrf=False)
|
||||||
|
@require_role('admin')
|
||||||
|
def put_ticket_config(self, **kw):
|
||||||
|
"""Guarda el diseño del ticket. Requiere motivo para la bitácora."""
|
||||||
|
try:
|
||||||
|
if request.frontend_user.role not in ('admin', 'gerencia'):
|
||||||
|
return json_response({'status': 'error', 'message': 'Sin permiso'}, 403)
|
||||||
|
data = _parse_json_body()
|
||||||
|
layout = data.get('layout')
|
||||||
|
if layout is None:
|
||||||
|
return json_response({'status': 'error',
|
||||||
|
'message': 'Falta el diseño del ticket'}, 400)
|
||||||
|
Config = request.env['skeen.ticket.config'].sudo()
|
||||||
|
antes = Config.get_layout()
|
||||||
|
nuevo = Config.set_layout(layout)
|
||||||
|
self._auditar('clinica', 'ticket:layout', 'layout',
|
||||||
|
old_value=json.dumps(antes, ensure_ascii=False)[:500],
|
||||||
|
new_value=json.dumps(nuevo, ensure_ascii=False)[:500],
|
||||||
|
entity_label='Diseño del ticket impreso',
|
||||||
|
notes=(data.get('motivo') or 'actualizado').strip())
|
||||||
|
return json_response({'status': 'success', 'layout': nuevo,
|
||||||
|
'message': 'Diseño del ticket actualizado'})
|
||||||
|
except ValidationError as e:
|
||||||
|
return json_response({'status': 'error',
|
||||||
|
'message': e.args[0] if e.args else str(e)}, 400)
|
||||||
|
except Exception as e:
|
||||||
|
request.env.cr.rollback()
|
||||||
|
return json_response({'status': 'error', 'message': str(e)}, 500)
|
||||||
|
|
||||||
|
@http.route('/skeen/frontend/v1/ticket_config/clinica', type='http',
|
||||||
|
auth='none', methods=['PUT', 'OPTIONS'], csrf=False)
|
||||||
|
@require_role('admin')
|
||||||
|
def put_ticket_clinica(self, **kw):
|
||||||
|
"""Perfil de la clínica que sale en el encabezado del ticket (y logo)."""
|
||||||
|
try:
|
||||||
|
if request.frontend_user.role not in ('admin', 'gerencia'):
|
||||||
|
return json_response({'status': 'error', 'message': 'Sin permiso'}, 403)
|
||||||
|
data = _parse_json_body()
|
||||||
|
Config = request.env['skeen.ticket.config'].sudo()
|
||||||
|
clinica = None
|
||||||
|
if data.get('clinica') is not None:
|
||||||
|
antes = Config.get_clinica()
|
||||||
|
clinica = Config.set_clinica(data['clinica'])
|
||||||
|
self._auditar('clinica', 'ticket:clinica', 'perfil',
|
||||||
|
old_value=json.dumps(antes, ensure_ascii=False)[:500],
|
||||||
|
new_value=json.dumps(clinica, ensure_ascii=False)[:500],
|
||||||
|
entity_label='Datos de la clínica en el ticket',
|
||||||
|
notes=(data.get('motivo') or 'actualizado').strip())
|
||||||
|
if 'logo' in data:
|
||||||
|
Config.set_logo(data.get('logo') or '')
|
||||||
|
self._auditar('clinica', 'ticket:logo', 'logo',
|
||||||
|
new_value='presente' if (data.get('logo') or '') else 'borrado',
|
||||||
|
entity_label='Logo del ticket')
|
||||||
|
return json_response({'status': 'success',
|
||||||
|
**Config.config_completa()})
|
||||||
|
except ValidationError as e:
|
||||||
|
return json_response({'status': 'error',
|
||||||
|
'message': e.args[0] if e.args else str(e)}, 400)
|
||||||
|
except Exception as e:
|
||||||
|
request.env.cr.rollback()
|
||||||
|
return json_response({'status': 'error', 'message': str(e)}, 500)
|
||||||
|
|
||||||
|
@http.route('/skeen/frontend/v1/ticket_config/reset', type='http',
|
||||||
|
auth='none', methods=['POST', 'OPTIONS'], csrf=False)
|
||||||
|
@require_role('admin')
|
||||||
|
def reset_ticket_config(self, **kw):
|
||||||
|
"""Devuelve el diseño al formato original (el del ticket de siempre)."""
|
||||||
|
try:
|
||||||
|
if request.frontend_user.role != 'admin':
|
||||||
|
return json_response({'status': 'error', 'message': 'Sin permiso'}, 403)
|
||||||
|
Config = request.env['skeen.ticket.config'].sudo()
|
||||||
|
layout = Config.reset_layout()
|
||||||
|
self._auditar('clinica', 'ticket:layout', 'layout',
|
||||||
|
new_value='por omisión',
|
||||||
|
entity_label='Diseño del ticket impreso',
|
||||||
|
notes='restaurado al formato original')
|
||||||
|
return json_response({'status': 'success', 'layout': layout})
|
||||||
|
except Exception as e:
|
||||||
|
return json_response({'status': 'error', 'message': str(e)}, 500)
|
||||||
|
|
||||||
# ============================================================
|
# ============================================================
|
||||||
# Reporte de cumpleañeros
|
# Reporte de cumpleañeros
|
||||||
# ============================================================
|
# ============================================================
|
||||||
|
|||||||
@@ -3,3 +3,4 @@ from . import wacrm_sync
|
|||||||
from . import frontend_user
|
from . import frontend_user
|
||||||
from . import config_audit
|
from . import config_audit
|
||||||
from . import wacrm_notes
|
from . import wacrm_notes
|
||||||
|
from . import ticket_config
|
||||||
|
|||||||
239
odoo-addons/skeen_whatsapp/models/ticket_config.py
Normal file
239
odoo-addons/skeen_whatsapp/models/ticket_config.py
Normal file
@@ -0,0 +1,239 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
|
||||||
|
from odoo import models, fields, api, _
|
||||||
|
from odoo.exceptions import ValidationError
|
||||||
|
|
||||||
|
import json
|
||||||
|
|
||||||
|
# Parámetro del sistema donde vive el diseño del ticket (JSON).
|
||||||
|
TICKET_LAYOUT_KEY = 'skeen.ticket_layout'
|
||||||
|
|
||||||
|
# Diseño por omisión: es el ticket que siempre se imprimió (80 mm, monoespaciado,
|
||||||
|
# con todos los rubros). El editor de Configuración parte de aquí.
|
||||||
|
DEFAULT_TICKET_LAYOUT = {
|
||||||
|
'ancho': '80mm', # 58mm | 80mm
|
||||||
|
'fuente': 'monoespaciada', # monoespaciada | sans
|
||||||
|
'letra': 'normal', # compacta | normal | grande
|
||||||
|
'separador': 'guiones', # guiones | solida | ninguna
|
||||||
|
'mayusculas': True,
|
||||||
|
'bloques': ['encabezado', 'cliente', 'conceptos', 'totales', 'pago', 'monedero', 'pie'],
|
||||||
|
'secciones': {
|
||||||
|
'nombre_clinica': True,
|
||||||
|
'direccion': True,
|
||||||
|
'telefono_clinica': True,
|
||||||
|
'sucursal': True,
|
||||||
|
'rfc': False,
|
||||||
|
'mensaje_encabezado': True,
|
||||||
|
'folio': True,
|
||||||
|
'fecha': True,
|
||||||
|
'cajero': True,
|
||||||
|
'paciente': True,
|
||||||
|
'telefono_paciente': False,
|
||||||
|
'doctor': True,
|
||||||
|
'conceptos': True,
|
||||||
|
'detalle_conceptos': True, # muestra "1 × $250.00" además del subtotal
|
||||||
|
'subtotal': True,
|
||||||
|
'descuento': True,
|
||||||
|
'puntos_canjeados': True,
|
||||||
|
'total': True,
|
||||||
|
'total_usd': True,
|
||||||
|
'metodo_pago': True,
|
||||||
|
'recibido': True,
|
||||||
|
'cambio': True,
|
||||||
|
'pagado': True,
|
||||||
|
'saldo_pendiente': True,
|
||||||
|
'puntos_ganados': True,
|
||||||
|
'monedero': True,
|
||||||
|
'notas': True,
|
||||||
|
'politica': False,
|
||||||
|
'mensaje_pie': True,
|
||||||
|
},
|
||||||
|
'textos': {
|
||||||
|
'encabezado': '',
|
||||||
|
'pie': '¡Gracias por su visita!',
|
||||||
|
'politica': 'Conserve este ticket. No tiene validez fiscal.',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
# Perfil de la clínica que sale impreso en el ticket. Antes vivía únicamente en
|
||||||
|
# localStorage del navegador de cada caja (clave skeen_clinic_settings), así que
|
||||||
|
# cada equipo imprimía una dirección o un teléfono distinto.
|
||||||
|
TICKET_CLINICA_KEY = 'skeen.ticket_clinica'
|
||||||
|
|
||||||
|
DEFAULT_CLINICA = {
|
||||||
|
'nombre': 'SKEEN Derma Experts',
|
||||||
|
'direccion': 'Playas de Rosarito, Baja California',
|
||||||
|
'telefono': '+52 (661) 100-2172',
|
||||||
|
'email': 'hola@skeen.mx',
|
||||||
|
'sitio': 'https://skeen.mx',
|
||||||
|
'rfc': '',
|
||||||
|
'sucursal': '',
|
||||||
|
'tiene_logo': False,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Logo en base64 (data URL). Se guarda aparte para no arrastrar cientos de KB
|
||||||
|
# en cada lectura del perfil.
|
||||||
|
TICKET_LOGO_KEY = 'skeen.ticket_logo'
|
||||||
|
# ~250 KB de imagen: de ahí arriba un térmica no imprime bien y solo infla la BD.
|
||||||
|
LOGO_MAX_BYTES = 250 * 1024
|
||||||
|
|
||||||
|
# Valores admitidos: se validan al guardar para no romper el render.
|
||||||
|
LAYOUT_ANCHOS = ('58mm', '80mm')
|
||||||
|
LAYOUT_FUENTES = ('monoespaciada', 'sans')
|
||||||
|
LAYOUT_LETRAS = ('compacta', 'normal', 'grande')
|
||||||
|
LAYOUT_SEPARADORES = ('guiones', 'solida', 'ninguna')
|
||||||
|
LAYOUT_BLOQUES = ('encabezado', 'cliente', 'conceptos', 'totales', 'pago', 'monedero', 'pie')
|
||||||
|
|
||||||
|
|
||||||
|
class SkeenTicketConfig(models.AbstractModel):
|
||||||
|
"""Acceso al diseño del ticket guardado en ir.config_parameter.
|
||||||
|
|
||||||
|
Es AbstractModel porque no tiene tabla propia: solo centraliza leer/validar/
|
||||||
|
escribir el JSON para que el controlador del frontend y cualquier otro
|
||||||
|
módulo usen la misma lógica.
|
||||||
|
"""
|
||||||
|
_name = 'skeen.ticket.config'
|
||||||
|
_description = 'Diseño de ticket de SKEEN'
|
||||||
|
|
||||||
|
@api.model
|
||||||
|
def get_layout(self):
|
||||||
|
raw = self.env['ir.config_parameter'].sudo().get_param(TICKET_LAYOUT_KEY)
|
||||||
|
if not raw:
|
||||||
|
return dict(DEFAULT_TICKET_LAYOUT)
|
||||||
|
try:
|
||||||
|
guardado = json.loads(raw)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
return dict(DEFAULT_TICKET_LAYOUT)
|
||||||
|
return self._mezclar(guardado)
|
||||||
|
|
||||||
|
@api.model
|
||||||
|
def _mezclar(self, guardado):
|
||||||
|
"""Une el layout guardado sobre el default (para agregar secciones nuevas).
|
||||||
|
|
||||||
|
Si mañana se agrega una sección al catálogo, un layout guardado antes
|
||||||
|
de ese cambio no la pierde: hereda el valor por omisión.
|
||||||
|
"""
|
||||||
|
out = json.loads(json.dumps(DEFAULT_TICKET_LAYOUT))
|
||||||
|
if not isinstance(guardado, dict):
|
||||||
|
return out
|
||||||
|
for k, v in guardado.items():
|
||||||
|
if k == 'secciones' and isinstance(v, dict):
|
||||||
|
out['secciones'].update({kk: bool(vv) for kk, vv in v.items()
|
||||||
|
if kk in out['secciones']})
|
||||||
|
elif k == 'textos' and isinstance(v, dict):
|
||||||
|
out['textos'].update({kk: str(vv) for kk, vv in v.items()
|
||||||
|
if kk in out['textos']})
|
||||||
|
elif k == 'bloques' and isinstance(v, list):
|
||||||
|
bloques = [b for b in v if b in LAYOUT_BLOQUES]
|
||||||
|
faltan = [b for b in LAYOUT_BLOQUES if b not in bloques]
|
||||||
|
out['bloques'] = bloques + faltan
|
||||||
|
elif k in out and k != 'secciones' and k != 'textos' and k != 'bloques':
|
||||||
|
out[k] = v
|
||||||
|
return out
|
||||||
|
|
||||||
|
@api.model
|
||||||
|
def _validar(self, layout):
|
||||||
|
if not isinstance(layout, dict):
|
||||||
|
raise ValidationError(_('El diseño del ticket debe ser un objeto.'))
|
||||||
|
if layout.get('ancho') not in LAYOUT_ANCHOS:
|
||||||
|
raise ValidationError(_('Ancho de ticket no válido.'))
|
||||||
|
if layout.get('fuente') not in LAYOUT_FUENTES:
|
||||||
|
raise ValidationError(_('Fuente del ticket no válida.'))
|
||||||
|
if layout.get('letra') not in LAYOUT_LETRAS:
|
||||||
|
raise ValidationError(_('Tamaño de letra no válido.'))
|
||||||
|
if layout.get('separador') not in LAYOUT_SEPARADORES:
|
||||||
|
raise ValidationError(_('Tipo de separador no válido.'))
|
||||||
|
if not isinstance(layout.get('secciones'), dict):
|
||||||
|
raise ValidationError(_('Faltan las secciones del ticket.'))
|
||||||
|
for clave, visible in layout['secciones'].items():
|
||||||
|
if clave not in DEFAULT_TICKET_LAYOUT['secciones']:
|
||||||
|
raise ValidationError(_('Sección desconocida: %s', clave))
|
||||||
|
if not isinstance(visible, bool):
|
||||||
|
raise ValidationError(_('La sección %s debe ser visible u oculta.', clave))
|
||||||
|
textos = layout.get('textos') or {}
|
||||||
|
for clave, valor in textos.items():
|
||||||
|
if clave not in DEFAULT_TICKET_LAYOUT['textos']:
|
||||||
|
raise ValidationError(_('Texto desconocido: %s', clave))
|
||||||
|
if len(str(valor)) > 240:
|
||||||
|
raise ValidationError(_('El texto "%s" es demasiado largo (máx. 240 caracteres).', clave))
|
||||||
|
return True
|
||||||
|
|
||||||
|
@api.model
|
||||||
|
def set_layout(self, layout):
|
||||||
|
self._validar(layout)
|
||||||
|
compacto = self._mezclar(layout)
|
||||||
|
self.env['ir.config_parameter'].sudo().set_param(
|
||||||
|
TICKET_LAYOUT_KEY, json.dumps(compacto, ensure_ascii=False))
|
||||||
|
return compacto
|
||||||
|
|
||||||
|
@api.model
|
||||||
|
def reset_layout(self):
|
||||||
|
self.env['ir.config_parameter'].sudo().set_param(TICKET_LAYOUT_KEY, '')
|
||||||
|
return dict(DEFAULT_TICKET_LAYOUT)
|
||||||
|
|
||||||
|
# ---------------- perfil de la clínica ----------------
|
||||||
|
|
||||||
|
@api.model
|
||||||
|
def get_clinica(self):
|
||||||
|
raw = self.env['ir.config_parameter'].sudo().get_param(TICKET_CLINICA_KEY)
|
||||||
|
out = dict(DEFAULT_CLINICA)
|
||||||
|
if raw:
|
||||||
|
try:
|
||||||
|
guardado = json.loads(raw)
|
||||||
|
if isinstance(guardado, dict):
|
||||||
|
for k in DEFAULT_CLINICA:
|
||||||
|
if k in guardado and k != 'tiene_logo':
|
||||||
|
out[k] = str(guardado[k] or '')
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
pass
|
||||||
|
out['tiene_logo'] = bool(
|
||||||
|
self.env['ir.config_parameter'].sudo().get_param(TICKET_LOGO_KEY))
|
||||||
|
return out
|
||||||
|
|
||||||
|
@api.model
|
||||||
|
def set_clinica(self, vals):
|
||||||
|
if not isinstance(vals, dict):
|
||||||
|
raise ValidationError(_('El perfil de la clínica debe ser un objeto.'))
|
||||||
|
actual = self.get_clinica()
|
||||||
|
for k in DEFAULT_CLINICA:
|
||||||
|
if k == 'tiene_logo' or k not in vals:
|
||||||
|
continue
|
||||||
|
v = vals[k]
|
||||||
|
v = '' if v is None else str(v).strip()
|
||||||
|
if len(v) > 160:
|
||||||
|
raise ValidationError(_('El campo %s es demasiado largo.', k))
|
||||||
|
actual[k] = v
|
||||||
|
actual.pop('tiene_logo', None)
|
||||||
|
self.env['ir.config_parameter'].sudo().set_param(
|
||||||
|
TICKET_CLINICA_KEY, json.dumps(actual, ensure_ascii=False))
|
||||||
|
return self.get_clinica()
|
||||||
|
|
||||||
|
# ---------------- logo ----------------
|
||||||
|
|
||||||
|
@api.model
|
||||||
|
def get_logo(self):
|
||||||
|
return self.env['ir.config_parameter'].sudo().get_param(TICKET_LOGO_KEY) or ''
|
||||||
|
|
||||||
|
@api.model
|
||||||
|
def set_logo(self, data_url):
|
||||||
|
"""Guarda el logo como data URL (png/jpg/svg en base64). Vacío = lo borra."""
|
||||||
|
Params = self.env['ir.config_parameter'].sudo()
|
||||||
|
data_url = (data_url or '').strip()
|
||||||
|
if not data_url:
|
||||||
|
Params.set_param(TICKET_LOGO_KEY, '')
|
||||||
|
return False
|
||||||
|
if not data_url.startswith('data:image/'):
|
||||||
|
raise ValidationError(_('El logo debe ser una imagen (PNG, JPG o SVG).'))
|
||||||
|
if ';base64,' not in data_url:
|
||||||
|
raise ValidationError(_('El logo debe venir codificado en base64.'))
|
||||||
|
if len(data_url) > LOGO_MAX_BYTES * 1.4: # base64 infla ~33%
|
||||||
|
raise ValidationError(_(
|
||||||
|
'El logo pesa demasiado (máximo 250 KB). Usa una imagen pequeña.'))
|
||||||
|
Params.set_param(TICKET_LOGO_KEY, data_url)
|
||||||
|
return True
|
||||||
|
|
||||||
|
@api.model
|
||||||
|
def config_completa(self):
|
||||||
|
"""Todo lo que el impresor necesita en una sola llamada."""
|
||||||
|
return {'layout': self.get_layout(), 'clinica': self.get_clinica(),
|
||||||
|
'logo': self.get_logo()}
|
||||||
Reference in New Issue
Block a user