- 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
618 lines
25 KiB
TypeScript
618 lines
25 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 } 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 { Card, Button, Input, PageHeader, toast } from '../components/ui';
|
|
import { odooApi, type Doctor, BRAND_DEFAULT } from '../services/odoo';
|
|
import { useAuth } from '../lib/auth';
|
|
|
|
const TABS = [
|
|
{ key: 'clinica', label: 'Clínica' },
|
|
{ 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;
|
|
|
|
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';
|
|
|
|
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.
|
|
const esAdmin = user?.role === 'admin';
|
|
const tabsVisibles = useMemo(
|
|
() => (esAdmin
|
|
? TABS
|
|
: TABS.filter((t) => t.key === 'usuarios' || t.key === 'roles' || t.key === 'tickets')),
|
|
[esAdmin],
|
|
);
|
|
const [searchParams, setSearchParams] = useSearchParams();
|
|
const tabPedida = searchParams.get('tab') || 'clinica';
|
|
const tab = tabsVisibles.some((t) => t.key === tabPedida) ? tabPedida : tabsVisibles[0].key;
|
|
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>(() => ({
|
|
...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(() => {
|
|
try {
|
|
const saved = localStorage.getItem(STORAGE_KEY);
|
|
if (saved) setSettings(JSON.parse(saved));
|
|
} catch {
|
|
// ignore
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}, []);
|
|
|
|
const save = () => {
|
|
try {
|
|
localStorage.setItem(STORAGE_KEY, JSON.stringify(settings));
|
|
toast.success('Configuración guardada');
|
|
} catch {
|
|
toast.error('Error al guardar configuración');
|
|
}
|
|
};
|
|
|
|
const update = (field: keyof ClinicSettings, value: string) => {
|
|
setSettings((prev) => ({ ...prev, [field]: value }));
|
|
};
|
|
|
|
// ---- 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: '',
|
|
});
|
|
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;
|
|
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>
|
|
|
|
{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>
|
|
) : (
|
|
<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.address)}`}
|
|
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.address}</p>
|
|
</div>
|
|
</a>
|
|
|
|
<a
|
|
href={`tel:${settings.phone}`}
|
|
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.phone}</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.website}
|
|
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.website.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.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)}
|
|
/>
|
|
<Input
|
|
label="Dirección"
|
|
value={settings.address}
|
|
onChange={(e) => update('address', e.target.value)}
|
|
/>
|
|
<Input
|
|
label="Sitio web"
|
|
value={settings.website}
|
|
onChange={(e) => update('website', e.target.value)}
|
|
/>
|
|
<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}
|
|
/>
|
|
</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;
|