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:
+62
-11
@@ -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>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user