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
This commit is contained in:
2026-10-08 00:09:00 +00:00
parent 2573562218
commit 2f7cbe237e
18 changed files with 302 additions and 48 deletions

View File

@@ -1,5 +1,5 @@
import type { FC } from 'react';
import { useEffect, useMemo, useState } 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';
@@ -11,7 +11,7 @@ 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 { odooApi, type Doctor, BRAND_DEFAULT } from '../services/odoo';
import { useAuth } from '../lib/auth';
const TABS = [
@@ -48,7 +48,7 @@ const defaultSettings: ClinicSettings = {
const STORAGE_KEY = 'skeen_clinic_settings';
const Configuracion: FC = () => {
const { user } = useAuth();
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';
@@ -66,7 +66,12 @@ const Configuracion: FC = () => {
const sub = searchParams.get('sub');
return sub === 'procedimientos' ? 'procedimientos' : 'diagnosticos';
});
const [settings, setSettings] = useState<ClinicSettings>(defaultSettings);
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(() => {
@@ -97,6 +102,43 @@ const Configuracion: FC = () => {
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;
@@ -368,6 +410,65 @@ const Configuracion: FC = () => {
</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">