fase 1 productizacion: capa de configuracion (glosario multi-giro, sucursales y categorias configurables, ajustes de clinica por instancia, colores de marca, horarios, URLs de pago dinamicas) + script de provisioning de instancia nueva con datos demo + frontend consumiendo la config

This commit is contained in:
2026-10-11 05:50:18 +00:00
parent 28118ce732
commit f92018f88e
50 changed files with 4314 additions and 210 deletions

View File

@@ -1,7 +1,7 @@
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 { Phone, MapPin, Mail, Globe, Save, Building2, ArrowRightLeft, Percent, Check, Calculator, Palette } from 'lucide-react';
import Layout from '../components/Layout';
import UsuariosPanel from '../components/UsuariosPanel';
import RolesPanel from '../components/RolesPanel';
@@ -10,12 +10,18 @@ import CatalogoPanel from '../components/CatalogoPanel';
import AuditoriaPanel from '../components/AuditoriaPanel';
import TicketDesigner from '../components/TicketDesigner';
import ImpactoModal from '../components/ImpactoModal';
import SucursalesPanel from '../components/SucursalesPanel';
import CategoriasPanel from '../components/CategoriasPanel';
import TerminologiaPanel from '../components/TerminologiaPanel';
import { Card, Button, Input, PageHeader, toast } from '../components/ui';
import { odooApi, type Doctor, BRAND_DEFAULT } from '../services/odoo';
import { odooApi, type Doctor, type ClinicSettings, BRAND_DEFAULT, CLINIC_SETTINGS_DEFAULT } from '../services/odoo';
import { useAuth } from '../lib/auth';
const TABS = [
{ key: 'clinica', label: 'Clínica' },
{ key: 'sucursales', label: 'Sucursales' },
{ key: 'categorias', label: 'Categorías' },
{ key: 'terminologia', label: 'Terminología' },
{ key: 'usuarios', label: 'Usuarios' },
{ key: 'roles', label: 'Roles' },
{ key: 'recetas', label: 'Recetas' },
@@ -24,38 +30,25 @@ const TABS = [
{ key: 'auditoria', label: 'Auditoría' },
] as const;
// Secciones que ve gerencia (admin del sistema ve todas).
const TABS_GERENCIA = ['sucursales', 'categorias', 'terminologia', 'usuarios', 'roles', 'tickets'];
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';
// Clave histórica del navegador: antes los ajustes de la clínica vivían solo en
// localStorage. Se migran al backend una vez y deja de usarse.
const LEGACY_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.
// Gerencia gestiona catálogos, usuarios y roles; la sección "Clínica" (datos
// de contacto, 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 ? TABS : TABS.filter((t) => TABS_GERENCIA.includes(t.key))),
[esAdmin],
);
const [searchParams, setSearchParams] = useSearchParams();
@@ -66,31 +59,60 @@ const Configuracion: FC = () => {
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 [settings, setSettings] = useState<ClinicSettings>(CLINIC_SETTINGS_DEFAULT);
const [loading, setLoading] = useState(true);
// Ajustes de la clínica desde el backend. Migración de una sola vez: si este
// navegador tiene los datos viejos en localStorage y el backend sigue con los
// defaults (contacto vacío), se precargan y se suben; después se deja de usar
// localStorage como fuente de verdad.
useEffect(() => {
try {
const saved = localStorage.getItem(STORAGE_KEY);
if (saved) setSettings(JSON.parse(saved));
} catch {
// ignore
} finally {
setLoading(false);
}
let alive = true;
odooApi.getClinicSettings()
.then(async (res) => {
if (!alive || res.status !== 'success') return;
let next = res.settings;
try {
const raw = localStorage.getItem(LEGACY_STORAGE_KEY);
if (raw) {
const legacy = JSON.parse(raw) as Partial<{ name: string; phone: string; email: string; address: string; website: string }>;
const backendVacio = !next.telefono && !next.email && !next.direccion && !next.web;
const legacyTieneDatos = Boolean(legacy?.phone || legacy?.email || legacy?.address || legacy?.website);
if (backendVacio && legacyTieneDatos) {
next = {
...next,
telefono: legacy?.phone || next.telefono,
email: legacy?.email || next.email,
direccion: legacy?.address || next.direccion,
web: legacy?.website || next.web,
};
const up = await odooApi.putClinicSettings({ ...next, motivo: 'Migración desde localStorage' });
if (up.status === 'success') next = up.settings;
}
localStorage.removeItem(LEGACY_STORAGE_KEY);
}
} catch {
localStorage.removeItem(LEGACY_STORAGE_KEY);
}
if (alive) setSettings(next);
})
.catch(() => {
toast.error('No se pudieron cargar los ajustes de la clínica');
})
.finally(() => {
if (alive) setLoading(false);
});
return () => { alive = false; };
}, []);
const save = () => {
const save = async () => {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(settings));
const res = await odooApi.putClinicSettings({ ...settings, motivo: 'Ajustes desde Configuración' });
if (res.status === 'success') setSettings(res.settings);
toast.success('Configuración guardada');
} catch {
toast.error('Error al guardar configuración');
} catch (err) {
const msg = (err as { response?: { data?: { message?: string } } })?.response?.data?.message;
toast.error(msg || 'Error al guardar configuración');
}
};
@@ -104,7 +126,7 @@ const Configuracion: FC = () => {
const [tcSaving, setTcSaving] = useState(false);
// ---- Identidad de la clínica (brand white-label) ----
const [brandForm, setBrandForm] = useState({
nombre: '', pie: '', logo_claro: '', logo_oscuro: '',
nombre: '', pie: '', logo_claro: '', logo_oscuro: '', url_publica: '', primario: '', acento: '',
});
const [brandSaving, setBrandSaving] = useState(false);
const brandCargadoRef = useRef(false);
@@ -116,6 +138,9 @@ const Configuracion: FC = () => {
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,
url_publica: brand.url_publica || '',
primario: brand.colores?.primario || '',
acento: brand.colores?.acento || '',
});
}, [brand]);
@@ -128,13 +153,16 @@ const Configuracion: FC = () => {
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,
url_publica: brandForm.url_publica.trim(),
colores: { primario: brandForm.primario.trim(), acento: brandForm.acento.trim() },
},
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');
} catch (err) {
const msg = (err as { response?: { data?: { message?: string } } })?.response?.data?.message;
toast.error(msg || 'No se pudo guardar la identidad de la clínica');
} finally {
setBrandSaving(false);
}
@@ -298,6 +326,24 @@ const Configuracion: FC = () => {
<CatalogoPanel kind={subcatalogo as 'diagnosticos' | 'procedimientos'} titulo={subcatalogo === 'diagnosticos' ? 'Diagnósticos' : 'Procedimientos'} />
</Card.Body>
</Card>
) : tab === 'sucursales' ? (
<Card>
<Card.Body>
<SucursalesPanel />
</Card.Body>
</Card>
) : tab === 'categorias' ? (
<Card>
<Card.Body>
<CategoriasPanel />
</Card.Body>
</Card>
) : tab === 'terminologia' ? (
<Card>
<Card.Body>
<TerminologiaPanel />
</Card.Body>
</Card>
) : (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4 sm:gap-6">
<Card>
@@ -305,7 +351,7 @@ const Configuracion: FC = () => {
<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)}`}
href={`https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(settings.direccion)}`}
target="_blank"
rel="noreferrer"
className="p-4 bg-theme-bg rounded-xl flex items-start space-x-3 hover:bg-theme-bg transition"
@@ -315,12 +361,12 @@ const Configuracion: FC = () => {
</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>
<p className="text-sm text-theme-muted truncate">{settings.direccion}</p>
</div>
</a>
<a
href={`tel:${settings.phone}`}
href={`tel:${settings.telefono}`}
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">
@@ -328,7 +374,7 @@ const Configuracion: FC = () => {
</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>
<p className="text-sm text-theme-muted truncate">{settings.telefono}</p>
</div>
</a>
@@ -346,7 +392,7 @@ const Configuracion: FC = () => {
</a>
<a
href={settings.website}
href={settings.web}
target="_blank"
rel="noreferrer"
className="p-4 bg-theme-bg rounded-xl flex items-start space-x-3 hover:bg-theme-bg transition"
@@ -356,7 +402,7 @@ const Configuracion: FC = () => {
</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>
<p className="text-sm text-theme-muted truncate">{(settings.web || '').replace(/^https?:\/\//, '')}</p>
</div>
</a>
</div>
@@ -375,30 +421,78 @@ const Configuracion: FC = () => {
<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)}
value={settings.nombre}
onChange={(e) => update('nombre', e.target.value)}
/>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<Input
label="Teléfono"
value={settings.telefono}
onChange={(e) => update('telefono', e.target.value)}
/>
<Input
label="RFC"
value={settings.rfc}
onChange={(e) => update('rfc', e.target.value)}
placeholder="Opcional"
/>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<Input
label="Email"
type="email"
value={settings.email}
onChange={(e) => update('email', e.target.value)}
/>
<Input
label="Sitio web"
value={settings.web}
onChange={(e) => update('web', e.target.value)}
placeholder="https://..."
/>
</div>
<Input
label="Dirección"
value={settings.address}
onChange={(e) => update('address', e.target.value)}
value={settings.direccion}
onChange={(e) => update('direccion', e.target.value)}
/>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<Input
label="Horario de apertura"
type="time"
value={settings.horario_apertura}
onChange={(e) => update('horario_apertura', e.target.value)}
/>
<Input
label="Horario de cierre"
type="time"
value={settings.horario_cierre}
onChange={(e) => update('horario_cierre', e.target.value)}
/>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<Input
label="Zona horaria"
value={settings.zona_horaria}
onChange={(e) => update('zona_horaria', e.target.value)}
placeholder="America/Tijuana"
/>
<Input
label="URL pública"
value={settings.url_publica}
onChange={(e) => update('url_publica', e.target.value)}
placeholder="https://..."
/>
</div>
<Input
label="Sitio web"
value={settings.website}
onChange={(e) => update('website', e.target.value)}
label="Nota del ticket"
value={settings.nota_ticket}
onChange={(e) => update('nota_ticket', e.target.value)}
placeholder="Se imprime al pie del ticket"
/>
<p className="text-xs text-theme-muted">
Moneda base: <span className="font-medium text-theme-heading">{settings.moneda_base || 'MXN'}</span>
</p>
<div className="pt-2">
<Button onClick={save}>
<Save size={16} className="mr-2" />
@@ -445,7 +539,53 @@ const Configuracion: FC = () => {
onChange={(e) => setBrandForm((f) => ({ ...f, logo_oscuro: e.target.value }))}
placeholder={BRAND_DEFAULT.logo_oscuro}
/>
<Input
label="URL pública de la instancia"
value={brandForm.url_publica}
onChange={(e) => setBrandForm((f) => ({ ...f, url_publica: e.target.value }))}
placeholder="https://..."
/>
</div>
{/* Colores de marca: vacíos = se conserva el color del tema activo. */}
<div className="mt-5">
<p className="text-sm font-medium text-theme-heading mb-1 flex items-center">
<Palette size={16} className="mr-2" /> Colores de marca
</p>
<p className="text-xs text-theme-muted mb-3">
Si se definen, pisan el color del tema en los tres temas. Dejarlos vacíos conserva el color original.
</p>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
{([
{ key: 'primario' as const, label: 'Color primario' },
{ key: 'acento' as const, label: 'Color de acento' },
]).map(({ key, label }) => (
<div key={key}>
<label className="block text-xs font-medium text-theme-muted mb-1.5">{label}</label>
<div className="flex items-center gap-2">
<input
type="color"
value={brandForm[key] || (key === 'primario' ? '#eda588' : '#c7bcdd')}
onChange={(e) => setBrandForm((f) => ({ ...f, [key]: e.target.value }))}
className="h-10 w-12 shrink-0 rounded-lg border border-theme-border-strong bg-theme-surface cursor-pointer"
aria-label={label}
/>
<Input
value={brandForm[key]}
onChange={(e) => setBrandForm((f) => ({ ...f, [key]: e.target.value }))}
placeholder="Vacío = color del tema"
/>
{brandForm[key] && (
<Button variant="ghost" size="sm" onClick={() => setBrandForm((f) => ({ ...f, [key]: '' }))} title="Usar el color del tema">
Limpiar
</Button>
)}
</div>
</div>
))}
</div>
</div>
<div className="flex items-center gap-3 mt-4">
{(brandForm.logo_claro || brandForm.logo_oscuro) && (
<div className="flex items-center gap-2">