Staff: campo tipo Doctor/Cosmetologa, filtro por tipo en agenda y selector editable en modulo Medicos
This commit is contained in:
@@ -58,6 +58,7 @@ const Agenda: FC = () => {
|
|||||||
const [date, setDate] = useState<string>(() => searchParams.get('date') || new Date().toISOString().split('T')[0]);
|
const [date, setDate] = useState<string>(() => searchParams.get('date') || new Date().toISOString().split('T')[0]);
|
||||||
const [stateFilter, setStateFilter] = useState('');
|
const [stateFilter, setStateFilter] = useState('');
|
||||||
const [doctorFilter, setDoctorFilter] = useState(() => (medicoEmployeeId ? String(medicoEmployeeId) : ''));
|
const [doctorFilter, setDoctorFilter] = useState(() => (medicoEmployeeId ? String(medicoEmployeeId) : ''));
|
||||||
|
const [staffTypeFilter, setStaffTypeFilter] = useState<'' | 'doctor' | 'cosmetologa'>('');
|
||||||
const [viewMode, setViewMode] = useState<'lista' | 'doctores' | 'horas'>('doctores');
|
const [viewMode, setViewMode] = useState<'lista' | 'doctores' | 'horas'>('doctores');
|
||||||
const [quickFilter, setQuickFilter] = useState<'' | 'libres' | 'primera' | 'checkin' | 'noshow' | 'porconfirmar'>('');
|
const [quickFilter, setQuickFilter] = useState<'' | 'libres' | 'primera' | 'checkin' | 'noshow' | 'porconfirmar'>('');
|
||||||
const [detailApt, setDetailApt] = useState<Appointment | null>(null);
|
const [detailApt, setDetailApt] = useState<Appointment | null>(null);
|
||||||
@@ -228,9 +229,14 @@ const Agenda: FC = () => {
|
|||||||
(_, i) => GRID_START + i / SLOTS_PER_HOUR
|
(_, i) => GRID_START + i / SLOTS_PER_HOUR
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Filtro por tipo de staff: las pills y las columnas del grid usan esta lista
|
||||||
|
const typedDoctors = doctors.filter(
|
||||||
|
(d) => !staffTypeFilter || (d.staff_type ?? 'doctor') === staffTypeFilter
|
||||||
|
);
|
||||||
|
|
||||||
const gridDoctors = doctorFilter
|
const gridDoctors = doctorFilter
|
||||||
? doctors.filter((d) => String(d.id) === doctorFilter)
|
? typedDoctors.filter((d) => String(d.id) === doctorFilter)
|
||||||
: doctors;
|
: typedDoctors;
|
||||||
const hasUnassigned = !doctorFilter && gridAppointments.some((a) => !a.doctor_id);
|
const hasUnassigned = !doctorFilter && gridAppointments.some((a) => !a.doctor_id);
|
||||||
const gridColumns: { key: string; label: string; subtitle: string; doctorId: number | null }[] = [
|
const gridColumns: { key: string; label: string; subtitle: string; doctorId: number | null }[] = [
|
||||||
...gridDoctors.map((d) => ({ key: `d-${d.id}`, label: d.name, subtitle: d.job_title || '', doctorId: d.id })),
|
...gridDoctors.map((d) => ({ key: `d-${d.id}`, label: d.name, subtitle: d.job_title || '', doctorId: d.id })),
|
||||||
@@ -873,6 +879,27 @@ const Agenda: FC = () => {
|
|||||||
)
|
)
|
||||||
) : (
|
) : (
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
{/* Selector de tipo de staff */}
|
||||||
|
<div className="flex items-center gap-1 mr-2 border-r border-theme-border pr-3">
|
||||||
|
{([
|
||||||
|
{ key: '', label: 'Todos' },
|
||||||
|
{ key: 'doctor', label: 'Doctores' },
|
||||||
|
{ key: 'cosmetologa', label: 'Cosmetólogas' },
|
||||||
|
] as const).map((t) => (
|
||||||
|
<button
|
||||||
|
key={t.key}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setStaffTypeFilter(t.key)}
|
||||||
|
className={`px-2.5 py-1 rounded-full text-[11px] font-medium border transition-all ${
|
||||||
|
staffTypeFilter === t.key
|
||||||
|
? 'bg-theme-accent text-theme-inverse border-theme-accent'
|
||||||
|
: 'bg-theme-bg text-theme-muted border-theme-border hover:border-theme-border-strong'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{t.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setDoctorFilter('')}
|
onClick={() => setDoctorFilter('')}
|
||||||
@@ -884,7 +911,7 @@ const Agenda: FC = () => {
|
|||||||
>
|
>
|
||||||
Todos
|
Todos
|
||||||
</button>
|
</button>
|
||||||
{doctors.map((d) => {
|
{typedDoctors.map((d) => {
|
||||||
const active = doctorFilter === String(d.id);
|
const active = doctorFilter === String(d.id);
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -411,6 +411,7 @@ const Configuracion: FC = () => {
|
|||||||
<thead className="bg-theme-bg">
|
<thead className="bg-theme-bg">
|
||||||
<tr>
|
<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">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">% 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-40">Nuevo %</th>
|
||||||
<th className="text-left p-3 text-xs font-medium text-theme-muted uppercase w-32">Acción</th>
|
<th className="text-left p-3 text-xs font-medium text-theme-muted uppercase w-32">Acción</th>
|
||||||
@@ -420,6 +421,9 @@ const Configuracion: FC = () => {
|
|||||||
{doctors.map((d) => (
|
{doctors.map((d) => (
|
||||||
<tr key={d.id} className="hover:bg-theme-bg">
|
<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 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">
|
<td className="p-3 text-sm text-theme-muted">
|
||||||
{d.commission_pct ? `${d.commission_pct}%` : '0% (usa el global)'}
|
{d.commission_pct ? `${d.commission_pct}%` : '0% (usa el global)'}
|
||||||
</td>
|
</td>
|
||||||
@@ -453,7 +457,7 @@ const Configuracion: FC = () => {
|
|||||||
))}
|
))}
|
||||||
{doctors.length === 0 && (
|
{doctors.length === 0 && (
|
||||||
<tr>
|
<tr>
|
||||||
<td colSpan={4} className="p-4 text-sm text-theme-muted text-center">No hay médicos dados de alta.</td>
|
<td colSpan={5} className="p-4 text-sm text-theme-muted text-center">No hay médicos dados de alta.</td>
|
||||||
</tr>
|
</tr>
|
||||||
)}
|
)}
|
||||||
</tbody>
|
</tbody>
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import type { FC } from 'react';
|
|||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
import { Mail, Phone, Stethoscope, Briefcase, Save, Percent, Eye, CalendarDays, FileText, ShoppingCart, Users, ClipboardList, CreditCard, DollarSign } from 'lucide-react';
|
import { Mail, Phone, Stethoscope, Briefcase, Save, Percent, Eye, CalendarDays, FileText, ShoppingCart, Users, ClipboardList, CreditCard, DollarSign } from 'lucide-react';
|
||||||
import Layout from '../components/Layout';
|
import Layout from '../components/Layout';
|
||||||
import { Card, Badge, EmptyState, PageHeader, Skeleton, MobileCard, Input, Button, Modal, toast } from '../components/ui';
|
import { Card, Badge, EmptyState, PageHeader, Skeleton, MobileCard, Input, Button, Modal, Select, toast } from '../components/ui';
|
||||||
import { odooApi, type Doctor, type DoctorHistory } from '../services/odoo';
|
import { odooApi, type Doctor, type DoctorHistory } from '../services/odoo';
|
||||||
|
|
||||||
const medicalTerms = ['doctor', 'dra', 'médico', 'medico', 'especialista', 'dermatólogo', 'dermatologo', 'skin', 'clínico', 'clinico'];
|
const medicalTerms = ['doctor', 'dra', 'médico', 'medico', 'especialista', 'dermatólogo', 'dermatologo', 'skin', 'clínico', 'clinico'];
|
||||||
@@ -152,6 +152,22 @@ const Medicos: FC = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Tipo de staff (Doctor / Cosmetóloga): guardado in-place al cambiar el selector
|
||||||
|
const [savingTypeId, setSavingTypeId] = useState<number | null>(null);
|
||||||
|
const saveStaffType = async (d: Doctor, staff_type: 'doctor' | 'cosmetologa') => {
|
||||||
|
if ((d.staff_type ?? 'doctor') === staff_type) return;
|
||||||
|
setSavingTypeId(d.id);
|
||||||
|
try {
|
||||||
|
await odooApi.updateDoctor(d.id, { staff_type });
|
||||||
|
setEmployees((prev) => prev.map((e) => (e.id === d.id ? { ...e, staff_type } : e)));
|
||||||
|
toast.success(`${d.name}: tipo actualizado a ${staff_type === 'cosmetologa' ? 'Cosmetóloga' : 'Doctor'}`);
|
||||||
|
} catch {
|
||||||
|
toast.error('No se pudo guardar el tipo');
|
||||||
|
} finally {
|
||||||
|
setSavingTypeId(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Layout title="Médicos" subtitle="Equipo médico">
|
<Layout title="Médicos" subtitle="Equipo médico">
|
||||||
<PageHeader title="Médicos" subtitle="Especialistas de la clínica" />
|
<PageHeader title="Médicos" subtitle="Especialistas de la clínica" />
|
||||||
@@ -172,6 +188,7 @@ const Medicos: FC = () => {
|
|||||||
<tr>
|
<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">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">Puesto</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 hidden md:table-cell">Teléfono</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 hidden lg:table-cell">Email</th>
|
||||||
<th className="text-left p-3 text-xs font-medium text-theme-muted uppercase">% Comisión</th>
|
<th className="text-left p-3 text-xs font-medium text-theme-muted uppercase">% Comisión</th>
|
||||||
@@ -188,6 +205,18 @@ const Medicos: FC = () => {
|
|||||||
{d.job_title || 'Sin puesto'}
|
{d.job_title || 'Sin puesto'}
|
||||||
</Badge>
|
</Badge>
|
||||||
</td>
|
</td>
|
||||||
|
<td className="p-3" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<Select
|
||||||
|
options={[
|
||||||
|
{ value: 'doctor', label: 'Doctor' },
|
||||||
|
{ value: 'cosmetologa', label: 'Cosmetóloga' },
|
||||||
|
]}
|
||||||
|
value={d.staff_type ?? 'doctor'}
|
||||||
|
onChange={(e) => saveStaffType(d, e.target.value as 'doctor' | 'cosmetologa')}
|
||||||
|
disabled={savingTypeId === d.id}
|
||||||
|
className="max-w-[150px]"
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
<td className="p-3 text-sm text-theme-muted hidden md:table-cell">
|
<td className="p-3 text-sm text-theme-muted hidden md:table-cell">
|
||||||
<a href={`tel:${d.work_phone}`} className="hover:text-theme-heading">
|
<a href={`tel:${d.work_phone}`} className="hover:text-theme-heading">
|
||||||
<Phone size={14} className="inline mr-1 text-theme-muted" />
|
<Phone size={14} className="inline mr-1 text-theme-muted" />
|
||||||
|
|||||||
@@ -255,6 +255,7 @@ export interface Doctor {
|
|||||||
work_phone: string;
|
work_phone: string;
|
||||||
work_email: string;
|
work_email: string;
|
||||||
commission_pct: number;
|
commission_pct: number;
|
||||||
|
staff_type?: 'doctor' | 'cosmetologa';
|
||||||
patient_count?: number;
|
patient_count?: number;
|
||||||
recomendaciones_30d?: number;
|
recomendaciones_30d?: number;
|
||||||
ventas_30d?: number;
|
ventas_30d?: number;
|
||||||
@@ -1211,7 +1212,7 @@ export const odooApi = {
|
|||||||
return data;
|
return data;
|
||||||
},
|
},
|
||||||
|
|
||||||
async updateDoctor(id: number, payload: { commission_pct: number }): Promise<{ status: string; doctor: Doctor }> {
|
async updateDoctor(id: number, payload: { commission_pct?: number; staff_type?: 'doctor' | 'cosmetologa' }): Promise<{ status: string; doctor: Doctor }> {
|
||||||
const { data } = await axios.put(`${ODOO_BASE}/skeen/frontend/v1/doctors/${id}`, payload);
|
const { data } = await axios.put(`${ODOO_BASE}/skeen/frontend/v1/doctors/${id}`, payload);
|
||||||
return data;
|
return data;
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -11,3 +11,10 @@ class HrEmployee(models.Model):
|
|||||||
default=0.0,
|
default=0.0,
|
||||||
help='Porcentaje de comisión sobre la venta de artículos recetados por este médico.',
|
help='Porcentaje de comisión sobre la venta de artículos recetados por este médico.',
|
||||||
)
|
)
|
||||||
|
|
||||||
|
staff_type = fields.Selection(
|
||||||
|
[('doctor', 'Doctor'), ('cosmetologa', 'Cosmetóloga')],
|
||||||
|
string='Tipo de Staff',
|
||||||
|
default='doctor',
|
||||||
|
help='Tipo de personal de la agenda: Doctor o Cosmetóloga.',
|
||||||
|
)
|
||||||
|
|||||||
@@ -1909,6 +1909,7 @@ class SkeenFrontendController(http.Controller):
|
|||||||
'work_phone': d.work_phone or '',
|
'work_phone': d.work_phone or '',
|
||||||
'work_email': d.work_email or '',
|
'work_email': d.work_email or '',
|
||||||
'commission_pct': 0.0 if hide_money else (d.commission_pct or 0.0),
|
'commission_pct': 0.0 if hide_money else (d.commission_pct or 0.0),
|
||||||
|
'staff_type': d.staff_type or 'doctor',
|
||||||
'patient_count': counts.get(d.id, 0),
|
'patient_count': counts.get(d.id, 0),
|
||||||
'recomendaciones_30d': recomendaciones.get(d.id, 0),
|
'recomendaciones_30d': recomendaciones.get(d.id, 0),
|
||||||
'ventas_30d': 0 if hide_money else len(stats.get(d.id, {}).get('ventas', set())),
|
'ventas_30d': 0 if hide_money else len(stats.get(d.id, {}).get('ventas', set())),
|
||||||
@@ -1931,6 +1932,10 @@ class SkeenFrontendController(http.Controller):
|
|||||||
vals = {}
|
vals = {}
|
||||||
if 'commission_pct' in data:
|
if 'commission_pct' in data:
|
||||||
vals['commission_pct'] = float(data.get('commission_pct') or 0)
|
vals['commission_pct'] = float(data.get('commission_pct') or 0)
|
||||||
|
if 'staff_type' in data:
|
||||||
|
if data['staff_type'] not in ('doctor', 'cosmetologa'):
|
||||||
|
return json_response({'status': 'error', 'message': "staff_type debe ser 'doctor' o 'cosmetologa'"}, 400)
|
||||||
|
vals['staff_type'] = data['staff_type']
|
||||||
if vals:
|
if vals:
|
||||||
doctor.write(vals)
|
doctor.write(vals)
|
||||||
return json_response({
|
return json_response({
|
||||||
@@ -1939,6 +1944,7 @@ class SkeenFrontendController(http.Controller):
|
|||||||
'id': doctor.id, 'name': doctor.name, 'job_title': doctor.job_title or '',
|
'id': doctor.id, 'name': doctor.name, 'job_title': doctor.job_title or '',
|
||||||
'work_phone': doctor.work_phone or '', 'work_email': doctor.work_email or '',
|
'work_phone': doctor.work_phone or '', 'work_email': doctor.work_email or '',
|
||||||
'commission_pct': doctor.commission_pct or 0.0,
|
'commission_pct': doctor.commission_pct or 0.0,
|
||||||
|
'staff_type': doctor.staff_type or 'doctor',
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|||||||
Reference in New Issue
Block a user