import type { FC } from 'react'; import { useEffect, useMemo, useRef, useState } from 'react'; import { useSearchParams } from 'react-router-dom'; import { Phone, MapPin, Mail, Globe, Save, Building2, ArrowRightLeft, Percent, Check, Calculator, Palette } 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 SucursalesPanel from '../components/SucursalesPanel'; import CategoriasPanel from '../components/CategoriasPanel'; import RecursosPanel from '../components/RecursosPanel'; import TerminologiaPanel from '../components/TerminologiaPanel'; import ReservasOnlinePanel from '../components/ReservasOnlinePanel'; import PortalClientePanel from '../components/PortalClientePanel'; import NotificacionesPanel from '../components/NotificacionesPanel'; import NotificacionesEnviadasPanel from '../components/NotificacionesEnviadasPanel'; import FormulariosPanel from '../components/FormulariosPanel'; import MembresiasPanel from '../components/MembresiasPanel'; import ComprasPanel from '../components/ComprasPanel'; import ComisionesPanel from '../components/ComisionesPanel'; import LealtadPanel from '../components/LealtadPanel'; import PropinasPanel from '../components/PropinasPanel'; import CampanasPanel from '../components/CampanasPanel'; import EncuestasPanel from '../components/EncuestasPanel'; import LicenciaPanel from '../components/LicenciaPanel'; import ApiKeysPanel from '../components/ApiKeysPanel'; import WebhooksPanel from '../components/WebhooksPanel'; import { Card, Button, Input, PageHeader, toast } from '../components/ui'; import { odooApi, type Doctor, type ClinicSettings, type ImpuestosSettings, BRAND_DEFAULT, CLINIC_SETTINGS_DEFAULT, IMPUESTOS_DEFAULT, RESERVAS_ONLINE_DEFAULT, ANTICIPO_DEFAULT, MEMBRESIAS_DEFAULT, COBRO_AUTOMATICO_DEFAULT, NOTIFICACIONES_DEFAULT } from '../services/odoo'; import { useAuth } from '../lib/auth'; import { publicarClinicSettings } from '../lib/clinic-settings'; import { useI18n } from '../lib/i18n'; import { LanguageSelect } from '../components/LanguageSelect'; const TABS = [ { key: 'clinica', label: 'Clínica' }, { key: 'reservas', label: 'Reservas en línea' }, { key: 'portal', label: 'Portal del cliente' }, { key: 'notificaciones', label: 'Notificaciones' }, { key: 'notif_enviadas', label: 'Notificaciones enviadas' }, { key: 'formularios', label: 'Formularios' }, { key: 'sucursales', label: 'Sucursales' }, { key: 'categorias', label: 'Categorías' }, { key: 'recursos', label: 'Recursos' }, { key: 'membresias', label: 'Membresías' }, { key: 'compras', label: 'Compras' }, { key: 'comisiones', label: 'Comisiones' }, { key: 'lealtad', label: 'Lealtad' }, { key: 'propinas', label: 'Propinas' }, { key: 'campanas', label: 'Campañas' }, { key: 'encuestas', label: 'Encuestas' }, { key: 'terminologia', label: 'Terminología' }, { key: 'usuarios', label: 'Usuarios' }, { key: 'roles', label: 'Roles' }, { key: 'recetas', label: 'Recetas' }, { key: 'catalogos', label: 'Catálogos' }, { key: 'tickets', label: 'Tickets' }, { key: 'licencia', label: 'Licencia' }, { key: 'api', label: 'API' }, { key: 'webhooks', label: 'Webhooks' }, { key: 'auditoria', label: 'Auditoría' }, ] as const; // Secciones que ve gerencia (admin del sistema ve todas). const TABS_GERENCIA = ['reservas', 'portal', 'notificaciones', 'notif_enviadas', 'formularios', 'sucursales', 'categorias', 'recursos', 'membresias', 'compras', 'comisiones', 'lealtad', 'propinas', 'campanas', 'encuestas', 'terminologia', 'usuarios', 'roles', 'tickets', 'licencia', 'webhooks']; const SUBCATALOGOS = [ { key: 'diagnosticos', label: 'Diagnósticos' }, { key: 'procedimientos', label: 'Procedimientos' }, ] as const; // Clave histórica del navegador: antes los ajustes de la clínica vivían solo en // localStorage. Se migran al backend una vez y deja de usarse. const LEGACY_STORAGE_KEY = 'skeen_clinic_settings'; const Configuracion: FC = () => { const { user, brand, setBrand, can } = useAuth(); const { t } = useI18n(); // Clave i18n de una pestaña: 'notif_enviadas' -> 'config.tabs.notifEnviadas'. const tabKey = (k: string) => 'config.tabs.' + k.replace(/_([a-z])/g, (_, c: string) => c.toUpperCase()); // Gerencia gestiona catálogos, usuarios y roles; la sección "Clínica" (datos // de contacto, tipo de cambio y comisiones) queda para administración del sistema. const esAdmin = user?.role === 'admin'; // Cada pestaña requiere su permiso por acción (Configuración › Roles › Permisos). const TABS_PERM: Record = { usuarios: 'config.usuarios', roles: 'config.roles', auditoria: 'config.auditoria', notificaciones: 'notificaciones.editar', notif_enviadas: 'notificaciones.ver', formularios: 'formularios.ver', membresias: 'membresias.editar', compras: 'compras.editar', comisiones: 'comisiones.editar', lealtad: 'lealtad.configurar', propinas: 'propinas.configurar', campanas: 'campanas.gestionar', encuestas: 'encuestas.gestionar', recursos: 'recursos.editar', licencia: 'config.editar', api: 'api.claves', webhooks: 'webhooks.editar', }; const tabsVisibles = useMemo( () => (esAdmin ? TABS : TABS.filter((t) => TABS_GERENCIA.includes(t.key))) .filter((t) => can(TABS_PERM[t.key] || 'config.editar')), [esAdmin, can], ); const [searchParams, setSearchParams] = useSearchParams(); const tabPedida = searchParams.get('tab') || 'clinica'; const tab = tabsVisibles.some((t) => t.key === tabPedida) ? tabPedida : (tabsVisibles[0]?.key || 'clinica'); const setTab = (t: string) => setSearchParams({ tab: t }); const [subcatalogo, setSubcatalogo] = useState(() => { const sub = searchParams.get('sub'); return sub === 'procedimientos' ? 'procedimientos' : 'diagnosticos'; }); const [settings, setSettings] = useState(CLINIC_SETTINGS_DEFAULT); const [loading, setLoading] = useState(true); // Ajustes de la clínica desde el backend. Migración de una sola vez: si este // navegador tiene los datos viejos en localStorage y el backend sigue con los // defaults (contacto vacío), se precargan y se suben; después se deja de usar // localStorage como fuente de verdad. useEffect(() => { let alive = true; odooApi.getClinicSettings() .then(async (res) => { if (!alive || res.status !== 'success') return; let next = res.settings; try { const raw = localStorage.getItem(LEGACY_STORAGE_KEY); if (raw) { const legacy = JSON.parse(raw) as Partial<{ name: string; phone: string; email: string; address: string; website: string }>; const backendVacio = !next.telefono && !next.email && !next.direccion && !next.web; const legacyTieneDatos = Boolean(legacy?.phone || legacy?.email || legacy?.address || legacy?.website); if (backendVacio && legacyTieneDatos) { next = { ...next, telefono: legacy?.phone || next.telefono, email: legacy?.email || next.email, direccion: legacy?.address || next.direccion, web: legacy?.website || next.web, }; const up = await odooApi.putClinicSettings({ ...next, motivo: 'Migración desde localStorage' }); if (up.status === 'success') next = up.settings; } localStorage.removeItem(LEGACY_STORAGE_KEY); } } catch { localStorage.removeItem(LEGACY_STORAGE_KEY); } if (alive) setSettings({ ...CLINIC_SETTINGS_DEFAULT, ...next, impuestos: { ...IMPUESTOS_DEFAULT, ...(next.impuestos || {}) }, reservas_online: { ...RESERVAS_ONLINE_DEFAULT, ...(next.reservas_online || {}), anticipo: { ...ANTICIPO_DEFAULT, ...((next.reservas_online || {}).anticipo || {}) }, }, notificaciones: { ...NOTIFICACIONES_DEFAULT, ...(next.notificaciones || {}) }, membresias: { ...MEMBRESIAS_DEFAULT, ...(next.membresias || {}), cobro_automatico: { ...COBRO_AUTOMATICO_DEFAULT, ...((next.membresias || {}).cobro_automatico || {}) }, }, }); }) .catch(() => { toast.error('No se pudieron cargar los ajustes de la clínica'); }) .finally(() => { if (alive) setLoading(false); }); return () => { alive = false; }; }, []); const save = async () => { try { const res = await odooApi.putClinicSettings({ ...settings, motivo: 'Ajustes desde Configuración' }); if (res.status === 'success') { setSettings(res.settings); publicarClinicSettings(res.settings); } toast.success('Configuración guardada'); } catch (err) { const msg = (err as { response?: { data?: { message?: string } } })?.response?.data?.message; toast.error(msg || 'Error al guardar configuración'); } }; const update = (field: keyof ClinicSettings, value: string) => { setSettings((prev) => ({ ...prev, [field]: value })); }; const updateImpuesto = (patch: Partial) => { setSettings((prev) => ({ ...prev, impuestos: { ...IMPUESTOS_DEFAULT, ...prev.impuestos, ...patch }, })); }; // ---- 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); // ---- Identidad de la clínica (brand white-label) ---- const [brandForm, setBrandForm] = useState({ nombre: '', pie: '', logo_claro: '', logo_oscuro: '', url_publica: '', primario: '', acento: '', }); const [brandSaving, setBrandSaving] = useState(false); const brandCargadoRef = useRef(false); useEffect(() => { if (brandCargadoRef.current || !brand.nombre) return; brandCargadoRef.current = true; setBrandForm({ nombre: brand.nombre === BRAND_DEFAULT.nombre ? '' : brand.nombre, pie: brand.pie === BRAND_DEFAULT.pie ? '' : brand.pie, logo_claro: brand.logo_claro === BRAND_DEFAULT.logo_claro ? '' : brand.logo_claro, logo_oscuro: brand.logo_oscuro === BRAND_DEFAULT.logo_oscuro ? '' : brand.logo_oscuro, url_publica: brand.url_publica || '', primario: brand.colores?.primario || '', acento: brand.colores?.acento || '', }); }, [brand]); const saveBrand = async () => { setBrandSaving(true); try { const res = await odooApi.putBrand({ brand: { nombre: brandForm.nombre.trim() || BRAND_DEFAULT.nombre, pie: brandForm.pie.trim() || BRAND_DEFAULT.pie, logo_claro: brandForm.logo_claro.trim() || BRAND_DEFAULT.logo_claro, logo_oscuro: brandForm.logo_oscuro.trim() || BRAND_DEFAULT.logo_oscuro, url_publica: brandForm.url_publica.trim(), colores: { primario: brandForm.primario.trim(), acento: brandForm.acento.trim() }, }, motivo: 'Identidad desde Configuración', }); setBrand(res.brand); toast.success('Identidad de la clínica actualizada (se ve al recargar en los demás equipos)'); } catch (err) { const msg = (err as { response?: { data?: { message?: string } } })?.response?.data?.message; toast.error(msg || 'No se pudo guardar la identidad de la clínica'); } finally { setBrandSaving(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([]); const [commissionInputs, setCommissionInputs] = useState>({}); const [commissionSavingId, setCommissionSavingId] = useState(null); const [commissionSavedId, setCommissionSavedId] = useState(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 ( {/* Tabs de secciones */}
{tabsVisibles.map((tabItem) => ( ))}
{tabsVisibles.length === 0 ? (

{t('config.noPermission')}

) : tab === 'usuarios' ? ( ) : tab === 'roles' ? ( ) : tab === 'recetas' ? ( ) : tab === 'tickets' ? ( ) : tab === 'auditoria' ? ( ) : tab === 'catalogos' ? (
{SUBCATALOGOS.map((s) => ( ))}
) : tab === 'sucursales' ? ( ) : tab === 'categorias' ? ( ) : tab === 'recursos' ? ( ) : tab === 'membresias' ? ( ) : tab === 'compras' ? ( ) : tab === 'comisiones' ? ( ) : tab === 'lealtad' ? ( ) : tab === 'propinas' ? ( ) : tab === 'campanas' ? ( ) : tab === 'encuestas' ? ( ) : tab === 'terminologia' ? ( ) : tab === 'reservas' ? ( ) : tab === 'portal' ? ( ) : tab === 'notificaciones' ? ( ) : tab === 'notif_enviadas' ? ( ) : tab === 'formularios' ? ( ) : tab === 'licencia' ? ( ) : tab === 'api' ? ( ) : tab === 'webhooks' ? ( ) : ( <>

{t('config.idiomaTitle')}

{t('config.idiomaBody')}

{t('config.clinicInfo')}

{t('config.settings')}

{loading ? (

Cargando...

) : (
update('nombre', e.target.value)} />
update('telefono', e.target.value)} /> update('rfc', e.target.value)} placeholder="Opcional" />
update('email', e.target.value)} /> update('web', e.target.value)} placeholder="https://..." />
update('direccion', e.target.value)} />
update('horario_apertura', e.target.value)} /> update('horario_cierre', e.target.value)} />
update('zona_horaria', e.target.value)} placeholder="America/Tijuana" /> update('url_publica', e.target.value)} placeholder="https://..." />
update('nota_ticket', e.target.value)} placeholder="Se imprime al pie del ticket" />

