- 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.
517 lines
20 KiB
TypeScript
517 lines
20 KiB
TypeScript
import type { FC } from 'react';
|
|
import { useEffect, useMemo, useState } from 'react';
|
|
import { useSearchParams } from 'react-router-dom';
|
|
import { Phone, MapPin, Mail, Globe, Save, Building2, ArrowRightLeft, Percent, Check, Calculator } from 'lucide-react';
|
|
import Layout from '../components/Layout';
|
|
import UsuariosPanel from '../components/UsuariosPanel';
|
|
import RolesPanel from '../components/RolesPanel';
|
|
import RecetasPanel from '../components/RecetasPanel';
|
|
import CatalogoPanel from '../components/CatalogoPanel';
|
|
import AuditoriaPanel from '../components/AuditoriaPanel';
|
|
import TicketDesigner from '../components/TicketDesigner';
|
|
import ImpactoModal from '../components/ImpactoModal';
|
|
import { Card, Button, Input, PageHeader, toast } from '../components/ui';
|
|
import { odooApi, type Doctor } from '../services/odoo';
|
|
import { useAuth } from '../lib/auth';
|
|
|
|
const TABS = [
|
|
{ key: 'clinica', label: 'Clínica' },
|
|
{ key: 'usuarios', label: 'Usuarios' },
|
|
{ key: 'roles', label: 'Roles' },
|
|
{ key: 'recetas', label: 'Recetas' },
|
|
{ key: 'catalogos', label: 'Catálogos' },
|
|
{ key: 'tickets', label: 'Tickets' },
|
|
{ key: 'auditoria', label: 'Auditoría' },
|
|
] as const;
|
|
|
|
const SUBCATALOGOS = [
|
|
{ key: 'diagnosticos', label: 'Diagnósticos' },
|
|
{ key: 'procedimientos', label: 'Procedimientos' },
|
|
] as const;
|
|
|
|
interface ClinicSettings {
|
|
name: string;
|
|
phone: string;
|
|
email: string;
|
|
address: string;
|
|
website: string;
|
|
}
|
|
|
|
const defaultSettings: ClinicSettings = {
|
|
name: 'SKEEN Derma Experts',
|
|
phone: '+52 (661) 100-2172',
|
|
email: 'hola@skeen.mx',
|
|
address: 'Playas de Rosarito, Baja California',
|
|
website: 'https://skeen.mx',
|
|
};
|
|
|
|
const STORAGE_KEY = 'skeen_clinic_settings';
|
|
|
|
const Configuracion: FC = () => {
|
|
const { user } = useAuth();
|
|
// Gerencia gestiona usuarios y roles; la sección "Clínica" (tipo de cambio y
|
|
// comisiones) queda para administración del sistema.
|
|
const esAdmin = user?.role === 'admin';
|
|
const tabsVisibles = useMemo(
|
|
() => (esAdmin
|
|
? TABS
|
|
: TABS.filter((t) => t.key === 'usuarios' || t.key === 'roles' || t.key === 'tickets')),
|
|
[esAdmin],
|
|
);
|
|
const [searchParams, setSearchParams] = useSearchParams();
|
|
const tabPedida = searchParams.get('tab') || 'clinica';
|
|
const tab = tabsVisibles.some((t) => t.key === tabPedida) ? tabPedida : tabsVisibles[0].key;
|
|
const setTab = (t: string) => setSearchParams({ tab: t });
|
|
const [subcatalogo, setSubcatalogo] = useState<string>(() => {
|
|
const sub = searchParams.get('sub');
|
|
return sub === 'procedimientos' ? 'procedimientos' : 'diagnosticos';
|
|
});
|
|
const [settings, setSettings] = useState<ClinicSettings>(defaultSettings);
|
|
const [loading, setLoading] = useState(true);
|
|
|
|
useEffect(() => {
|
|
try {
|
|
const saved = localStorage.getItem(STORAGE_KEY);
|
|
if (saved) setSettings(JSON.parse(saved));
|
|
} catch {
|
|
// ignore
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}, []);
|
|
|
|
const save = () => {
|
|
try {
|
|
localStorage.setItem(STORAGE_KEY, JSON.stringify(settings));
|
|
toast.success('Configuración guardada');
|
|
} catch {
|
|
toast.error('Error al guardar configuración');
|
|
}
|
|
};
|
|
|
|
const update = (field: keyof ClinicSettings, value: string) => {
|
|
setSettings((prev) => ({ ...prev, [field]: value }));
|
|
};
|
|
|
|
// ---- Tipo de cambio USD/MXN ----
|
|
const [tcCurrent, setTcCurrent] = useState<{ rate: number; date: string; source: string } | null>(null);
|
|
const [tcInput, setTcInput] = useState('');
|
|
const [tcSaving, setTcSaving] = useState(false);
|
|
|
|
useEffect(() => {
|
|
if (!esAdmin) return;
|
|
odooApi.getExchangeRate()
|
|
.then((res) => {
|
|
if (res.current) {
|
|
setTcCurrent(res.current);
|
|
setTcInput(String(res.current.rate));
|
|
}
|
|
})
|
|
.catch(() => {/* odoo no disponible, ignorar */});
|
|
}, []);
|
|
|
|
const saveTc = async () => {
|
|
const rate = parseFloat(tcInput);
|
|
if (!rate || rate <= 0) {
|
|
toast.error('Ingresa un tipo de cambio válido');
|
|
return;
|
|
}
|
|
setTcSaving(true);
|
|
try {
|
|
const res = await odooApi.setExchangeRate(rate, 'manual');
|
|
setTcCurrent(res.exchange_rate);
|
|
toast.success(`Tipo de cambio actualizado: ${rate.toFixed(2)} MXN/USD`);
|
|
} catch {
|
|
toast.error('No se pudo guardar el tipo de cambio');
|
|
} finally {
|
|
setTcSaving(false);
|
|
}
|
|
};
|
|
|
|
// ---- Comisiones de médicos ----
|
|
const [commissionDefault, setCommissionDefault] = useState('');
|
|
const [commissionDefaultSaving, setCommissionDefaultSaving] = useState(false);
|
|
const [doctors, setDoctors] = useState<Doctor[]>([]);
|
|
const [commissionInputs, setCommissionInputs] = useState<Record<number, string>>({});
|
|
const [commissionSavingId, setCommissionSavingId] = useState<number | null>(null);
|
|
const [commissionSavedId, setCommissionSavedId] = useState<number | null>(null);
|
|
const [impactoOpen, setImpactoOpen] = useState(false);
|
|
|
|
useEffect(() => {
|
|
if (!esAdmin) return;
|
|
odooApi.getCommissionDefault()
|
|
.then((res) => setCommissionDefault(String(res.commission_pct ?? 0)))
|
|
.catch(() => {/* odoo no disponible, ignorar */});
|
|
odooApi.getDoctors()
|
|
.then((res) => {
|
|
if (res.status === 'success') {
|
|
setDoctors(res.doctors);
|
|
setCommissionInputs(Object.fromEntries(res.doctors.map((d) => [d.id, String(d.commission_pct ?? 0)])));
|
|
}
|
|
})
|
|
.catch(() => {/* odoo no disponible, ignorar */});
|
|
}, []);
|
|
|
|
const validPct = (raw: string): number | null => {
|
|
const pct = parseFloat(raw);
|
|
if (Number.isNaN(pct) || pct < 0 || pct > 100) return null;
|
|
return pct;
|
|
};
|
|
|
|
const saveCommissionDefault = async () => {
|
|
const pct = validPct(commissionDefault);
|
|
if (pct === null) {
|
|
toast.error('El porcentaje debe estar entre 0 y 100');
|
|
return;
|
|
}
|
|
setCommissionDefaultSaving(true);
|
|
try {
|
|
await odooApi.setCommissionDefault(pct);
|
|
toast.success(`Comisión global actualizada: ${pct}%`);
|
|
} catch {
|
|
toast.error('No se pudo guardar la comisión global');
|
|
} finally {
|
|
setCommissionDefaultSaving(false);
|
|
}
|
|
};
|
|
|
|
const saveDoctorCommission = async (id: number) => {
|
|
const pct = validPct(commissionInputs[id] ?? '');
|
|
if (pct === null) {
|
|
toast.error('El porcentaje debe estar entre 0 y 100');
|
|
return;
|
|
}
|
|
setCommissionSavingId(id);
|
|
try {
|
|
await odooApi.setDoctorCommission(id, pct);
|
|
setDoctors((prev) => prev.map((d) => (d.id === id ? { ...d, commission_pct: pct } : d)));
|
|
setCommissionSavedId(id);
|
|
setTimeout(() => setCommissionSavedId((cur) => (cur === id ? null : cur)), 2000);
|
|
} catch {
|
|
toast.error('No se pudo guardar la comisión del médico');
|
|
} finally {
|
|
setCommissionSavingId(null);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<Layout title="Configuración" subtitle="Ajustes de la clínica">
|
|
<PageHeader title="Configuración" subtitle="Información y ajustes de la clínica" />
|
|
|
|
{/* Tabs de secciones */}
|
|
<div className="flex flex-wrap gap-1.5 mb-6">
|
|
{tabsVisibles.map((t) => (
|
|
<button
|
|
key={t.key}
|
|
type="button"
|
|
onClick={() => setTab(t.key)}
|
|
className={`px-4 py-2 text-sm font-medium rounded-full transition ${
|
|
tab === t.key ? 'bg-theme-accent text-theme-inverse' : 'bg-theme-accent-bg text-theme-heading hover:bg-theme-bg'
|
|
}`}
|
|
>
|
|
{t.label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
|
|
{tab === 'usuarios' ? (
|
|
<Card>
|
|
<Card.Body>
|
|
<UsuariosPanel />
|
|
</Card.Body>
|
|
</Card>
|
|
) : tab === 'roles' ? (
|
|
<Card>
|
|
<Card.Body>
|
|
<RolesPanel />
|
|
</Card.Body>
|
|
</Card>
|
|
) : tab === 'recetas' ? (
|
|
<Card>
|
|
<Card.Body>
|
|
<RecetasPanel />
|
|
</Card.Body>
|
|
</Card>
|
|
) : tab === 'tickets' ? (
|
|
<TicketDesigner />
|
|
) : tab === 'auditoria' ? (
|
|
<AuditoriaPanel />
|
|
) : tab === 'catalogos' ? (
|
|
<Card>
|
|
<Card.Body>
|
|
<div className="flex flex-wrap gap-1.5 mb-4">
|
|
{SUBCATALOGOS.map((s) => (
|
|
<button
|
|
key={s.key}
|
|
type="button"
|
|
onClick={() => setSubcatalogo(s.key)}
|
|
className={`px-3 py-1.5 text-sm font-medium rounded-full transition ${
|
|
subcatalogo === s.key ? 'bg-theme-accent text-theme-inverse' : 'bg-theme-accent-bg text-theme-heading hover:bg-theme-bg'
|
|
}`}
|
|
>
|
|
{s.label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
<CatalogoPanel kind={subcatalogo as 'diagnosticos' | 'procedimientos'} titulo={subcatalogo === 'diagnosticos' ? 'Diagnósticos' : 'Procedimientos'} />
|
|
</Card.Body>
|
|
</Card>
|
|
) : (
|
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4 sm:gap-6">
|
|
<Card>
|
|
<Card.Body>
|
|
<h3 className="font-heading text-xl text-theme-heading mb-4">Información de la clínica</h3>
|
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
|
<a
|
|
href={`https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(settings.address)}`}
|
|
target="_blank"
|
|
rel="noreferrer"
|
|
className="p-4 bg-theme-bg rounded-xl flex items-start space-x-3 hover:bg-theme-bg transition"
|
|
>
|
|
<div className="w-10 h-10 rounded-full bg-theme-surface flex items-center justify-center text-theme-muted shadow-sm shrink-0">
|
|
<MapPin size={18} />
|
|
</div>
|
|
<div className="min-w-0">
|
|
<p className="text-sm font-medium text-theme-heading">Dirección</p>
|
|
<p className="text-sm text-theme-muted truncate">{settings.address}</p>
|
|
</div>
|
|
</a>
|
|
|
|
<a
|
|
href={`tel:${settings.phone}`}
|
|
className="p-4 bg-theme-bg rounded-xl flex items-start space-x-3 hover:bg-theme-bg transition"
|
|
>
|
|
<div className="w-10 h-10 rounded-full bg-theme-surface flex items-center justify-center text-theme-muted shadow-sm shrink-0">
|
|
<Phone size={18} />
|
|
</div>
|
|
<div className="min-w-0">
|
|
<p className="text-sm font-medium text-theme-heading">Teléfono</p>
|
|
<p className="text-sm text-theme-muted truncate">{settings.phone}</p>
|
|
</div>
|
|
</a>
|
|
|
|
<a
|
|
href={`mailto:${settings.email}`}
|
|
className="p-4 bg-theme-bg rounded-xl flex items-start space-x-3 hover:bg-theme-bg transition"
|
|
>
|
|
<div className="w-10 h-10 rounded-full bg-theme-surface flex items-center justify-center text-theme-muted shadow-sm shrink-0">
|
|
<Mail size={18} />
|
|
</div>
|
|
<div className="min-w-0">
|
|
<p className="text-sm font-medium text-theme-heading">Email</p>
|
|
<p className="text-sm text-theme-muted truncate">{settings.email}</p>
|
|
</div>
|
|
</a>
|
|
|
|
<a
|
|
href={settings.website}
|
|
target="_blank"
|
|
rel="noreferrer"
|
|
className="p-4 bg-theme-bg rounded-xl flex items-start space-x-3 hover:bg-theme-bg transition"
|
|
>
|
|
<div className="w-10 h-10 rounded-full bg-theme-surface flex items-center justify-center text-theme-muted shadow-sm shrink-0">
|
|
<Globe size={18} />
|
|
</div>
|
|
<div className="min-w-0">
|
|
<p className="text-sm font-medium text-theme-heading">Sitio web</p>
|
|
<p className="text-sm text-theme-muted truncate">{settings.website.replace(/^https?:\/\//, '')}</p>
|
|
</div>
|
|
</a>
|
|
</div>
|
|
</Card.Body>
|
|
</Card>
|
|
|
|
<Card>
|
|
<Card.Body>
|
|
<h3 className="font-heading text-xl text-theme-heading mb-4 flex items-center">
|
|
<Building2 size={20} className="mr-2" />
|
|
Ajustes
|
|
</h3>
|
|
{loading ? (
|
|
<p className="text-sm text-theme-muted">Cargando...</p>
|
|
) : (
|
|
<div className="space-y-4">
|
|
<Input
|
|
label="Nombre de la clínica"
|
|
value={settings.name}
|
|
onChange={(e) => update('name', e.target.value)}
|
|
/>
|
|
<Input
|
|
label="Teléfono"
|
|
value={settings.phone}
|
|
onChange={(e) => update('phone', e.target.value)}
|
|
/>
|
|
<Input
|
|
label="Email"
|
|
type="email"
|
|
value={settings.email}
|
|
onChange={(e) => update('email', e.target.value)}
|
|
/>
|
|
<Input
|
|
label="Dirección"
|
|
value={settings.address}
|
|
onChange={(e) => update('address', e.target.value)}
|
|
/>
|
|
<Input
|
|
label="Sitio web"
|
|
value={settings.website}
|
|
onChange={(e) => update('website', e.target.value)}
|
|
/>
|
|
<div className="pt-2">
|
|
<Button onClick={save}>
|
|
<Save size={16} className="mr-2" />
|
|
Guardar ajustes
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</Card.Body>
|
|
</Card>
|
|
|
|
<Card className="lg:col-span-2">
|
|
<Card.Body>
|
|
<h3 className="font-heading text-xl text-theme-heading mb-1 flex items-center">
|
|
<ArrowRightLeft size={20} className="mr-2" />
|
|
Tipo de cambio USD / MXN
|
|
</h3>
|
|
<p className="text-sm text-theme-muted mb-4">
|
|
Se usa para convertir ventas en dólares. Vigente:
|
|
{tcCurrent ? (
|
|
<span className="font-semibold text-theme-heading"> {Number(tcCurrent.rate).toFixed(2)} MXN por 1 USD <span className="font-normal text-theme-muted">({tcCurrent.date})</span></span>
|
|
) : (
|
|
<span className="text-theme-muted"> sin definir</span>
|
|
)}
|
|
</p>
|
|
<div className="flex flex-col sm:flex-row items-start sm:items-end gap-3 max-w-md">
|
|
<div className="flex-1 w-full">
|
|
<Input
|
|
label="Nuevo tipo de cambio (MXN por 1 USD)"
|
|
type="number"
|
|
step="0.01"
|
|
min="0"
|
|
value={tcInput}
|
|
onChange={(e) => setTcInput(e.target.value)}
|
|
placeholder="17.25"
|
|
/>
|
|
</div>
|
|
<Button onClick={saveTc} disabled={tcSaving}>
|
|
<Save size={16} className="mr-2" />
|
|
{tcSaving ? 'Guardando...' : 'Actualizar TC'}
|
|
</Button>
|
|
</div>
|
|
</Card.Body>
|
|
</Card>
|
|
|
|
<Card className="lg:col-span-2">
|
|
<Card.Body>
|
|
<h3 className="font-heading text-xl text-theme-heading mb-1 flex items-center">
|
|
<Percent size={20} className="mr-2" />
|
|
Comisiones
|
|
</h3>
|
|
<p className="text-sm text-theme-muted mb-4">
|
|
Porcentaje de comisión sobre las ventas atribuidas a cada médico (visible en su página "Mis ventas").
|
|
</p>
|
|
<div className="mb-5 p-4 bg-theme-bg rounded-xl flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
|
|
<div>
|
|
<p className="text-sm font-medium text-theme-heading">¿Cuánto costaría esta configuración?</p>
|
|
<p className="text-xs text-theme-muted">
|
|
Simula la comisión de cada médico con las ventas atribuidas reales de un mes, sin guardar nada.
|
|
</p>
|
|
</div>
|
|
<Button variant="outline" onClick={() => setImpactoOpen(true)}>
|
|
<Calculator size={16} className="mr-2" />
|
|
Ver efecto
|
|
</Button>
|
|
</div>
|
|
<div className="flex flex-col sm:flex-row items-start sm:items-end gap-3 max-w-md mb-6">
|
|
<div className="flex-1 w-full">
|
|
<Input
|
|
label="% global por defecto"
|
|
type="number"
|
|
step="0.5"
|
|
min="0"
|
|
max="100"
|
|
value={commissionDefault}
|
|
onChange={(e) => setCommissionDefault(e.target.value)}
|
|
placeholder="0"
|
|
/>
|
|
<p className="text-xs text-theme-muted mt-1">Se aplica a médicos sin porcentaje propio (con 0%).</p>
|
|
</div>
|
|
<Button onClick={saveCommissionDefault} disabled={commissionDefaultSaving}>
|
|
<Save size={16} className="mr-2" />
|
|
{commissionDefaultSaving ? 'Guardando...' : 'Guardar'}
|
|
</Button>
|
|
</div>
|
|
<div className="overflow-x-auto">
|
|
<table className="w-full">
|
|
<thead className="bg-theme-bg">
|
|
<tr>
|
|
<th className="text-left p-3 text-xs font-medium text-theme-muted uppercase">Médico</th>
|
|
<th className="text-left p-3 text-xs font-medium text-theme-muted uppercase">Tipo</th>
|
|
<th className="text-left p-3 text-xs font-medium text-theme-muted uppercase">% actual</th>
|
|
<th className="text-left p-3 text-xs font-medium text-theme-muted uppercase w-40">Nuevo %</th>
|
|
<th className="text-left p-3 text-xs font-medium text-theme-muted uppercase w-32">Acción</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody className="divide-y">
|
|
{doctors.map((d) => (
|
|
<tr key={d.id} className="hover:bg-theme-bg">
|
|
<td className="p-3 text-sm font-medium text-theme-heading">{d.name}</td>
|
|
<td className="p-3 text-sm text-theme-muted">
|
|
{(d.staff_type ?? 'doctor') === 'cosmetologa' ? 'Cosmetóloga' : 'Doctor'}
|
|
</td>
|
|
<td className="p-3 text-sm text-theme-muted">
|
|
{d.commission_pct ? `${d.commission_pct}%` : '0% (usa el global)'}
|
|
</td>
|
|
<td className="p-3">
|
|
<Input
|
|
type="number"
|
|
step="0.5"
|
|
min="0"
|
|
max="100"
|
|
value={commissionInputs[d.id] ?? ''}
|
|
onChange={(e) => setCommissionInputs((prev) => ({ ...prev, [d.id]: e.target.value }))}
|
|
/>
|
|
</td>
|
|
<td className="p-3">
|
|
{commissionSavedId === d.id ? (
|
|
<span className="inline-flex items-center text-sm text-emerald-600">
|
|
<Check size={16} className="mr-1" /> Guardado
|
|
</span>
|
|
) : (
|
|
<Button
|
|
size="sm"
|
|
variant="outline"
|
|
onClick={() => saveDoctorCommission(d.id)}
|
|
disabled={commissionSavingId === d.id}
|
|
>
|
|
{commissionSavingId === d.id ? 'Guardando...' : 'Guardar'}
|
|
</Button>
|
|
)}
|
|
</td>
|
|
</tr>
|
|
))}
|
|
{doctors.length === 0 && (
|
|
<tr>
|
|
<td colSpan={5} className="p-4 text-sm text-theme-muted text-center">No hay médicos dados de alta.</td>
|
|
</tr>
|
|
)}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</Card.Body>
|
|
</Card>
|
|
</div>
|
|
)}
|
|
|
|
<ImpactoModal
|
|
isOpen={impactoOpen}
|
|
onClose={() => setImpactoOpen(false)}
|
|
comisionGlobal={parseFloat(commissionDefault) || 0}
|
|
/>
|
|
</Layout>
|
|
);
|
|
};
|
|
|
|
export default Configuracion;
|