import { useEffect, useState } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { Plus, Star, DollarSign, CalendarCheck, Activity, Mail, Phone, Pencil, Trash2, } from "lucide-react"; import { api } from "../lib/api"; import type { Employee } from "../../shared/types"; import { PageHeader, Avatar, Spinner, EmptyState } from "../components/ui"; import { Modal } from "../components/Modal"; import { formatCurrency, cn } from "../lib/format"; import { DEFAULT_WH, DAYS, parseWh } from "../lib/workingHours"; const COLORS = ["#3b66ff", "#f17616", "#10b981", "#a855f7", "#ec4899", "#14b8a6", "#f59e0b", "#6366f1"]; export function EmployeesPage() { const qc = useQueryClient(); const { data, isLoading } = useQuery({ queryKey: ["employees"], queryFn: () => api.employees.list() }); const { data: servicesData } = useQuery({ queryKey: ["services"], queryFn: () => api.services.list() }); const [editing, setEditing] = useState(null); const [creating, setCreating] = useState(false); const removeMut = useMutation({ mutationFn: (id: number) => api.employees.remove(id), onSuccess: () => qc.invalidateQueries({ queryKey: ["employees"] }), }); return (
{ setEditing(null); setCreating(true); }} className="btn btn-primary" > Nuevo empleado } />
{isLoading ? (
) : !data?.employees.length ? ( ) : (
{data.employees.map((e) => (

{e.name}

{!e.active && ( Inactivo )}

{e.role}

{e.email && ( {e.email} )} {e.phone && ( {e.phone} )}
{e.service_ids && e.service_ids.length > 0 && (
{(servicesData?.services ?? []) .filter((s) => e.service_ids!.includes(s.id)) .slice(0, 4) .map((s) => ( {s.name} ))} {e.service_ids.length > 4 && ( +{e.service_ids.length - 4} )}
)}
% de citas {e.stats?.share_pct ?? 0}%
))}
)}
{ setEditing(null); setCreating(false); }} />
); } function Stat({ icon: Icon, color, label, value, }: { icon: React.ComponentType<{ className?: string; style?: React.CSSProperties }>; color: string; label: string; value: string; }) { return (
{value}
{label}
); } function EmployeeModal({ open, employee, services, onClose, }: { open: boolean; employee: Employee | null; services: { id: number; name: string; color: string; category: string }[]; onClose: () => void; }) { const qc = useQueryClient(); const isEdit = !!employee; const [name, setName] = useState(""); const [role, setRole] = useState("Especialista"); const [email, setEmail] = useState(""); const [phone, setPhone] = useState(""); const [color, setColor] = useState(COLORS[0]); const [active, setActive] = useState(true); const [svcIds, setSvcIds] = useState([]); const [specialties, setSpecialties] = useState([]); const [specialtyInput, setSpecialtyInput] = useState(""); const [efficiency, setEfficiency] = useState(50); const [inheritHours, setInheritHours] = useState(true); const [wh, setWh] = useState>({ ...DEFAULT_WH }); useEffect(() => { if (!open) return; if (employee) { setName(employee.name); setRole(employee.role); setEmail(employee.email ?? ""); setPhone(employee.phone ?? ""); setColor(employee.color); setActive(!!employee.active); setSvcIds(employee.service_ids ?? []); setSpecialties(Array.isArray(employee.specialties) ? employee.specialties : []); setEfficiency(typeof employee.efficiency_score === "number" ? employee.efficiency_score : 50); const parsed = parseWh(employee.working_hours); const hasOwn = !!employee.working_hours && Object.values(parsed).some((v) => v !== null); setInheritHours(!hasOwn); setWh(hasOwn ? parsed : { ...DEFAULT_WH }); } else { setName(""); setRole("Especialista"); setEmail(""); setPhone(""); setColor(COLORS[Math.floor(Math.random() * COLORS.length)]); setActive(true); setSvcIds([]); setSpecialties([]); setSpecialtyInput(""); setEfficiency(50); setInheritHours(true); setWh({ ...DEFAULT_WH }); } }, [open, employee]); const mut = useMutation({ mutationFn: async () => { const payload = { name, role, email, phone, color, active, service_ids: svcIds, specialties, efficiency_score: efficiency, working_hours: inheritHours ? null : wh, }; if (employee) return api.employees.update(employee.id, payload); return api.employees.create(payload); }, onSuccess: () => { qc.invalidateQueries({ queryKey: ["employees"] }); qc.invalidateQueries({ queryKey: ["services"] }); onClose(); }, }); return (
} >
{COLORS.map((c) => (
setName(e.target.value)} />
setRole(e.target.value)} />
setEmail(e.target.value)} />
setPhone(e.target.value)} />
{services.map((s) => { const on = svcIds.includes(s.id); return ( ); })}
{specialties.map((sp) => ( {sp} ))} setSpecialtyInput(e.target.value)} onKeyDown={(e) => { if (e.key === "Enter" && specialtyInput.trim()) { e.preventDefault(); setSpecialties((a) => [...new Set([...a, specialtyInput.trim()])]); setSpecialtyInput(""); } }} />

Etiquetas que el algoritmo compara con la categoría y el nombre del servicio.

setEfficiency(Number(e.target.value))} className="w-full accent-brand-500" />

Qué tan rápido/hábil es. El algoritmo premia valores altos cuando hay empate por disponibilidad.

{!inheritHours && (
{DAYS.map(({ n, label }) => { const on = !!wh[n]; return (
{on ? (
setWh({ ...wh, [n]: { ...(wh[n] as { start: string; end: string }), start: e.target.value } }) } /> a setWh({ ...wh, [n]: { ...(wh[n] as { start: string; end: string }), end: e.target.value } }) } />
) : ( Cerrado )}
); })}
)}
); }