AgendaPro v1.0 - multi-tenant SaaS, mobile calendar, public booking
Multi-tenant scheduling SaaS (AgendaPro-equivalent): - Backend: Node + Express + node:sqlite, multi-tenant (admin/owner/employee), versioned migrations, 4 templates (estetica-spa, barberia, clinica, blank). - Frontend: React 18 + TS + Vite + Tailwind, FullCalendar drag-and-drop, Recharts dashboard, mobile-first (day/list views on mobile). - Features: calendar+services+employees+clients+tickets, dashboard with best employee/service, top tickets, top/frequent clients, commissions, cancellation policy + no-show tracking, public online booking (/b/:slug), cash register (cierre de caja), reminders/notifications center. - Verification: 65/65 e2e API, 58/58 visual (0 overflow, 0 console errors), typecheck clean, vite build OK.
This commit is contained in:
@@ -0,0 +1,313 @@
|
||||
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";
|
||||
|
||||
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<Employee | null>(null);
|
||||
const [creating, setCreating] = useState(false);
|
||||
|
||||
const removeMut = useMutation({
|
||||
mutationFn: (id: number) => api.employees.remove(id),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ["employees"] }),
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
<PageHeader
|
||||
title="Empleados"
|
||||
subtitle="Gestiona a tu equipo, sus servicios y mide su rendimiento."
|
||||
actions={
|
||||
<button
|
||||
onClick={() => {
|
||||
setEditing(null);
|
||||
setCreating(true);
|
||||
}}
|
||||
className="btn btn-primary"
|
||||
>
|
||||
<Plus className="h-4 w-4" /> Nuevo empleado
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="flex-1 px-5 py-5 sm:px-7">
|
||||
{isLoading ? (
|
||||
<div className="flex h-40 items-center justify-center"><Spinner /></div>
|
||||
) : !data?.employees.length ? (
|
||||
<EmptyState title="Sin empleados" description="Agrega a tu primer especialista." />
|
||||
) : (
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-3">
|
||||
{data.employees.map((e) => (
|
||||
<div key={e.id} className="card group p-5">
|
||||
<div className="flex items-start gap-3">
|
||||
<Avatar name={e.name} color={e.color} size={48} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="truncate font-bold text-slate-900">{e.name}</h3>
|
||||
{!e.active && (
|
||||
<span className="chip bg-slate-100 text-slate-500">Inactivo</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-slate-500">{e.role}</p>
|
||||
<div className="mt-1 flex flex-wrap gap-2 text-[11px] text-slate-500">
|
||||
{e.email && (
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<Mail className="h-3 w-3" /> {e.email}
|
||||
</span>
|
||||
)}
|
||||
{e.phone && (
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<Phone className="h-3 w-3" /> {e.phone}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1 opacity-0 transition-opacity group-hover:opacity-100">
|
||||
<button
|
||||
onClick={() => {
|
||||
setEditing(e);
|
||||
setCreating(false);
|
||||
}}
|
||||
className="rounded-lg p-1.5 text-slate-400 hover:bg-slate-100 hover:text-brand-600"
|
||||
>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
if (confirm(`¿Eliminar a ${e.name}?`)) removeMut.mutate(e.id);
|
||||
}}
|
||||
className="rounded-lg p-1.5 text-slate-400 hover:bg-rose-50 hover:text-rose-600"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 grid grid-cols-3 gap-2 text-center">
|
||||
<Stat icon={DollarSign} color="#10b981" label="Ingresos" value={formatCurrency(e.stats?.revenue_total ?? 0)} />
|
||||
<Stat icon={CalendarCheck} color="#3b66ff" label="Citas" value={String(e.stats?.appointments_completed ?? 0)} />
|
||||
<Stat icon={Star} color="#f59e0b" label="Rating" value={`⭐ ${e.stats?.avg_rating ?? e.rating}`} />
|
||||
</div>
|
||||
|
||||
{e.service_ids && e.service_ids.length > 0 && (
|
||||
<div className="mt-3 flex flex-wrap gap-1">
|
||||
{(servicesData?.services ?? [])
|
||||
.filter((s) => e.service_ids!.includes(s.id))
|
||||
.slice(0, 4)
|
||||
.map((s) => (
|
||||
<span
|
||||
key={s.id}
|
||||
className="chip"
|
||||
style={{ background: `${s.color}1a`, color: s.color }}
|
||||
>
|
||||
{s.name}
|
||||
</span>
|
||||
))}
|
||||
{e.service_ids.length > 4 && (
|
||||
<span className="chip bg-slate-100 text-slate-500">+{e.service_ids.length - 4}</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="mt-3 flex items-center gap-2 text-[11px] text-slate-400">
|
||||
<Activity className="h-3 w-3" />
|
||||
Utilización {e.stats?.utilization_pct ?? 0}%
|
||||
<div className="ml-1 h-1.5 flex-1 overflow-hidden rounded-full bg-slate-100">
|
||||
<div className="h-full rounded-full bg-brand-500" style={{ width: `${e.stats?.utilization_pct ?? 0}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<EmployeeModal
|
||||
open={creating || !!editing}
|
||||
employee={editing}
|
||||
services={servicesData?.services ?? []}
|
||||
onClose={() => {
|
||||
setEditing(null);
|
||||
setCreating(false);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Stat({
|
||||
icon: Icon,
|
||||
color,
|
||||
label,
|
||||
value,
|
||||
}: {
|
||||
icon: React.ComponentType<{ className?: string; style?: React.CSSProperties }>;
|
||||
color: string;
|
||||
label: string;
|
||||
value: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="rounded-xl bg-slate-50 px-2 py-2">
|
||||
<Icon className="mx-auto h-3.5 w-3.5" style={{ color }} />
|
||||
<div className="mt-1 truncate text-sm font-bold text-slate-800">{value}</div>
|
||||
<div className="text-[10px] font-medium uppercase tracking-wide text-slate-400">{label}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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<number[]>([]);
|
||||
|
||||
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 ?? []);
|
||||
} else {
|
||||
setName("");
|
||||
setRole("Especialista");
|
||||
setEmail("");
|
||||
setPhone("");
|
||||
setColor(COLORS[Math.floor(Math.random() * COLORS.length)]);
|
||||
setActive(true);
|
||||
setSvcIds([]);
|
||||
}
|
||||
}, [open, employee]);
|
||||
|
||||
const mut = useMutation({
|
||||
mutationFn: async () => {
|
||||
const payload = { name, role, email, phone, color, active, service_ids: svcIds };
|
||||
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 (
|
||||
<Modal
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
title={isEdit ? "Editar empleado" : "Nuevo empleado"}
|
||||
footer={
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<button onClick={onClose} className="btn btn-ghost">Cancelar</button>
|
||||
<button onClick={() => mut.mutate()} className="btn btn-primary" disabled={!name.trim() || mut.isPending}>
|
||||
{mut.isPending ? <Spinner /> : isEdit ? "Guardar" : "Crear"}
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<Avatar name={name || "?"} color={color} size={56} />
|
||||
<div className="flex-1">
|
||||
<label className="label">Color</label>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{COLORS.map((c) => (
|
||||
<button
|
||||
key={c}
|
||||
type="button"
|
||||
onClick={() => setColor(c)}
|
||||
className={cn(
|
||||
"h-7 w-7 rounded-full transition-transform",
|
||||
color === c && "ring-2 ring-offset-2 ring-slate-400 scale-110"
|
||||
)}
|
||||
style={{ background: c }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<div>
|
||||
<label className="label">Nombre *</label>
|
||||
<input className="input" value={name} onChange={(e) => setName(e.target.value)} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="label">Puesto</label>
|
||||
<input className="input" value={role} onChange={(e) => setRole(e.target.value)} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="label">Correo</label>
|
||||
<input className="input" value={email} onChange={(e) => setEmail(e.target.value)} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="label">Teléfono</label>
|
||||
<input className="input" value={phone} onChange={(e) => setPhone(e.target.value)} />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="label">Servicios que ofrece</label>
|
||||
<div className="flex max-h-44 flex-wrap gap-1.5 overflow-y-auto rounded-xl border border-slate-100 p-2">
|
||||
{services.map((s) => {
|
||||
const on = svcIds.includes(s.id);
|
||||
return (
|
||||
<button
|
||||
key={s.id}
|
||||
type="button"
|
||||
onClick={() => setSvcIds((arr) => (on ? arr.filter((x) => x !== s.id) : [...arr, s.id]))}
|
||||
className={cn(
|
||||
"chip transition-colors",
|
||||
on ? "text-white" : "bg-slate-100 text-slate-600 hover:bg-slate-200"
|
||||
)}
|
||||
style={on ? { background: s.color } : undefined}
|
||||
>
|
||||
{s.name}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<label className="flex items-center gap-2 text-sm font-medium text-slate-700">
|
||||
<input type="checkbox" checked={active} onChange={(e) => setActive(e.target.checked)} />
|
||||
Activo (puede recibir citas)
|
||||
</label>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user