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:
AgendaPro Dev
2026-07-25 13:45:53 -06:00
commit e8d2435cc2
63 changed files with 16539 additions and 0 deletions
+313
View File
@@ -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>
);
}