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
+314
View File
@@ -0,0 +1,314 @@
import { useEffect, useMemo, useRef, useState } from "react";
import FullCalendar from "@fullcalendar/react";
import dayGridPlugin from "@fullcalendar/daygrid";
import timeGridPlugin from "@fullcalendar/timegrid";
import interactionPlugin from "@fullcalendar/interaction";
import listPlugin from "@fullcalendar/list";
import type {
DateSelectArg,
EventClickArg,
EventDropArg,
} from "@fullcalendar/core";
import esLocale from "@fullcalendar/core/locales/es";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { CalendarDays, Plus, Filter, RotateCcw } from "lucide-react";
import { api } from "../lib/api";
import { useAuth } from "../lib/auth";
import type { Appointment } from "../../shared/types";
import { PageHeader, Spinner, EmptyState } from "../components/ui";
import { AppointmentModal } from "../components/AppointmentModal";
import { statusColor } from "../lib/format";
const STATUS_FILTERS = [
{ value: "all", label: "Todas" },
{ value: "scheduled", label: "Programadas" },
{ value: "completed", label: "Completadas" },
{ value: "cancelled", label: "Canceladas" },
];
export function CalendarPage() {
const { user } = useAuth();
const qc = useQueryClient();
const calRef = useRef<FullCalendar>(null);
const [modalOpen, setModalOpen] = useState(false);
const [editing, setEditing] = useState<Appointment | null>(null);
const [draftSlot, setDraftSlot] = useState<{ start: string; end: string } | null>(null);
const [employeeFilter, setEmployeeFilter] = useState<number | "all">("all");
const [statusFilter, setStatusFilter] = useState<string>("all");
const [isMobile, setIsMobile] = useState(
typeof window !== "undefined" ? window.matchMedia("(max-width: 640px)").matches : false
);
useEffect(() => {
const mq = window.matchMedia("(max-width: 640px)");
const handler = (e: MediaQueryListEvent) => setIsMobile(e.matches);
mq.addEventListener("change", handler);
return () => mq.removeEventListener("change", handler);
}, []);
const isOwner = user?.role === "owner";
// Employees see only their own by default
const effectiveEmployee = !isOwner && user?.employee_id ? user.employee_id : employeeFilter;
const { data: employeesData } = useQuery({
queryKey: ["employees"],
queryFn: () => api.employees.list(),
enabled: isOwner,
});
const rangeRef = useRef<{ from: string; to: string }>({ from: "", to: "" });
const { data, isLoading, refetch } = useQuery({
queryKey: ["appointments", "calendar", effectiveEmployee, statusFilter],
queryFn: async () => {
const cal = calRef.current?.getApi();
const from = cal?.view.activeStart.toISOString() ?? new Date().toISOString();
const to = cal?.view.activeEnd.toISOString() ?? new Date().toISOString();
rangeRef.current = { from, to };
return api.appointments.list({
from,
to,
employee_id: effectiveEmployee === "all" ? undefined : Number(effectiveEmployee),
status: statusFilter === "all" ? undefined : statusFilter,
});
},
});
const events = useMemo(() => {
return (data?.appointments ?? []).map((a) => {
const empColor = a.employee?.color ?? "#3b66ff";
const bg =
a.status === "cancelled"
? "#f3f4f6"
: a.status === "completed"
? `${empColor}22`
: `${empColor}1a`;
const border = a.status === "cancelled" ? "#d1d5db" : empColor;
const fg = a.status === "cancelled" ? "#6b7280" : a.status === "completed" ? empColor : "#1e293b";
return {
id: String(a.id),
start: a.start_at,
end: a.end_at,
title: `${a.client?.name ?? "Cliente"} · ${a.service?.name ?? ""}`,
backgroundColor: bg,
borderColor: border,
textColor: fg,
extendedProps: { appointment: a },
editable: a.status !== "completed",
};
});
}, [data]);
const moveMutation = useMutation({
mutationFn: async ({ id, start, end }: { id: number; start: string; end: string }) =>
api.appointments.update(id, { start_at: start, end_at: end }),
onSuccess: () => qc.invalidateQueries({ queryKey: ["appointments"] }),
});
const onDrop = (arg: EventDropArg) => {
const id = Number(arg.event.id);
const start = arg.event.start!.toISOString();
const end = (arg.event.end ?? new Date(arg.event.start!.getTime() + 60 * 60000)).toISOString();
moveMutation.mutate({ id, start, end });
};
const onResize = (arg: any) => {
const id = Number(arg.event.id);
moveMutation.mutate({
id,
start: arg.event.start!.toISOString(),
end: arg.event.end!.toISOString(),
});
};
const onSelectSlot = (arg: DateSelectArg) => {
setEditing(null);
setDraftSlot({ start: arg.start.toISOString(), end: arg.end.toISOString() });
setModalOpen(true);
};
const onClickEvent = (arg: EventClickArg) => {
arg.jsEvent.preventDefault();
const a = arg.event.extendedProps.appointment as Appointment;
setEditing(a);
setDraftSlot(null);
setModalOpen(true);
};
const onToday = () => calRef.current?.getApi().today();
return (
<div className="flex h-full flex-col">
<PageHeader
title="Calendario"
subtitle={
isOwner
? "Arrastra para reagendar, redimensiona para cambiar duración, clic para editar."
: "Tu agenda personal. Crea citas y se te asignarán automáticamente."
}
actions={
<>
<button onClick={() => refetch()} className="btn btn-secondary" title="Refrescar">
<RotateCcw className="h-4 w-4" />
<span className="hidden sm:inline">Refrescar</span>
</button>
<button
onClick={() => {
setEditing(null);
setDraftSlot({ start: new Date().toISOString(), end: new Date(Date.now() + 60 * 60000).toISOString() });
setModalOpen(true);
}}
className="btn btn-primary"
>
<Plus className="h-4 w-4" />
Nueva cita
</button>
</>
}
/>
<div className="flex flex-wrap items-center gap-3 px-5 pb-3 pt-4 sm:px-7">
{isOwner && (
<div className="flex min-w-0 flex-1 items-center gap-2 sm:flex-none">
<Filter className="h-4 w-4 shrink-0 text-slate-400" />
<select
className="select w-full min-w-0 sm:w-auto sm:min-w-[180px]"
value={employeeFilter === "all" ? "all" : String(employeeFilter)}
onChange={(e) => setEmployeeFilter(e.target.value === "all" ? "all" : Number(e.target.value))}
>
<option value="all">Todos los empleados</option>
{employeesData?.employees.map((e) => (
<option key={e.id} value={e.id}>
{e.name} {e.role}
</option>
))}
</select>
</div>
)}
<div className="flex flex-wrap items-center gap-1 rounded-xl border border-slate-200 bg-white p-1">
{STATUS_FILTERS.map((s) => (
<button
key={s.value}
onClick={() => setStatusFilter(s.value)}
className={`rounded-lg px-2.5 py-1.5 text-xs font-semibold transition-colors ${
statusFilter === s.value ? "bg-brand-500 text-white" : "text-slate-600 hover:bg-slate-100"
}`}
>
{s.label}
</button>
))}
</div>
</div>
<div className="flex-1 overflow-y-auto px-5 pb-6 sm:px-7">
<div className="card overflow-x-auto p-3 sm:p-4">
{isLoading && !data && (
<div className="flex h-64 items-center justify-center">
<Spinner className="h-6 w-6 text-brand-500" />
</div>
)}
<FullCalendar
ref={calRef}
plugins={[dayGridPlugin, timeGridPlugin, interactionPlugin, listPlugin]}
locale={esLocale}
initialView={isMobile ? "timeGridDay" : "timeGridWeek"}
headerToolbar={
isMobile
? { left: "prev,next", center: "title", right: "timeGridDay,listWeek" }
: { left: "prev,next today", center: "title", right: "dayGridMonth,timeGridWeek,timeGridDay,listWeek" }
}
footerToolbar={isMobile ? { center: "today" } : false}
buttonText={{
today: "Hoy",
month: "Mes",
week: "Semana",
day: "Día",
list: "Lista",
}}
views={{
listWeek: {
buttonText: "Lista",
listDayFormat: { weekday: "long", day: "numeric", month: "short" },
listDaySideFormat: false,
noEventsContent: "Sin citas esta semana",
},
timeGridDay: { buttonText: "Día" },
}}
height={isMobile ? "auto" : "auto"}
contentHeight={isMobile ? 560 : 680}
stickyHeaderDates
firstDay={1}
slotMinTime="08:00:00"
slotMaxTime="21:00:00"
slotDuration="00:30:00"
slotLabelInterval="01:00:00"
slotLabelFormat={{
hour: "2-digit",
minute: "2-digit",
hour12: true,
}}
nowIndicator
allDaySlot={false}
selectable
selectMirror
editable
eventResizableFromStart
eventDurationEditable
eventStartEditable
dayMaxEvents={isMobile ? 2 : 3}
eventTimeFormat={{
hour: "2-digit",
minute: "2-digit",
hour12: true,
}}
events={events}
select={onSelectSlot}
eventClick={onClickEvent}
eventDrop={onDrop}
eventResize={onResize}
datesSet={() => refetch()}
eventContent={(arg) => <EventContent arg={arg} />}
/>
{!isLoading && events.length === 0 && (
<div className="mt-2">
<EmptyState
icon={CalendarDays}
title="Sin citas en este rango"
description="Selecciona un horario en el calendario o crea una nueva cita."
/>
</div>
)}
</div>
<button onClick={onToday} className="sr-only">Hoy</button>
</div>
<AppointmentModal
open={modalOpen}
onClose={() => {
setModalOpen(false);
setEditing(null);
setDraftSlot(null);
}}
appointment={editing}
draftSlot={draftSlot}
/>
</div>
);
}
function EventContent({ arg }: { arg: any }) {
const a = arg.event.extendedProps.appointment as Appointment | undefined;
if (!a) return <div>{arg.timeText} {arg.event.title}</div>;
const c = statusColor(a.status);
const isTime = arg.view.type !== "dayGridMonth";
return (
<div className="flex h-full flex-col gap-0.5 overflow-hidden">
<div className="flex items-center gap-1">
<span className="h-1.5 w-1.5 shrink-0 rounded-full" style={{ background: c.dot }} />
<span className="text-[10px] font-semibold opacity-70">{arg.timeText}</span>
</div>
<div className="truncate font-bold leading-tight">{a.client?.name}</div>
<div className="truncate text-[10px] opacity-80">{a.service?.name}</div>
{isTime && a.employee && (
<div className="mt-auto truncate text-[10px] opacity-70">con {a.employee.name}</div>
)}
</div>
);
}