Initial commit: SKEEN Derma Experts - Sistema Integral de Gestión Clínica
- Frontend React (SKEEN Brand) con Vite, TypeScript, Tailwind - Frontend Homenest (versión alternativa) - Módulos Odoo 17 custom (citas, pacientes, monedero, pagos, ventas, inventario, whatsapp) - WACRM fork (Next.js 16 + Supabase) - Hermes + Bridge + Skills (Qwen3.6 via Nan Builders) - Scripts de migración y operación - Documentación extensiva en docs/
This commit is contained in:
170
frontend/src/pages/Medicos.tsx
Normal file
170
frontend/src/pages/Medicos.tsx
Normal file
@@ -0,0 +1,170 @@
|
||||
import type { FC } from 'react';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { Mail, Phone, Stethoscope, Briefcase, Save, Percent } from 'lucide-react';
|
||||
import Layout from '../components/Layout';
|
||||
import { Card, Badge, EmptyState, PageHeader, Skeleton, MobileCard, Input, Button, toast } from '../components/ui';
|
||||
import { odooApi, type Doctor } from '../services/odoo';
|
||||
|
||||
const medicalTerms = ['doctor', 'dra', 'médico', 'medico', 'especialista', 'dermatólogo', 'dermatologo', 'skin', 'clínico', 'clinico'];
|
||||
|
||||
const isMedicalTitle = (jobTitle?: string) => {
|
||||
if (!jobTitle) return false;
|
||||
const normalized = jobTitle.toLowerCase().normalize('NFD').replace(/[\u0300-\u036f]/g, '');
|
||||
return medicalTerms.some((term) => normalized.includes(term));
|
||||
};
|
||||
|
||||
const Medicos: FC = () => {
|
||||
const [employees, setEmployees] = useState<Doctor[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const load = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const res = await odooApi.getDoctors();
|
||||
if (res.status === 'success') setEmployees(res.doctors);
|
||||
} catch (err) {
|
||||
setError('Error al cargar médicos');
|
||||
toast.error('Error al cargar médicos');
|
||||
console.error(err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
load();
|
||||
}, []);
|
||||
|
||||
const doctors = useMemo(() => {
|
||||
const medical = employees.filter((d) => isMedicalTitle(d.job_title));
|
||||
return medical.length > 0 ? medical : employees;
|
||||
}, [employees]);
|
||||
|
||||
const [pctDraft, setPctDraft] = useState<Record<number, string>>({});
|
||||
const [savingPct, setSavingPct] = useState<number | null>(null);
|
||||
|
||||
const pctValue = (d: Doctor) => (pctDraft[d.id] ?? String(d.commission_pct ?? 0));
|
||||
|
||||
const savePct = async (d: Doctor) => {
|
||||
const raw = pctDraft[d.id];
|
||||
const pct = parseFloat(raw ?? String(d.commission_pct ?? 0));
|
||||
if (Number.isNaN(pct) || pct < 0 || pct > 100) {
|
||||
toast.error('Porcentaje inválido (0-100)');
|
||||
return;
|
||||
}
|
||||
setSavingPct(d.id);
|
||||
try {
|
||||
await odooApi.updateDoctor(d.id, { commission_pct: pct });
|
||||
setEmployees((prev) => prev.map((e) => (e.id === d.id ? { ...e, commission_pct: pct } : e)));
|
||||
setPctDraft((prev) => { const n = { ...prev }; delete n[d.id]; return n; });
|
||||
toast.success(`Comisión de ${d.name}: ${pct}%`);
|
||||
} catch {
|
||||
toast.error('No se pudo guardar la comisión');
|
||||
} finally {
|
||||
setSavingPct(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Layout title="Médicos" subtitle="Equipo médico">
|
||||
<PageHeader title="Médicos" subtitle="Especialistas de la clínica" />
|
||||
|
||||
<Card>
|
||||
<Card.Body>
|
||||
{error && <p className="text-rose-600 text-sm mb-4">{error}</p>}
|
||||
|
||||
{loading ? (
|
||||
<Skeleton count={5} className="h-14 w-full" />
|
||||
) : doctors.length === 0 ? (
|
||||
<EmptyState title="Sin médicos" subtitle="No se encontraron médicos en el equipo." icon={<Stethoscope size={28} />} />
|
||||
) : (
|
||||
<>
|
||||
<div className="hidden sm:block 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">Nombre</th>
|
||||
<th className="text-left p-3 text-xs font-medium text-theme-muted uppercase">Puesto</th>
|
||||
<th className="text-left p-3 text-xs font-medium text-theme-muted uppercase hidden md:table-cell">Teléfono</th>
|
||||
<th className="text-left p-3 text-xs font-medium text-theme-muted uppercase hidden lg:table-cell">Email</th>
|
||||
<th className="text-left p-3 text-xs font-medium text-theme-muted uppercase">% Comisió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">
|
||||
<Badge variant="default" className="inline-flex items-center">
|
||||
<Briefcase size={12} className="mr-1" />
|
||||
{d.job_title || 'Sin puesto'}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="p-3 text-sm text-theme-muted hidden md:table-cell">
|
||||
<a href={`tel:${d.work_phone}`} className="hover:text-theme-heading">
|
||||
<Phone size={14} className="inline mr-1 text-theme-muted" />
|
||||
{d.work_phone || '-'}
|
||||
</a>
|
||||
</td>
|
||||
<td className="p-3 text-sm text-theme-muted hidden lg:table-cell">
|
||||
{d.work_email ? (
|
||||
<a href={`mailto:${d.work_email}`} className="hover:text-theme-heading">
|
||||
<Mail size={14} className="inline mr-1 text-theme-muted" />
|
||||
{d.work_email}
|
||||
</a>
|
||||
) : (
|
||||
'-'
|
||||
)}
|
||||
</td>
|
||||
<td className="p-3">
|
||||
<div className="flex items-center gap-2 max-w-[170px]">
|
||||
<Percent size={14} className="text-theme-muted shrink-0" />
|
||||
<Input
|
||||
type="number"
|
||||
min="0"
|
||||
max="100"
|
||||
step="0.5"
|
||||
value={pctValue(d)}
|
||||
onChange={(e) => setPctDraft((prev) => ({ ...prev, [d.id]: e.target.value }))}
|
||||
className="max-w-[92px]"
|
||||
/>
|
||||
<Button size="sm" variant="outline" loading={savingPct === d.id} onClick={() => savePct(d)} title="Guardar %">
|
||||
<Save size={14} />
|
||||
</Button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div className="sm:hidden space-y-3">
|
||||
{doctors.map((d) => (
|
||||
<MobileCard
|
||||
key={d.id}
|
||||
title={d.name}
|
||||
subtitle={
|
||||
<Badge variant="default" className="inline-flex items-center">
|
||||
<Briefcase size={12} className="mr-1" />
|
||||
{d.job_title || 'Sin puesto'}
|
||||
</Badge>
|
||||
}
|
||||
rows={[
|
||||
{ label: 'Teléfono', value: d.work_phone || '-' },
|
||||
{ label: 'Email', value: d.work_email || '-' },
|
||||
{ label: '% Comisión', value: `${d.commission_pct ?? 0}%` },
|
||||
]}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Card.Body>
|
||||
</Card>
|
||||
</Layout>
|
||||
);
|
||||
};
|
||||
|
||||
export default Medicos;
|
||||
Reference in New Issue
Block a user