HIGH fixes (7): - H1: VNCdirectory admin i18n — 30+ translation keys added - H2: handleSave try/catch with error toast - H3: Free/busy accountId scoping - H4: cancelEventBookings filter by eventId - H5: Resource picker static apiFetch import - H6: Sharing-store toast messages via lastMessage state - H7: roleLabel for all resource types MEDIUM fixes (11): - M1: identitySignatureMap cleanup on delete - M2: Now-line relative positioning - M3: Radial menu disabled item keyboard nav - M4: Radial menu stable event listener via refs - M5: cancelBooking error on missing booking - M6: PasswordRow isMasked state flag - M7: Extract shared rights into lib/sharing-rights.ts - M8: VNCtalk client server-side guard - M9: Collabora configManager instead of process.env - M10: CONFIG_ENV_MAP VNCdirectory fields - M11: SENSITIVE_CONFIG_KEYS field name unification LOW fixes (7): - L1-L3: Unused imports removed - L4: aria-labels on close, clear, search, spinner - L5-L7: Comments for intentional patterns, null guard
200 lines
6.4 KiB
TypeScript
200 lines
6.4 KiB
TypeScript
"use client";
|
|
|
|
import { useState, useMemo, useCallback, useEffect } from "react";
|
|
import { useTranslations } from "next-intl";
|
|
import { useRouter } from "@/i18n/navigation";
|
|
import { ChevronLeft, ChevronRight } from "lucide-react";
|
|
import {
|
|
startOfMonth,
|
|
endOfMonth,
|
|
startOfWeek,
|
|
endOfWeek,
|
|
eachDayOfInterval,
|
|
format,
|
|
isToday,
|
|
isSameDay,
|
|
addMonths,
|
|
subMonths,
|
|
isSameMonth,
|
|
} from "date-fns";
|
|
import { cn } from "@/lib/utils";
|
|
import { useSettingsStore } from "@/stores/settings-store";
|
|
import { useCalendarStore } from "@/stores/calendar-store";
|
|
import { useAuthStore } from "@/stores/auth-store";
|
|
import { getEventDayBounds } from "@/lib/calendar-utils";
|
|
|
|
interface MiniCalendarDashletProps {
|
|
events?: { date: string; color?: string }[];
|
|
onDayClick?: (date: Date) => void;
|
|
selectedDate?: Date;
|
|
}
|
|
|
|
const ALL_DAY_KEYS = ["sun", "mon", "tue", "wed", "thu", "fri", "sat"] as const;
|
|
|
|
export function MiniCalendarDashlet({
|
|
events: propEvents,
|
|
onDayClick,
|
|
selectedDate: propSelectedDate,
|
|
}: MiniCalendarDashletProps) {
|
|
const t = useTranslations("calendar");
|
|
const router = useRouter();
|
|
const firstDayOfWeek = useSettingsStore((s) => s.firstDayOfWeek);
|
|
const storeSelectedDate = useCalendarStore((s) => s.selectedDate);
|
|
const storeEvents = useCalendarStore((s) => s.events);
|
|
const selectedDate = propSelectedDate ?? storeSelectedDate;
|
|
const client = useAuthStore((s) => s.client);
|
|
|
|
const [displayMonth, setDisplayMonth] = useState(() => new Date());
|
|
|
|
const weekStartsOn = useMemo(() => {
|
|
if (firstDayOfWeek === 0) return 0 as const;
|
|
if (firstDayOfWeek === 6) return 6 as const;
|
|
return 1 as const;
|
|
}, [firstDayOfWeek]);
|
|
|
|
useEffect(() => {
|
|
if (!client) return;
|
|
const start = format(startOfMonth(displayMonth), "yyyy-MM-dd'T'00:00:00");
|
|
const end = format(endOfMonth(displayMonth), "yyyy-MM-dd'T'23:59:59");
|
|
const { dateRange } = useCalendarStore.getState();
|
|
if (dateRange?.start === start && dateRange?.end === end) return;
|
|
// Imperative fetch via getState() is intentional: we only need to
|
|
// trigger a data fetch, not react to its completion directly within
|
|
// this component. The store handles loading / error states internally.
|
|
useCalendarStore.getState().fetchEvents(client, start, end);
|
|
}, [displayMonth, client]);
|
|
|
|
const days = useMemo(() => {
|
|
const monthStart = startOfMonth(displayMonth);
|
|
const monthEnd = endOfMonth(displayMonth);
|
|
const calStart = startOfWeek(monthStart, { weekStartsOn });
|
|
const calEnd = endOfWeek(monthEnd, { weekStartsOn });
|
|
return eachDayOfInterval({ start: calStart, end: calEnd });
|
|
}, [displayMonth, weekStartsOn]);
|
|
|
|
const eventDates = useMemo(() => {
|
|
const set = new Set<string>();
|
|
for (const e of storeEvents) {
|
|
try {
|
|
const { startDay, endDay } = getEventDayBounds(e);
|
|
const cursor = new Date(startDay);
|
|
while (cursor <= endDay) {
|
|
set.add(format(cursor, "yyyy-MM-dd"));
|
|
cursor.setDate(cursor.getDate() + 1);
|
|
}
|
|
} catch {
|
|
/* skip */
|
|
}
|
|
}
|
|
if (propEvents) {
|
|
for (const e of propEvents) {
|
|
set.add(e.date);
|
|
}
|
|
}
|
|
return set;
|
|
}, [storeEvents, propEvents]);
|
|
|
|
const dayHeaders = useMemo(
|
|
() => [...ALL_DAY_KEYS.slice(weekStartsOn), ...ALL_DAY_KEYS.slice(0, weekStartsOn)],
|
|
[weekStartsOn],
|
|
);
|
|
|
|
const handlePrevMonth = useCallback(() => {
|
|
setDisplayMonth((prev) => subMonths(prev, 1));
|
|
}, []);
|
|
|
|
const handleNextMonth = useCallback(() => {
|
|
setDisplayMonth((prev) => addMonths(prev, 1));
|
|
}, []);
|
|
|
|
const handleGoToToday = useCallback(() => {
|
|
setDisplayMonth(new Date());
|
|
}, []);
|
|
|
|
const handleDayClick = useCallback(
|
|
(day: Date) => {
|
|
useCalendarStore.getState().setSelectedDate(day);
|
|
if (onDayClick) {
|
|
onDayClick(day);
|
|
} else {
|
|
router.push("/calendar");
|
|
}
|
|
},
|
|
[onDayClick, router],
|
|
);
|
|
|
|
return (
|
|
<div className="select-none px-2 py-1.5">
|
|
<div className="flex items-center justify-between mb-1">
|
|
<button
|
|
onClick={handlePrevMonth}
|
|
className="p-0.5 rounded hover:bg-muted transition-colors"
|
|
aria-label={t("nav_prev")}
|
|
>
|
|
<ChevronLeft className="w-3.5 h-3.5 text-muted-foreground" />
|
|
</button>
|
|
<button
|
|
onClick={handleGoToToday}
|
|
className="text-xs font-medium hover:bg-muted px-1.5 py-0.5 rounded transition-colors"
|
|
title={t("views.today")}
|
|
>
|
|
{format(displayMonth, "MMM yyyy")}
|
|
</button>
|
|
<button
|
|
onClick={handleNextMonth}
|
|
className="p-0.5 rounded hover:bg-muted transition-colors"
|
|
aria-label={t("nav_next")}
|
|
>
|
|
<ChevronRight className="w-3.5 h-3.5 text-muted-foreground" />
|
|
</button>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-7 mb-0.5">
|
|
{dayHeaders.map((dh) => (
|
|
<div
|
|
key={dh}
|
|
className="text-center text-[9px] font-medium text-muted-foreground py-0.5"
|
|
>
|
|
{t(`days.${dh}`)}
|
|
</div>
|
|
))}
|
|
</div>
|
|
|
|
<div className="grid grid-cols-7 gap-0">
|
|
{days.map((day) => {
|
|
const inMonth = isSameMonth(day, displayMonth);
|
|
const selected = isSameDay(day, selectedDate);
|
|
const today = isToday(day);
|
|
const dateStr = format(day, "yyyy-MM-dd");
|
|
const hasEvent = eventDates.has(dateStr);
|
|
const dotColor =
|
|
propEvents?.find((e) => e.date === dateStr && e.color)?.color ??
|
|
undefined;
|
|
|
|
return (
|
|
<button
|
|
key={day.toISOString()}
|
|
onClick={() => handleDayClick(day)}
|
|
className={cn(
|
|
"relative flex items-center justify-center w-6 h-6 text-[11px] rounded-full transition-colors mx-auto",
|
|
!inMonth && "text-muted-foreground/30",
|
|
inMonth && !selected && "hover:bg-muted",
|
|
today && !selected && "font-bold text-primary",
|
|
selected && "bg-primary text-primary-foreground",
|
|
)}
|
|
>
|
|
{day.getDate()}
|
|
{hasEvent && !selected && (
|
|
<span
|
|
className="absolute bottom-0 left-1/2 -translate-x-1/2 w-1 h-1 rounded-full bg-primary"
|
|
style={dotColor ? { backgroundColor: dotColor } : undefined}
|
|
/>
|
|
)}
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|