feat: rebrand AgendaPro -> AgendaMax + calendar fixes + current-week demo seed

- Rebrand all user-facing text to AgendaMax
- Fix sidebar scrolling: card container no longer overflows html (flex h-full min-h-0)
- Fix calendar toolbar hover: active buttons keep readable contrast
- Sticky calendar toolbar (month/week/day always visible on scroll)
- Seed guarantees 2-4 appointments per day for current week (Mon-Sat)
This commit is contained in:
AgendaPro Dev
2026-07-27 10:11:16 -06:00
parent 4227db0c8b
commit d796538fd9
57 changed files with 2477 additions and 322 deletions
+62 -11
View File
@@ -9,9 +9,10 @@ import type {
EventClickArg,
EventDropArg,
} from "@fullcalendar/core";
import type { EventResizeDoneArg } from "@fullcalendar/interaction";
import esLocale from "@fullcalendar/core/locales/es";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { CalendarDays, Plus, Filter, RotateCcw } from "lucide-react";
import { CalendarDays, Plus, Filter, RotateCcw, AlertTriangle } from "lucide-react";
import { api } from "../lib/api";
import { useAuth } from "../lib/auth";
import type { Appointment } from "../../shared/types";
@@ -30,6 +31,8 @@ export function CalendarPage() {
const { user } = useAuth();
const qc = useQueryClient();
const calRef = useRef<FullCalendar>(null);
const pendingRevert = useRef<(() => void) | null>(null);
const [moveError, setMoveError] = useState<string | null>(null);
const [modalOpen, setModalOpen] = useState(false);
const [editing, setEditing] = useState<Appointment | null>(null);
const [draftSlot, setDraftSlot] = useState<{ start: string; end: string } | null>(null);
@@ -100,16 +103,27 @@ export function CalendarPage() {
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"] }),
onSuccess: () => {
pendingRevert.current = null;
setMoveError(null);
qc.invalidateQueries({ queryKey: ["appointments"] });
},
onError: () => {
pendingRevert.current?.();
pendingRevert.current = null;
setMoveError("No se pudo reagendar la cita (quizá se ocupó el horario). Se regresó a su lugar.");
},
});
const onDrop = (arg: EventDropArg) => {
pendingRevert.current = arg.revert;
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 onResize = (arg: EventResizeDoneArg) => {
pendingRevert.current = arg.revert;
const id = Number(arg.event.id);
moveMutation.mutate({
id,
@@ -164,6 +178,21 @@ export function CalendarPage() {
}
/>
{moveError && (
<div className="mx-5 mt-2 flex items-start gap-2 rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-sm text-amber-800 sm:mx-7">
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0 text-amber-500" />
<span className="flex-1">{moveError}</span>
<button
type="button"
onClick={() => setMoveError(null)}
className="shrink-0 text-amber-500 hover:text-amber-700"
aria-label="Cerrar aviso"
>
×
</button>
</div>
)}
<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">
@@ -198,7 +227,7 @@ export function CalendarPage() {
</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">
<div className="card flex h-full min-h-0 flex-col overflow-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" />
@@ -252,6 +281,8 @@ export function CalendarPage() {
eventResizableFromStart
eventDurationEditable
eventStartEditable
slotEventOverlap={false}
eventMinHeight={28}
dayMaxEvents={isMobile ? 2 : 3}
eventTimeFormat={{
hour: "2-digit",
@@ -297,17 +328,37 @@ 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 client = a.client as (NonNullable<Appointment["client"]> & {
notes?: string | null;
no_show_count?: number;
}) | undefined;
const risky =
(client?.no_show_count ?? 0) >= 2 || !!client?.notes?.startsWith("[Riesgo");
const isTime = arg.view.type !== "dayGridMonth";
// When the event is rendered in a narrow column (because FullCalendar
// splits overlapping events side-by-side) we drop the employee line so
// client + service still fit without bleeding into adjacent slots.
const isNarrow = arg.view.type === "timeGridWeek" || arg.view.type === "timeGridDay";
const dur = a.service?.duration_min;
return (
<div className="flex h-full flex-col gap-0.5 overflow-hidden">
<div className="flex items-center gap-1">
<div className="flex h-full min-h-0 flex-col gap-0.5 overflow-hidden">
<div className="flex items-center gap-1 leading-none">
<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>
<span className="truncate 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 className="flex items-center gap-1">
<span className="truncate font-bold leading-tight">{a.client?.name ?? "Cliente"}</span>
{risky && (
<span title="Ausencias previas sin avisar" aria-label="Ausencias previas sin avisar">
<AlertTriangle className="h-3 w-3 shrink-0 text-amber-500" />
</span>
)}
</div>
<div className="truncate text-[10px] leading-tight opacity-80">
{a.service?.name}{dur ? ` (${dur} min)` : ""}
</div>
{isTime && a.employee && !isNarrow && (
<div className="mt-auto truncate text-[10px] leading-tight opacity-70">con {a.employee.name}</div>
)}
</div>
);