909 lines
39 KiB
TypeScript
909 lines
39 KiB
TypeScript
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 NotificacionesPanel from '../components/NotificacionesPanel';
|
||
import NotificacionesEnviadasPanel from '../components/NotificacionesEnviadasPanel';
|
||
import FormulariosPanel from '../components/FormulariosPanel';
|
||
import MembresiasPanel from '../components/MembresiasPanel';
|
||
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, NOTIFICACIONES_DEFAULT } from '../services/odoo';
|
||
import { useAuth } from '../lib/auth';
|
||
import { publicarClinicSettings } from '../lib/clinic-settings';
|
||
|
||
const TABS = [
|
||
{ key: 'clinica', label: 'Clínica' },
|
||
{ key: 'reservas', label: 'Reservas en línea' },
|
||
{ 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: '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: 'auditoria', label: 'Auditoría' },
|
||
] as const;
|
||
|
||
// Secciones que ve gerencia (admin del sistema ve todas).
|
||
const TABS_GERENCIA = ['reservas', 'notificaciones', 'notif_enviadas', 'formularios', 'sucursales', 'categorias', 'recursos', 'membresias', 'terminologia', 'usuarios', 'roles', 'tickets'];
|
||
|
||
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();
|
||
// 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',
|
||
recursos: 'recursos.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 || {}) },
|
||
notificaciones: { ...NOTIFICACIONES_DEFAULT, ...(next.notificaciones || {}) },
|
||
});
|
||
})
|
||
.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="Configuración" subtitle="Ajustes de la clínica">
|
||
<PageHeader title="Configuración" subtitle="Información y ajustes de la clínica" />
|
||
|
||
{/* Tabs de secciones */}
|
||
<div className="flex flex-wrap gap-1.5 mb-6">
|
||
{tabsVisibles.map((t) => (
|
||
<button
|
||
key={t.key}
|
||
type="button"
|
||
onClick={() => setTab(t.key)}
|
||
className={`px-4 py-2 text-sm font-medium rounded-full transition ${
|
||
tab === t.key ? 'bg-theme-accent text-theme-inverse' : 'bg-theme-accent-bg text-theme-heading hover:bg-theme-bg'
|
||
}`}
|
||
>
|
||
{t.label}
|
||
</button>
|
||
))}
|
||
</div>
|
||
|
||
{tabsVisibles.length === 0 ? (
|
||
<Card>
|
||
<Card.Body>
|
||
<p className="text-sm text-theme-muted">No tienes permiso para ninguna sección de Configuración.</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'
|
||
}`}
|
||
>
|
||
{s.label}
|
||
</button>
|
||
))}
|
||
</div>
|
||
<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 === '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 === 'terminologia' ? (
|
||
<Card>
|
||
<Card.Body>
|
||
<TerminologiaPanel />
|
||
</Card.Body>
|
||
</Card>
|
||
) : tab === 'reservas' ? (
|
||
<Card>
|
||
<Card.Body>
|
||
<ReservasOnlinePanel 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>
|
||
) : (
|
||
<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">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.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" />
|
||
Ajustes
|
||
</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">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}
|
||
/>
|
||
<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">
|
||
{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" />
|
||
Tipo de cambio USD / MXN
|
||
</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 ? 'Guardando...' : 'Actualizar TC'}
|
||
</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" />
|
||
Comisiones
|
||
</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 ? 'Guardando...' : 'Guardar'}
|
||
</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 ? 'Guardando...' : 'Guardar'}
|
||
</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;
|