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 } 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, BRAND_DEFAULT } 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, brand, setBrand } = 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(() => { const sub = searchParams.get('sub'); return sub === 'procedimientos' ? 'procedimientos' : 'diagnosticos'; }); const [settings, setSettings] = useState(() => ({ ...defaultSettings, // El nombre default sigue al brand (white-label); teléfono/dirección // quedan en localStorage o los valores históricos de la clínica. name: brand.nombre, })); 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); // ---- Identidad de la clínica (brand white-label) ---- const [brandForm, setBrandForm] = useState({ nombre: '', pie: '', logo_claro: '', logo_oscuro: '', }); 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, }); }, [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, }, 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 { toast.error('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((t) => ( ))}
{tab === 'usuarios' ? ( ) : tab === 'roles' ? ( ) : tab === 'recetas' ? ( ) : tab === 'tickets' ? ( ) : tab === 'auditoria' ? ( ) : tab === 'catalogos' ? (
{SUBCATALOGOS.map((s) => ( ))}
) : (

Información de la clínica

Ajustes

{loading ? (

Cargando...

) : (
update('name', e.target.value)} /> update('phone', e.target.value)} /> update('email', e.target.value)} /> update('address', e.target.value)} /> update('website', e.target.value)} />
)}
{/* 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 && (

Identidad de la clínica

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} />
{(brandForm.logo_claro || brandForm.logo_oscuro) && (
{brandForm.logo_claro && ( logo oscuro )} {brandForm.logo_oscuro && ( logo claro )}
)}
)}

Tipo de cambio USD / MXN

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" />

Comisiones

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;