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

View File

@@ -18,6 +18,7 @@ import {
} from '../components/ui';
import { CortePrint } from '../components/CortePrint';
import { odooApi, type CashClosing } from '../services/odoo';
import { useAuth } from '../lib/auth';
import { exportToExcel } from '../lib/exporter';
const money = (v: number) =>
@@ -33,6 +34,7 @@ const PASOS = [
] as const;
const Cortes: FC = () => {
const { brand } = useAuth();
const [closings, setClosings] = useState<CashClosing[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
@@ -79,9 +81,9 @@ const Cortes: FC = () => {
}
try {
await exportToExcel({
filename: `cortes-skeen-${mes}`,
filename: `cortes-${mes}`,
sheetName: 'Cortes',
title: 'Cortes de caja SKEEN',
title: `Cortes de caja ${brand.nombre}`,
subtitle: `Mes ${mes} · ${cortesDelMes.length} cortes`,
columns: [
{ header: 'Referencia', key: 'name' },

View File

@@ -6,6 +6,7 @@ import { Card, PageHeader, Button, toast } from '../components/ui';
import { odooApi } from '../services/odoo';
import type { Birthday } from '../services/odoo';
import { waLink } from '../lib/whatsapp';
import { useAuth } from '../lib/auth';
import { exportToExcel } from '../lib/exporter';
type Period = 'today' | 'week' | 'month';
@@ -25,10 +26,11 @@ const fmtDate = (s: string | null) => {
return d.toLocaleDateString('es-MX', { day: '2-digit', month: 'short' });
};
const waBirthdayText = (name: string) =>
`¡Feliz cumpleaños ${(name || '').split(' ')[0]}! Le saluda el equipo de SKEEN Derma Experts.`;
const waBirthdayText = (name: string, marca: string) =>
`¡Feliz cumpleaños ${(name || '').split(' ')[0]}! Le saluda el equipo de ${marca}.`;
const Cumpleanos: FC = () => {
const { brand } = useAuth();
const [period, setPeriod] = useState<Period>('month');
const [items, setItems] = useState<Birthday[]>([]);
const [range, setRange] = useState<{ start: string; end: string } | null>(null);
@@ -57,9 +59,9 @@ const Cumpleanos: FC = () => {
const exportBirthdays = async () => {
try {
await exportToExcel({
filename: `cumpleaneros-skeen-${period}-${new Date().toISOString().split('T')[0]}`,
filename: `cumpleaneros-${period}-${new Date().toISOString().split('T')[0]}`,
sheetName: 'Cumpleañeros',
title: 'Cumpleañeros SKEEN',
title: `Cumpleañeros ${brand.nombre}`,
subtitle: `Periodo: ${period} · ${items.length} pacientes`,
columns: [
{ header: 'Nombre', key: 'name' },
@@ -160,9 +162,9 @@ const Cumpleanos: FC = () => {
{fmtMoney(b.amount_due)}
</td>
<td className="px-4 py-3 text-right">
{waLink(b.whatsapp || b.phone, waBirthdayText(b.name)) ? (
{waLink(b.whatsapp || b.phone, waBirthdayText(b.name, brand.nombre)) ? (
<a
href={waLink(b.whatsapp || b.phone, waBirthdayText(b.name)) || '#'}
href={waLink(b.whatsapp || b.phone, waBirthdayText(b.name, brand.nombre)) || '#'}
target="_blank"
rel="noreferrer"
className="inline-flex items-center gap-1 text-theme-heading hover:text-theme-accent text-xs font-medium"

View File

@@ -1,16 +1,14 @@
import type { FC, FormEvent } from 'react';
import { useState } from 'react';
import { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { Eye, EyeOff, LogIn } from 'lucide-react';
import { Button, Input } from '../components/ui';
import { odooApi } from '../services/odoo';
import { useAuth } from '../lib/auth';
import { useTheme } from '../lib/theme';
const LOGO_SKEEN = '/skeen-brand/logos/Logo%20Completo%20Blanco.png';
const LOGO_HOMENEST = '/skeen-brand/logos/Logo%20Completo%20Negro.png';
const Login: FC = () => {
const { login } = useAuth();
const { login, brand, setBrand } = useAuth();
const { theme } = useTheme();
const navigate = useNavigate();
const [username, setUsername] = useState('');
@@ -19,7 +17,18 @@ const Login: FC = () => {
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const isHomeNest = theme === 'homenest';
const logo = isHomeNest ? LOGO_HOMENEST : LOGO_SKEEN;
// El logo sigue al tema (claro/oscuro) pero el asset lo define el brand.
const logo = isHomeNest ? brand.logo_oscuro : brand.logo_claro;
// Sin sesión aún: el login pide el brand directo (GET público) para pintar
// nombre/logo de la clínica antes de autenticar.
useEffect(() => {
let vivo = true;
odooApi.getBrand()
.then((res) => { if (vivo && res.status === 'success' && res.brand) setBrand(res.brand); })
.catch(() => { /* queda el brand cacheado/default */ });
return () => { vivo = false; };
}, [setBrand]);
const onSubmit = async (e: FormEvent) => {
e.preventDefault();
@@ -68,7 +77,7 @@ const Login: FC = () => {
<div className="text-center mb-10">
<img
src={logo}
alt="SKEEN Derma Experts"
alt={brand.nombre}
className="h-10 w-auto object-contain mx-auto mb-6"
/>
<h1 className="font-display text-3xl mb-2 tracking-wide" style={{ color: isHomeNest ? 'var(--text-heading)' : '#fff' }}>Bienvenido</h1>
@@ -128,7 +137,7 @@ const Login: FC = () => {
</form>
<p className="text-center text-xs mt-6" style={{ color: isHomeNest ? 'var(--text-muted)' : 'rgba(255,255,255,0.3)' }}>
SKEEN Derma Experts · Playas de Rosarito, B.C.
{brand.pie}
</p>
</div>
</div>

View File

@@ -31,6 +31,7 @@ import type { BadgeVariant } from '../components/ui/Badge';
import { useSearchParams } from 'react-router-dom';
import { odooApi, type Patient, type Appointment, type Sale, type Visita, type Doctor, type EstadoCuenta, type PatientWallet, type ReceiptData } from '../services/odoo';
import { exportToExcel } from '../lib/exporter';
import { useAuth } from '../lib/auth';
const fmtMoney = (n: number) =>
(n || 0).toLocaleString('es-MX', { style: 'currency', currency: 'MXN', maximumFractionDigits: 0 });
@@ -120,6 +121,7 @@ const CRITICAL_ALERTS: { key: keyof Patient; label: string }[] = [
];
const Pacientes: FC = () => {
const { brand } = useAuth();
const [patients, setPatients] = useState<Patient[]>([]);
const [loading, setLoading] = useState(true);
const [submitting, setSubmitting] = useState(false);
@@ -481,9 +483,9 @@ const Pacientes: FC = () => {
}
const todos = res.patients;
await exportToExcel({
filename: `pacientes-skeen-${new Date().toISOString().split('T')[0]}`,
filename: `pacientes-${new Date().toISOString().split('T')[0]}`,
sheetName: 'Pacientes',
title: 'Pacientes SKEEN',
title: `Pacientes ${brand.nombre}`,
subtitle: res.truncado
? `${res.total} de ${res.total_disponible} pacientes (límite ${res.limite})`
: `${res.total} pacientes (exportación completa)`,

View File

@@ -142,7 +142,7 @@ interface CashReport {
const ReportesContent: FC = () => {
const [searchParams, setSearchParams] = useSearchParams();
const { hasRole, canSee } = useAuth();
const { hasRole, canSee, brand } = useAuth();
const esAdmin = hasRole('admin');
// PuedeVer una pestana = canSee('/reportes?tab=x'): la matriz guarda claves
// por pestana y, cuando no existe la del tab, canSee cae al prefijo
@@ -262,9 +262,9 @@ const ReportesContent: FC = () => {
commissions.forEach((c) =>
rows.push({ seccion: 'Comisiones', concepto: c.doctor, cantidad: c.items, monto: c.commission }));
await exportToExcel({
filename: `reporte-skeen-${start}_${end}`,
filename: `reporte-${start}_${end}`,
sheetName: 'General',
title: 'Reporte general SKEEN',
title: `Reporte general ${brand.nombre}`,
subtitle: `Rango ${start} a ${end} · efectivo del ${today}`,
columns: [
{ header: 'Sección', key: 'seccion' },

View File

@@ -47,7 +47,7 @@ const paymentMethods = ['Efectivo', 'Tarjeta', 'Transferencia', 'MercadoPago'];
const Ventas: FC = () => {
const { openPatient } = usePatientDrawer();
const { user } = useAuth();
const { user, brand } = useAuth();
const puedeCancelar = ROLES_CANCELAN.includes(user?.role || '');
const [sales, setSales] = useState<Sale[]>([]);
const [services, setServices] = useState<Service[]>([]);
@@ -314,9 +314,9 @@ const Ventas: FC = () => {
const exportSales = async () => {
try {
await exportToExcel({
filename: `ventas-skeen-${new Date().toISOString().split('T')[0]}`,
filename: `ventas-${new Date().toISOString().split('T')[0]}`,
sheetName: 'Ventas',
title: 'Ventas SKEEN',
title: `Ventas ${brand.nombre}`,
subtitle: `${sales.length} ventas de la vista actual`,
columns: [
{ header: 'Referencia', key: 'name' },