Moneda base: {settings.moneda_base || 'MXN'}

{/* Impuestos (por instancia). Apagados = comportamiento de siempre: sin cálculo, sin desglose y sin renglón en tickets. */}
Impuestos

{settings.impuestos.activo ? 'El impuesto se muestra y se cobra en POS, tickets y reportes.' : 'Apagado: los totales, tickets y reportes salen exactamente como hoy.'}

{settings.impuestos.activo && ( <>
updateImpuesto({ nombre: e.target.value })} placeholder="IVA" /> updateImpuesto({ tasa_default: parseFloat(e.target.value) || 0 })} />
{ const opciones = e.target.value .split(',') .map((v) => parseFloat(v.trim())) .filter((v) => !Number.isNaN(v) && v >= 0 && v <= 100); updateImpuesto({ tasa_opciones: opciones.length ? opciones : [0] }); }} placeholder="0, 8, 16" /> )}
)}
{/* Identidad de la clínica (white-label): nombre, pie y logos que ve el usuario en login, menú lateral, impresos y títulos. Solo admin. */} {esAdmin && (

{t('config.clinicIdentity')}

Nombre, pie de página y logos que aparecen en el inicio de sesión, el menú y los documentos impresos. Dejar un campo vacío vuelve al valor por omisión.

setBrandForm((f) => ({ ...f, nombre: e.target.value }))} placeholder={BRAND_DEFAULT.nombre} /> setBrandForm((f) => ({ ...f, pie: e.target.value }))} placeholder={BRAND_DEFAULT.pie} /> setBrandForm((f) => ({ ...f, logo_claro: e.target.value }))} placeholder={BRAND_DEFAULT.logo_claro} /> setBrandForm((f) => ({ ...f, logo_oscuro: e.target.value }))} placeholder={BRAND_DEFAULT.logo_oscuro} /> setBrandForm((f) => ({ ...f, url_publica: e.target.value }))} placeholder="https://..." />
{/* Colores de marca: vacíos = se conserva el color del tema activo. */}

