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,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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user