Files
SKEEN-Proyecto/frontend/src/pages/Configuracion.tsx
Consultoría Alcaraz Salazar 2f7cbe237e white-label: brand de la clinica por configuracion (fase 1 de instancia sin branding SKEEN)
- backend: GET/PUT /skeen/frontend/v1/brand leyendo ir.config_parameter
  skeen.clinic.brand (JSON: nombre, logo_claro, logo_oscuro, pie) con
  defaults SKEEN; GET publico (el login lo necesita sin sesion), PUT admin
  con auditoria. frontend_users.xml: 'Administrador SKEEN' -> 'Administrador'
- frontend: ClinicBrand + BRAND_DEFAULT en odoo.ts; brand y setBrand en el
  contexto de auth (fetch al boot + cache localStorage + document.title)
- Login/layouts/mensajes/exports leen el brand en vez de strings SKEEN:
  Login.tsx, SkeenLayout, HomeNestLayout, Cumpleanos (wa + export),
  Pacientes/Ventas/Cortes/Reportes/Kpis/Alertas/AgendaPrint (titulos),
  useClinicaPrint (fallback nombre), Login hace GET publico al montar
- Configuracion > Clinica: tarjeta 'Identidad de la clinica' (solo admin)
  para editar nombre/pie/logos con preview
- no renombrado (invisible al usuario): rutas API /skeen/*, claves
  localStorage, nombres de modulos, parametros skeen.*

Verificacion API: 11/11 tests OK (defaults, PUT merge, campo invalido
ignorado, restore, admin neutral). Deploy: build + rsync + Odoo reiniciado
2026-10-08 00:09:00 +00:00

618 lines
25 KiB
TypeScript

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<string>(() => {
const sub = searchParams.get('sub');
return sub === 'procedimientos' ? 'procedimientos' : 'diagnosticos';
});
const [settings, setSettings] = useState<ClinicSettings>(() => ({
...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<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>
{/* 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 && (
<Card>
<Card.Body>
<h3 className="font-heading text-xl text-theme-heading mb-1">Identidad de la clínica</h3>
<p className="text-sm text-theme-muted mb-4">
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.
</p>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<Input
label="Nombre comercial"
value={brandForm.nombre}
onChange={(e) => setBrandForm((f) => ({ ...f, nombre: e.target.value }))}
placeholder={BRAND_DEFAULT.nombre}
/>
<Input
label="Pie de página (login / documentos)"
value={brandForm.pie}
onChange={(e) => setBrandForm((f) => ({ ...f, pie: e.target.value }))}
placeholder={BRAND_DEFAULT.pie}
/>
<Input
label="Logo fondo oscuro (ruta o URL)"
value={brandForm.logo_claro}
onChange={(e) => setBrandForm((f) => ({ ...f, logo_claro: e.target.value }))}
placeholder={BRAND_DEFAULT.logo_claro}
/>
<Input
label="Logo fondo claro (ruta o URL)"
value={brandForm.logo_oscuro}
onChange={(e) => setBrandForm((f) => ({ ...f, logo_oscuro: e.target.value }))}
placeholder={BRAND_DEFAULT.logo_oscuro}
/>
</div>
<div className="flex items-center gap-3 mt-4">
{(brandForm.logo_claro || brandForm.logo_oscuro) && (
<div className="flex items-center gap-2">
{brandForm.logo_claro && (
<span className="inline-flex items-center justify-center w-24 h-10 rounded-lg bg-[#1A1A1A] border border-theme-border">
<img src={brandForm.logo_claro} alt="logo oscuro" className="max-h-7 max-w-[88px] object-contain" />
</span>
)}
{brandForm.logo_oscuro && (
<span className="inline-flex items-center justify-center w-24 h-10 rounded-lg bg-white border border-theme-border">
<img src={brandForm.logo_oscuro} alt="logo claro" className="max-h-7 max-w-[88px] object-contain" />
</span>
)}
</div>
)}
<Button onClick={saveBrand} loading={brandSaving}>
Guardar identidad
</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">
<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;