Colores de marca

Si se definen, pisan el color del tema en los tres temas. Dejarlos vacíos conserva el color original.

{([ { key: 'primario' as const, label: 'Color primario' }, { key: 'acento' as const, label: 'Color de acento' }, ]).map(({ key, label }) => (
setBrandForm((f) => ({ ...f, [key]: e.target.value }))} className="h-10 w-12 shrink-0 rounded-lg border border-theme-border-strong bg-theme-surface cursor-pointer" aria-label={label} /> setBrandForm((f) => ({ ...f, [key]: e.target.value }))} placeholder="Vacío = color del tema" /> {brandForm[key] && ( )}
))}
{(brandForm.logo_claro || brandForm.logo_oscuro) && (
{brandForm.logo_claro && ( logo oscuro )} {brandForm.logo_oscuro && ( logo claro )}
)}
)}

{t('config.exchangeRate')}

Se usa para convertir ventas en dólares. Vigente: {tcCurrent ? ( {Number(tcCurrent.rate).toFixed(2)} MXN por 1 USD ({tcCurrent.date}) ) : ( sin definir )}

setTcInput(e.target.value)} placeholder="17.25" />

{t('config.commissions')}

Porcentaje de comisión sobre las ventas atribuidas a cada médico (visible en su página "Mis ventas").

¿Cuánto costaría esta configuración?

Simula la comisión de cada médico con las ventas atribuidas reales de un mes, sin guardar nada.

setCommissionDefault(e.target.value)} placeholder="0" />

Se aplica a médicos sin porcentaje propio (con 0%).

{doctors.map((d) => ( ))} {doctors.length === 0 && ( )}
Médico Tipo % actual Nuevo % Acción
{d.name} {(d.staff_type ?? 'doctor') === 'cosmetologa' ? 'Cosmetóloga' : 'Doctor'} {d.commission_pct ? `${d.commission_pct}%` : '0% (usa el global)'} setCommissionInputs((prev) => ({ ...prev, [d.id]: e.target.value }))} /> {commissionSavedId === d.id ? ( Guardado ) : ( )}
No hay médicos dados de alta.
)} setImpactoOpen(false)} comisionGlobal={parseFloat(commissionDefault) || 0} />
); }; export default Configuracion;