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:
@@ -1,6 +1,7 @@
|
||||
import type { FC } from 'react';
|
||||
import { Printer } from 'lucide-react';
|
||||
import { Button, Modal } from './ui';
|
||||
import { useAuth } from '../lib/auth';
|
||||
import type { Appointment, Bloqueo } from '../services/odoo';
|
||||
|
||||
// Vista de impresión de la agenda del día (patrón print-*): un bloque por
|
||||
@@ -50,6 +51,7 @@ const AgendaPrint: FC<AgendaPrintProps> = ({
|
||||
doctors,
|
||||
filterDoctorName,
|
||||
}) => {
|
||||
const { brand } = useAuth();
|
||||
const dayApts = appointments.filter((a) => a.date === date);
|
||||
// Bloques: un doctor por bloque (o solo el filtrado). Incluye "Sin asignar"
|
||||
// si hay citas sin doctor.
|
||||
@@ -122,7 +124,7 @@ const AgendaPrint: FC<AgendaPrintProps> = ({
|
||||
{/* Encabezado */}
|
||||
<div className="flex items-start justify-between border-b-2 border-[#1A1A1A] pb-3 mb-4">
|
||||
<div>
|
||||
<p className="text-lg font-bold">SKEEN Derma Experts</p>
|
||||
<p className="text-lg font-bold">{brand.nombre}</p>
|
||||
<p className="text-sm text-[#555]">Agenda del día</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
|
||||
@@ -53,8 +53,6 @@ interface LayoutProps {
|
||||
subtitle?: string;
|
||||
}
|
||||
|
||||
const LOGO_HOMENEST = '/skeen-brand/logos/Logo%20Completo%20Negro.png';
|
||||
|
||||
const topNavItems = [
|
||||
{ label: 'Dashboard', icon: LayoutDashboard, href: '/' },
|
||||
{ label: 'Agenda', icon: Calendar, href: '/agenda' },
|
||||
@@ -114,7 +112,7 @@ const menuSections = [
|
||||
export const HomeNestLayout: FC<LayoutProps> = ({ children, title: _title, subtitle: _subtitle }) => {
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const { user, canSee, hasRole, logout } = useAuth();
|
||||
const { user, canSee, hasRole, logout, brand } = useAuth();
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
const [expanded, setExpanded] = useState<string[]>(['Clínica']);
|
||||
const chatUnread = useChatUnread();
|
||||
@@ -239,7 +237,7 @@ export const HomeNestLayout: FC<LayoutProps> = ({ children, title: _title, subti
|
||||
<Menu size={22} />
|
||||
</Button>
|
||||
<Link to="/" className="flex items-center gap-2">
|
||||
<img src={LOGO_HOMENEST} alt="SKEEN" className="h-8 w-auto object-contain" />
|
||||
<img src={brand.logo_oscuro} alt={brand.nombre} className="h-8 w-auto object-contain" />
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
@@ -515,7 +513,7 @@ export const HomeNestLayout: FC<LayoutProps> = ({ children, title: _title, subti
|
||||
<div className="fixed inset-0 z-40 lg:hidden" style={{ backgroundColor: 'rgba(62,44,28,0.4)' }} onClick={() => setMenuOpen(false)} aria-hidden="true" />
|
||||
<aside className="fixed inset-y-0 left-0 w-80 z-50 flex flex-col overflow-y-auto lg:hidden" style={{ backgroundColor: 'var(--surface)' }}>
|
||||
<div className="flex items-center justify-between px-5 h-16 border-b" style={{ borderColor: 'var(--border)' }}>
|
||||
<img src={LOGO_HOMENEST} alt="SKEEN" className="h-8 w-auto object-contain" />
|
||||
<img src={brand.logo_oscuro} alt={brand.nombre} className="h-8 w-auto object-contain" />
|
||||
<Button variant="ghost" size="sm" onClick={() => setMenuOpen(false)} aria-label="Cerrar menú">
|
||||
<X size={22} />
|
||||
</Button>
|
||||
|
||||
@@ -55,8 +55,6 @@ interface LayoutProps {
|
||||
subtitle?: string;
|
||||
}
|
||||
|
||||
const LOGO_SKEEN = '/skeen-brand/logos/Logo%20Completo%20Blanco.png';
|
||||
|
||||
const menuGroups = [
|
||||
{
|
||||
label: 'Principal',
|
||||
@@ -122,7 +120,7 @@ const menuGroups = [
|
||||
export const SkeenLayout: FC<LayoutProps> = ({ children, title, subtitle }) => {
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const { user, canSee, hasRole, logout } = useAuth();
|
||||
const { user, canSee, hasRole, logout, brand } = useAuth();
|
||||
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||
const chatUnread = useChatUnread();
|
||||
// Sidebar colapsable (modo iconos). Preferencia persistente; en pantallas
|
||||
@@ -266,7 +264,7 @@ export const SkeenLayout: FC<LayoutProps> = ({ children, title, subtitle }) => {
|
||||
}) => (
|
||||
<>
|
||||
<div className={`h-20 flex items-center border-b border-white/10 shrink-0 ${collapsed ? 'flex-col justify-center gap-1 px-1' : 'px-5 lg:px-6'}`}>
|
||||
<img src={LOGO_SKEEN} alt="SKEEN" className={collapsed ? 'h-5 w-auto object-contain' : 'h-7 w-auto object-contain'} />
|
||||
<img src={brand.logo_claro} alt={brand.nombre} className={collapsed ? 'h-5 w-auto object-contain' : 'h-7 w-auto object-contain'} />
|
||||
{showToggle && (
|
||||
<button
|
||||
type="button"
|
||||
@@ -466,7 +464,7 @@ export const SkeenLayout: FC<LayoutProps> = ({ children, title, subtitle }) => {
|
||||
style={{ backgroundColor: 'var(--sidebar-bg)' }}
|
||||
>
|
||||
<div className="h-20 flex items-center justify-between px-5 border-b border-white/10 shrink-0">
|
||||
<img src={LOGO_SKEEN} alt="SKEEN" className="h-7 w-auto object-contain" />
|
||||
<img src={brand.logo_claro} alt={brand.nombre} className="h-7 w-auto object-contain" />
|
||||
<Button variant="ghost" size="sm" onClick={() => setSidebarOpen(false)} aria-label="Cerrar menú">
|
||||
<X size={22} />
|
||||
</Button>
|
||||
|
||||
@@ -6,6 +6,7 @@ import { ExportButton } from '../ui/ExportButton';
|
||||
import MovimientoModal from './MovimientoModal';
|
||||
import { exportToExcel } from '../../lib/exporter';
|
||||
import { odooApi, type CaducidadRow, type SugerenciaRow } from '../../services/odoo';
|
||||
import { useAuth } from '../../lib/auth';
|
||||
import type { BadgeVariant } from '../ui/Badge';
|
||||
|
||||
const fmtMoney = (n: number) =>
|
||||
@@ -18,6 +19,7 @@ const caducidadBadge = (row: CaducidadRow): { variant: BadgeVariant; label: stri
|
||||
};
|
||||
|
||||
const AlertasPanel: FC = () => {
|
||||
const { brand } = useAuth();
|
||||
const [caducidades, setCaducidades] = useState<CaducidadRow[]>([]);
|
||||
const [sugerencia, setSugerencia] = useState<SugerenciaRow[]>([]);
|
||||
const [totalSugerencia, setTotalSugerencia] = useState({ items: 0, costo_total: 0 });
|
||||
@@ -51,7 +53,7 @@ const AlertasPanel: FC = () => {
|
||||
await exportToExcel({
|
||||
filename: `sugerencia-compra-${new Date().toISOString().split('T')[0]}`,
|
||||
sheetName: 'Sugerencia de compra',
|
||||
title: 'Sugerencia de compra — inventario SKEEN',
|
||||
title: `Sugerencia de compra — inventario ${brand.nombre}`,
|
||||
subtitle: `${totalSugerencia.items} artículos bajo mínimo · costo estimado ${fmtMoney(totalSugerencia.costo_total)}`,
|
||||
columns: [
|
||||
{ header: 'Artículo', key: 'name' },
|
||||
|
||||
@@ -37,7 +37,7 @@ const ExportarReport: FC = () => {
|
||||
return;
|
||||
}
|
||||
await exportToExcel({
|
||||
filename: `${soloVip ? 'correos-vip' : 'correos'}-skeen-${new Date().toISOString().split('T')[0]}`,
|
||||
filename: `${soloVip ? 'correos-vip' : 'correos'}-${new Date().toISOString().split('T')[0]}`,
|
||||
sheetName: soloVip ? 'Correos VIP' : 'Correos',
|
||||
title: soloVip ? 'Correos de pacientes VIP' : 'Correos de pacientes',
|
||||
subtitle: `${filtrados.length} correos`,
|
||||
@@ -74,7 +74,7 @@ const ExportarReport: FC = () => {
|
||||
page += 1;
|
||||
} while (page <= totalPages);
|
||||
await exportToExcel({
|
||||
filename: `lista-precios-skeen-${new Date().toISOString().split('T')[0]}`,
|
||||
filename: `lista-precios-${new Date().toISOString().split('T')[0]}`,
|
||||
sheetName: 'Lista de precios',
|
||||
title: 'Lista de precios de servicios',
|
||||
subtitle: `${rows.length} servicios activos`,
|
||||
|
||||
@@ -15,6 +15,7 @@ import { ExportButton } from '../ui/ExportButton';
|
||||
import { exportToExcel } from '../../lib/exporter';
|
||||
import { odooApi, type KpisReport as KpisData } from '../../services/odoo';
|
||||
import { useReportRange } from '../../lib/reportRange';
|
||||
import { useAuth } from '../../lib/auth';
|
||||
|
||||
const pct = (v: number) => `${Math.round(v * 100)}%`;
|
||||
|
||||
@@ -30,6 +31,7 @@ const Kpi: FC<{ label: string; value: React.ReactNode; icon: React.ReactNode; ro
|
||||
);
|
||||
|
||||
const KpisReport: FC = () => {
|
||||
const { brand } = useAuth();
|
||||
// Rango global de Reportes; los campos Inicio/Fin fijan uno propio
|
||||
const { start, end, setStart, setEnd } = useReportRange('kpis');
|
||||
const [data, setData] = useState<KpisData | null>(null);
|
||||
@@ -69,7 +71,7 @@ const KpisReport: FC = () => {
|
||||
await exportToExcel({
|
||||
filename: `kpis-${start}_${end}`,
|
||||
sheetName: 'KPIs',
|
||||
title: 'KPIs clínicos SKEEN',
|
||||
title: `KPIs clínicos ${brand.nombre}`,
|
||||
subtitle: `Del ${start} al ${end}`,
|
||||
columns: [
|
||||
{ header: 'Sección', key: 'seccion' },
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { createContext, useContext, useEffect, useState, useCallback } from 'react';
|
||||
import type { FC, ReactNode } from 'react';
|
||||
import { odooApi, type FrontendUser, type FrontendRole, type AgendaPerm } from '../services/odoo';
|
||||
import { odooApi, type FrontendUser, type FrontendRole, type AgendaPerm, type ClinicBrand, BRAND_DEFAULT } from '../services/odoo';
|
||||
|
||||
const TOKEN_KEY = 'skeen_token';
|
||||
const USER_KEY = 'skeen_user';
|
||||
const MENU_ROLES_KEY = 'skeen_menu_roles';
|
||||
const BRAND_KEY_LS = 'skeen_clinic_brand';
|
||||
|
||||
const ROLE_RANK: Record<FrontendRole, number> = {
|
||||
admin: 100,
|
||||
@@ -76,6 +77,9 @@ interface AuthContextValue {
|
||||
hasRole: (min: FrontendRole) => boolean;
|
||||
canSee: (href: string) => boolean;
|
||||
canAgenda: (perm: AgendaPerm) => boolean;
|
||||
// Brand de la clínica (white-label). BRAND_DEFAULT hasta que /brand responde.
|
||||
brand: ClinicBrand;
|
||||
setBrand: (b: ClinicBrand) => void;
|
||||
}
|
||||
|
||||
const AuthContext = createContext<AuthContextValue | undefined>(undefined);
|
||||
@@ -96,6 +100,34 @@ export const AuthProvider: FC<{ children: ReactNode }> = ({ children }) => {
|
||||
return null;
|
||||
}
|
||||
});
|
||||
// Brand de la clínica: se hidrata del caché para no parpadear y se refresca
|
||||
// con /brand en el arranque (mismo patrón que menuRoles).
|
||||
const [brand, setBrandState] = useState<ClinicBrand>(() => {
|
||||
try {
|
||||
const cached = localStorage.getItem(BRAND_KEY_LS);
|
||||
return cached ? { ...BRAND_DEFAULT, ...JSON.parse(cached) } : BRAND_DEFAULT;
|
||||
} catch {
|
||||
return BRAND_DEFAULT;
|
||||
}
|
||||
});
|
||||
|
||||
const setBrand = useCallback((b: ClinicBrand) => {
|
||||
setBrandState(b);
|
||||
localStorage.setItem(BRAND_KEY_LS, JSON.stringify(b));
|
||||
// Título de la pestaña sigue al brand configurado.
|
||||
if (typeof document !== 'undefined' && b.nombre) {
|
||||
document.title = b.nombre;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const refreshBrand = useCallback(async () => {
|
||||
try {
|
||||
const res = await odooApi.getBrand();
|
||||
if (res.status === 'success' && res.brand) setBrand(res.brand);
|
||||
} catch {
|
||||
// fallback: BRAND_DEFAULT / caché
|
||||
}
|
||||
}, [setBrand]);
|
||||
|
||||
const refreshMenuRoles = useCallback(async () => {
|
||||
try {
|
||||
@@ -146,6 +178,7 @@ export const AuthProvider: FC<{ children: ReactNode }> = ({ children }) => {
|
||||
setUser(res.user);
|
||||
localStorage.setItem(USER_KEY, JSON.stringify(res.user));
|
||||
refreshMenuRoles();
|
||||
refreshBrand();
|
||||
} else {
|
||||
clear();
|
||||
}
|
||||
@@ -184,6 +217,7 @@ export const AuthProvider: FC<{ children: ReactNode }> = ({ children }) => {
|
||||
setToken(res.token);
|
||||
setUser(res.user);
|
||||
refreshMenuRoles();
|
||||
refreshBrand();
|
||||
return res.user;
|
||||
};
|
||||
|
||||
@@ -232,7 +266,7 @@ export const AuthProvider: FC<{ children: ReactNode }> = ({ children }) => {
|
||||
};
|
||||
|
||||
return (
|
||||
<AuthContext.Provider value={{ user, token, loading, bootError, retryBoot, login, logout, hasRole, canSee, canAgenda }}>
|
||||
<AuthContext.Provider value={{ user, token, loading, bootError, retryBoot, login, logout, hasRole, canSee, canAgenda, brand, setBrand }}>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { odooApi } from '../services/odoo';
|
||||
import { useAuth } from './auth';
|
||||
|
||||
// Configuración del ticket (diseño + datos de la clínica + logo).
|
||||
//
|
||||
@@ -178,15 +179,16 @@ export const ticketConfigActual = (): TicketConfig => actual;
|
||||
* Datos de la clínica para los documentos impresos (ticket, recibo, estado de
|
||||
* cuenta, receta, corte, conteo). Sale de la configuración del servidor; si
|
||||
* nunca se configuró, cae al skeen_clinic_settings de este navegador y de ahí
|
||||
* a los valores históricos de SKEEN.
|
||||
* al nombre del brand (SKEEN por omisión, o la clínica white-label).
|
||||
*/
|
||||
export const useClinicaPrint = () => {
|
||||
const { brand } = useAuth();
|
||||
const c = useTicketConfig().clinica;
|
||||
const local = leerLocal()?.clinica;
|
||||
const mix = (campo: keyof TicketClinica, porDefecto = '') =>
|
||||
String(c[campo] || local?.[campo] || porDefecto || '');
|
||||
return {
|
||||
nombre: mix('nombre', 'SKEEN Derma Experts'),
|
||||
nombre: mix('nombre', brand.nombre),
|
||||
direccion: mix('direccion', 'Playas de Rosarito, Baja California'),
|
||||
telefono: mix('telefono'),
|
||||
email: mix('email'),
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)`,
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -898,6 +898,22 @@ export const AGENDA_PERM_ITEMS: { key: AgendaPerm; label: string; nota: string }
|
||||
{ key: 'visitas', label: 'Generar visitas', nota: 'Check-in (En cabina) y todo el expediente del día.' },
|
||||
];
|
||||
|
||||
// Brand de la clínica (white-label). El backend resuelve herencia: si la
|
||||
// clínica no configuró un campo, devuelve el default de SKEEN.
|
||||
export interface ClinicBrand {
|
||||
nombre: string;
|
||||
logo_claro: string; // fondos oscuros (login SKEEN, sidebar)
|
||||
logo_oscuro: string; // fondos claros (tema HomeNest)
|
||||
pie: string; // línea del pie (login / documentos)
|
||||
}
|
||||
|
||||
export const BRAND_DEFAULT: ClinicBrand = {
|
||||
nombre: 'SKEEN Derma Experts',
|
||||
logo_claro: '/skeen-brand/logos/Logo%20Completo%20Blanco.png',
|
||||
logo_oscuro: '/skeen-brand/logos/Logo%20Completo%20Negro.png',
|
||||
pie: 'SKEEN Derma Experts · Playas de Rosarito, B.C.',
|
||||
};
|
||||
|
||||
export interface FrontendUser {
|
||||
id: number;
|
||||
login: string;
|
||||
@@ -1338,6 +1354,17 @@ export const odooApi = {
|
||||
return data;
|
||||
},
|
||||
|
||||
// Brand de la clínica (white-label): nombre, logos y pie desde Config.
|
||||
async getBrand(): Promise<{ status: string; brand: ClinicBrand; message?: string }> {
|
||||
const { data } = await axios.get(`${ODOO_BASE}/skeen/frontend/v1/brand`);
|
||||
return data;
|
||||
},
|
||||
|
||||
async putBrand(payload: { brand: Partial<ClinicBrand>; motivo?: string }): Promise<{ status: string; brand: ClinicBrand; message?: string }> {
|
||||
const { data } = await axios.put(`${ODOO_BASE}/skeen/frontend/v1/brand`, payload);
|
||||
return data;
|
||||
},
|
||||
|
||||
async updateRolePermissions(matrix: Record<string, FrontendRole[]>): Promise<{ status: string; matrix: Record<string, FrontendRole[]>; message?: string }> {
|
||||
const { data } = await axios.put(`${ODOO_BASE}/skeen/frontend/v1/roles/permissions`, { matrix });
|
||||
return data;
|
||||
|
||||
@@ -6244,6 +6244,79 @@ class SkeenFrontendController(http.Controller):
|
||||
except Exception as e:
|
||||
return json_response({'status': 'error', 'message': str(e)}, 500)
|
||||
|
||||
# ============================================================
|
||||
# Brand de la clínica (nombre, logos, pie) — white-label
|
||||
# ============================================================
|
||||
BRAND_KEY = 'skeen.clinic.brand'
|
||||
|
||||
def _brand_dict(self):
|
||||
"""Brand vigente: lo guardado en ir.config_parameter o defaults SKEEN.
|
||||
|
||||
Este endpoint es lo que permite desplegar la plataforma para otra
|
||||
clínica sin tocar código: la instancia nueva solo cambia este
|
||||
parámetro y sube sus logos. Los campos:
|
||||
nombre - nombre comercial (login, títulos, mensajes, tickets)
|
||||
logo_claro - logo para fondos oscuros (login SKEEN, sidebar)
|
||||
logo_oscuro - logo para fondos claros (tema HomeNest)
|
||||
pie - línea del pie (login / documentos impresos)
|
||||
"""
|
||||
defaults = {
|
||||
'nombre': 'SKEEN Derma Experts',
|
||||
'logo_claro': '/skeen-brand/logos/Logo%20Completo%20Blanco.png',
|
||||
'logo_oscuro': '/skeen-brand/logos/Logo%20Completo%20Negro.png',
|
||||
'pie': 'SKEEN Derma Experts · Playas de Rosarito, B.C.',
|
||||
}
|
||||
raw = request.env['ir.config_parameter'].sudo().get_param(self.BRAND_KEY)
|
||||
if raw:
|
||||
try:
|
||||
guardado = json.loads(raw)
|
||||
if isinstance(guardado, dict):
|
||||
defaults.update({k: str(v) for k, v in guardado.items()
|
||||
if k in defaults and v is not None and str(v).strip()})
|
||||
except Exception:
|
||||
pass # JSON roto: se sirven los defaults
|
||||
return defaults
|
||||
|
||||
@http.route('/skeen/frontend/v1/brand', type='http',
|
||||
auth='none', methods=['GET', 'OPTIONS'], csrf=False)
|
||||
def get_brand(self, **kw):
|
||||
"""Brand de la clínica. PÚBLICO: el login (sin sesión) lo necesita
|
||||
para pintar nombre/logo de la clínica antes de autenticar. Solo expone
|
||||
nombre y URLs de logos, nada sensible."""
|
||||
try:
|
||||
return json_response({'status': 'success', 'brand': self._brand_dict()})
|
||||
except Exception as e:
|
||||
return json_response({'status': 'error', 'message': str(e)}, 500)
|
||||
|
||||
@http.route('/skeen/frontend/v1/brand', type='http',
|
||||
auth='none', methods=['PUT', 'OPTIONS'], csrf=False)
|
||||
@require_role('admin')
|
||||
def put_brand(self, **kw):
|
||||
"""Guarda el brand. Solo admin del sistema; queda en la bitácora."""
|
||||
try:
|
||||
data = _parse_json_body()
|
||||
brand = data.get('brand')
|
||||
if not isinstance(brand, dict):
|
||||
return json_response({'status': 'error',
|
||||
'message': 'brand debe ser un objeto'}, 400)
|
||||
antes = self._brand_dict()
|
||||
limpio = {k: str(brand[k]).strip() for k in
|
||||
('nombre', 'logo_claro', 'logo_oscuro', 'pie')
|
||||
if brand.get(k) is not None and str(brand.get(k)).strip()}
|
||||
request.env['ir.config_parameter'].sudo().set_param(
|
||||
self.BRAND_KEY, json.dumps(limpio, ensure_ascii=False))
|
||||
nuevo = self._brand_dict()
|
||||
self._auditar('clinica', 'brand', 'brand',
|
||||
old_value=json.dumps(antes, ensure_ascii=False)[:500],
|
||||
new_value=json.dumps(nuevo, ensure_ascii=False)[:500],
|
||||
entity_label='Identidad de la clínica (brand)',
|
||||
notes=(data.get('motivo') or 'actualizado').strip())
|
||||
return json_response({'status': 'success', 'brand': nuevo,
|
||||
'message': 'Identidad de la clínica actualizada'})
|
||||
except Exception as e:
|
||||
request.env.cr.rollback()
|
||||
return json_response({'status': 'error', 'message': str(e)}, 500)
|
||||
|
||||
# ============================================================
|
||||
# Diseño de tickets (Configuración › Tickets)
|
||||
# ============================================================
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
Cámbiala en cuanto se tenga pantalla de usuarios. -->
|
||||
<record id="seed_admin" model="skeen.frontend.user">
|
||||
<field name="login">admin</field>
|
||||
<field name="name">Administrador SKEEN</field>
|
||||
<field name="name">Administrador</field>
|
||||
<field name="password_hash">scrypt:32768:8:1$qUN8LJrWj2F6a5Fe$699e6d72d42004abb0fb401843c2ccb497eccc39600ea2e7160eda276148fbc246c3a1e777eb3d44161cfe1cd86bc490e35c062cabf8c3d753cd170c478ae06a</field>
|
||||
<field name="role">admin</field>
|
||||
<field name="active" eval="True"/>
|
||||
|
||||
Reference in New Issue
Block a user