Files
SKEEN-Proyecto/frontend/src/pages/Configuracion.tsx

1011 lines
43 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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, Palette } 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 SucursalesPanel from '../components/SucursalesPanel';
import CategoriasPanel from '../components/CategoriasPanel';
import RecursosPanel from '../components/RecursosPanel';
import TerminologiaPanel from '../components/TerminologiaPanel';
import ReservasOnlinePanel from '../components/ReservasOnlinePanel';
import PortalClientePanel from '../components/PortalClientePanel';
import NotificacionesPanel from '../components/NotificacionesPanel';
import NotificacionesEnviadasPanel from '../components/NotificacionesEnviadasPanel';
import FormulariosPanel from '../components/FormulariosPanel';
import MembresiasPanel from '../components/MembresiasPanel';
import ComprasPanel from '../components/ComprasPanel';
import ComisionesPanel from '../components/ComisionesPanel';
import LealtadPanel from '../components/LealtadPanel';
import PropinasPanel from '../components/PropinasPanel';
import CampanasPanel from '../components/CampanasPanel';
import EncuestasPanel from '../components/EncuestasPanel';
import LicenciaPanel from '../components/LicenciaPanel';
import ApiKeysPanel from '../components/ApiKeysPanel';
import WebhooksPanel from '../components/WebhooksPanel';
import { Card, Button, Input, PageHeader, toast } from '../components/ui';
import { odooApi, type Doctor, type ClinicSettings, type ImpuestosSettings, BRAND_DEFAULT, CLINIC_SETTINGS_DEFAULT, IMPUESTOS_DEFAULT, RESERVAS_ONLINE_DEFAULT, ANTICIPO_DEFAULT, MEMBRESIAS_DEFAULT, COBRO_AUTOMATICO_DEFAULT, NOTIFICACIONES_DEFAULT } from '../services/odoo';
import { useAuth } from '../lib/auth';
import { publicarClinicSettings } from '../lib/clinic-settings';
import { useI18n } from '../lib/i18n';
import { LanguageSelect } from '../components/LanguageSelect';
const TABS = [
{ key: 'clinica', label: 'Clínica' },
{ key: 'reservas', label: 'Reservas en línea' },
{ key: 'portal', label: 'Portal del cliente' },
{ key: 'notificaciones', label: 'Notificaciones' },
{ key: 'notif_enviadas', label: 'Notificaciones enviadas' },
{ key: 'formularios', label: 'Formularios' },
{ key: 'sucursales', label: 'Sucursales' },
{ key: 'categorias', label: 'Categorías' },
{ key: 'recursos', label: 'Recursos' },
{ key: 'membresias', label: 'Membresías' },
{ key: 'compras', label: 'Compras' },
{ key: 'comisiones', label: 'Comisiones' },
{ key: 'lealtad', label: 'Lealtad' },
{ key: 'propinas', label: 'Propinas' },
{ key: 'campanas', label: 'Campañas' },
{ key: 'encuestas', label: 'Encuestas' },
{ key: 'terminologia', label: 'Terminología' },
{ key: 'usuarios', label: 'Usuarios' },
{ key: 'roles', label: 'Roles' },
{ key: 'recetas', label: 'Recetas' },
{ key: 'catalogos', label: 'Catálogos' },
{ key: 'tickets', label: 'Tickets' },
{ key: 'licencia', label: 'Licencia' },
{ key: 'api', label: 'API' },
{ key: 'webhooks', label: 'Webhooks' },
{ key: 'auditoria', label: 'Auditoría' },
] as const;
// Secciones que ve gerencia (admin del sistema ve todas).
const TABS_GERENCIA = ['reservas', 'portal', 'notificaciones', 'notif_enviadas', 'formularios', 'sucursales', 'categorias', 'recursos', 'membresias', 'compras', 'comisiones', 'lealtad', 'propinas', 'campanas', 'encuestas', 'terminologia', 'usuarios', 'roles', 'tickets', 'licencia', 'webhooks'];
const SUBCATALOGOS = [
{ key: 'diagnosticos', label: 'Diagnósticos' },
{ key: 'procedimientos', label: 'Procedimientos' },
] as const;
// 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, can } = useAuth();
const { t } = useI18n();
// Clave i18n de una pestaña: 'notif_enviadas' -> 'config.tabs.notifEnviadas'.
const tabKey = (k: string) => 'config.tabs.' + k.replace(/_([a-z])/g, (_, c: string) => c.toUpperCase());
// 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';
// Cada pestaña requiere su permiso por acción (Configuración › Roles › Permisos).
const TABS_PERM: Record<string, string> = {
usuarios: 'config.usuarios',
roles: 'config.roles',
auditoria: 'config.auditoria',
notificaciones: 'notificaciones.editar',
notif_enviadas: 'notificaciones.ver',
formularios: 'formularios.ver',
membresias: 'membresias.editar',
compras: 'compras.editar',
comisiones: 'comisiones.editar',
lealtad: 'lealtad.configurar',
propinas: 'propinas.configurar',
campanas: 'campanas.gestionar',
encuestas: 'encuestas.gestionar',
recursos: 'recursos.editar',
licencia: 'config.editar',
api: 'api.claves',
webhooks: 'webhooks.editar',
};
const tabsVisibles = useMemo(
() => (esAdmin ? TABS : TABS.filter((t) => TABS_GERENCIA.includes(t.key)))
.filter((t) => can(TABS_PERM[t.key] || 'config.editar')),
[esAdmin, can],
);
const [searchParams, setSearchParams] = useSearchParams();
const tabPedida = searchParams.get('tab') || 'clinica';
const tab = tabsVisibles.some((t) => t.key === tabPedida) ? tabPedida : (tabsVisibles[0]?.key || 'clinica');
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>(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(() => {
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({
...CLINIC_SETTINGS_DEFAULT,
...next,
impuestos: { ...IMPUESTOS_DEFAULT, ...(next.impuestos || {}) },
reservas_online: {
...RESERVAS_ONLINE_DEFAULT,
...(next.reservas_online || {}),
anticipo: { ...ANTICIPO_DEFAULT, ...((next.reservas_online || {}).anticipo || {}) },
},
notificaciones: { ...NOTIFICACIONES_DEFAULT, ...(next.notificaciones || {}) },
membresias: {
...MEMBRESIAS_DEFAULT,
...(next.membresias || {}),
cobro_automatico: { ...COBRO_AUTOMATICO_DEFAULT, ...((next.membresias || {}).cobro_automatico || {}) },
},
});
})
.catch(() => {
toast.error('No se pudieron cargar los ajustes de la clínica');
})
.finally(() => {
if (alive) setLoading(false);
});
return () => { alive = false; };
}, []);
const save = async () => {
try {
const res = await odooApi.putClinicSettings({ ...settings, motivo: 'Ajustes desde Configuración' });
if (res.status === 'success') {
setSettings(res.settings);
publicarClinicSettings(res.settings);
}
toast.success('Configuración guardada');
} catch (err) {
const msg = (err as { response?: { data?: { message?: string } } })?.response?.data?.message;
toast.error(msg || 'Error al guardar configuración');
}
};
const update = (field: keyof ClinicSettings, value: string) => {
setSettings((prev) => ({ ...prev, [field]: value }));
};
const updateImpuesto = (patch: Partial<ImpuestosSettings>) => {
setSettings((prev) => ({
...prev,
impuestos: { ...IMPUESTOS_DEFAULT, ...prev.impuestos, ...patch },
}));
};
// ---- 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: '', url_publica: '', primario: '', acento: '',
});
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,
url_publica: brand.url_publica || '',
primario: brand.colores?.primario || '',
acento: brand.colores?.acento || '',
});
}, [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,
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 (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);
}
};
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={t('config.title')} subtitle={t('config.subtitle')}>
<PageHeader title={t('config.title')} subtitle={t('config.pageSubtitle')} />
{/* Tabs de secciones */}
<div className="flex flex-wrap gap-1.5 mb-6">
{tabsVisibles.map((tabItem) => (
<button
key={tabItem.key}
type="button"
onClick={() => setTab(tabItem.key)}
className={`px-4 py-2 text-sm font-medium rounded-full transition ${
tab === tabItem.key ? 'bg-theme-accent text-theme-inverse' : 'bg-theme-accent-bg text-theme-heading hover:bg-theme-bg'
}`}
>
{t(tabKey(tabItem.key))}
</button>
))}
</div>
{tabsVisibles.length === 0 ? (
<Card>
<Card.Body>
<p className="text-sm text-theme-muted">{t('config.noPermission')}</p>
</Card.Body>
</Card>
) : 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'
}`}
>
{t(`config.tabs.${s.key}`)}
</button>
))}
</div>
<CatalogoPanel kind={subcatalogo as 'diagnosticos' | 'procedimientos'} titulo={t(`config.tabs.${subcatalogo}`)} />
</Card.Body>
</Card>
) : tab === 'sucursales' ? (
<Card>
<Card.Body>
<SucursalesPanel />
</Card.Body>
</Card>
) : tab === 'categorias' ? (
<Card>
<Card.Body>
<CategoriasPanel />
</Card.Body>
</Card>
) : tab === 'recursos' ? (
<Card>
<Card.Body>
<RecursosPanel settings={settings} onSaved={setSettings} />
</Card.Body>
</Card>
) : tab === 'membresias' ? (
<Card>
<Card.Body>
<MembresiasPanel settings={settings} onSaved={setSettings} />
</Card.Body>
</Card>
) : tab === 'compras' ? (
<Card>
<Card.Body>
<ComprasPanel settings={settings} onSaved={setSettings} />
</Card.Body>
</Card>
) : tab === 'comisiones' ? (
<Card>
<Card.Body>
<ComisionesPanel settings={settings} onSaved={setSettings} />
</Card.Body>
</Card>
) : tab === 'lealtad' ? (
<Card>
<Card.Body>
<LealtadPanel settings={settings} onSaved={setSettings} />
</Card.Body>
</Card>
) : tab === 'propinas' ? (
<Card>
<Card.Body>
<PropinasPanel settings={settings} onSaved={setSettings} />
</Card.Body>
</Card>
) : tab === 'campanas' ? (
<Card>
<Card.Body>
<CampanasPanel settings={settings} onSaved={setSettings} />
</Card.Body>
</Card>
) : tab === 'encuestas' ? (
<Card>
<Card.Body>
<EncuestasPanel settings={settings} onSaved={setSettings} />
</Card.Body>
</Card>
) : tab === 'terminologia' ? (
<Card>
<Card.Body>
<TerminologiaPanel />
</Card.Body>
</Card>
) : tab === 'reservas' ? (
<Card>
<Card.Body>
<ReservasOnlinePanel settings={settings} onSaved={setSettings} />
</Card.Body>
</Card>
) : tab === 'portal' ? (
<Card>
<Card.Body>
<PortalClientePanel settings={settings} onSaved={setSettings} />
</Card.Body>
</Card>
) : tab === 'notificaciones' ? (
<Card>
<Card.Body>
<NotificacionesPanel settings={settings} onSaved={setSettings} />
</Card.Body>
</Card>
) : tab === 'notif_enviadas' ? (
<Card>
<Card.Body>
<NotificacionesEnviadasPanel />
</Card.Body>
</Card>
) : tab === 'formularios' ? (
<Card>
<Card.Body>
<FormulariosPanel />
</Card.Body>
</Card>
) : tab === 'licencia' ? (
<LicenciaPanel />
) : tab === 'api' ? (
<ApiKeysPanel />
) : tab === 'webhooks' ? (
<WebhooksPanel />
) : (
<>
<Card className="mb-4 sm:mb-6">
<Card.Body>
<h3 className="font-heading text-xl text-theme-heading mb-1">{t('config.idiomaTitle')}</h3>
<p className="text-sm text-theme-muted mb-3">{t('config.idiomaBody')}</p>
<div className="max-w-xs">
<LanguageSelect />
</div>
</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">{t('config.clinicInfo')}</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.direccion)}`}
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.direccion}</p>
</div>
</a>
<a
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">
<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.telefono}</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.web}
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.web || '').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" />
{t('config.settings')}
</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.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.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="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>
{/* Impuestos (por instancia). Apagados = comportamiento de
siempre: sin cálculo, sin desglose y sin renglón en tickets. */}
<div className="border border-theme-border rounded-xl p-3 space-y-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Percent size={16} className="text-theme-muted" />
<span className="text-sm font-medium text-theme-heading">Impuestos</span>
</div>
<label className="flex items-center gap-2 text-sm text-theme-muted cursor-pointer">
<input
type="checkbox"
checked={settings.impuestos.activo}
onChange={(e) => updateImpuesto({ activo: e.target.checked })}
className="h-4 w-4 rounded border-theme-border-strong"
/>
Calcular impuestos
</label>
</div>
<p className="text-xs text-theme-muted">
{settings.impuestos.activo
? 'El impuesto se muestra y se cobra en POS, tickets y reportes.'
: 'Apagado: los totales, tickets y reportes salen exactamente como hoy.'}
</p>
{settings.impuestos.activo && (
<>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<Input
label="Nombre del impuesto"
value={settings.impuestos.nombre}
onChange={(e) => updateImpuesto({ nombre: e.target.value })}
placeholder="IVA"
/>
<Input
label="Tasa por defecto (%)"
type="number"
min={0}
max={100}
step={0.01}
value={String(settings.impuestos.tasa_default)}
onChange={(e) => updateImpuesto({ tasa_default: parseFloat(e.target.value) || 0 })}
/>
</div>
<Input
label="Opciones de tasa (%)"
value={settings.impuestos.tasa_opciones.join(', ')}
onChange={(e) => {
const opciones = e.target.value
.split(',')
.map((v) => parseFloat(v.trim()))
.filter((v) => !Number.isNaN(v) && v >= 0 && v <= 100);
updateImpuesto({ tasa_opciones: opciones.length ? opciones : [0] });
}}
placeholder="0, 8, 16"
/>
<label className="flex items-center gap-2 text-sm text-theme-muted cursor-pointer">
<input
type="checkbox"
checked={settings.impuestos.precios_incluyen_impuesto}
onChange={(e) => updateImpuesto({ precios_incluyen_impuesto: e.target.checked })}
className="h-4 w-4 rounded border-theme-border-strong"
/>
Los precios capturados ya incluyen impuesto
</label>
</>
)}
</div>
<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">{t('config.clinicIdentity')}</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}
/>
<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={t('config.useThemeColor')}>
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">
{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" />
{t('config.exchangeRate')}
</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 ? t('common.saving') : t('config.updateRate')}
</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" />
{t('config.commissions')}
</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 ? t('common.saving') : t('common.save')}
</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 ? t('common.saving') : t('common.save')}
</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;