Learning_Management_System_LMS/src/components/calendar/EducationalCalendarPage.tsx

650 خطوط
47 KiB
TypeScript
خام سرزنش تاریخچه

مخزن invisible runes header

راهنمای مخزن invisible runes توضیحات

راهنمای مخزن ambiguous runes توضیحات

"use client";
import { useEffect, useMemo, useState } from "react";
import { Bell, CalendarDays, ChevronLeft, ChevronRight, Download, Filter, Plus, Search, Settings, X } from "@/components/shared/ui-icons";
import type { UserRole } from "@/config/homeAppsConfig";
import { formatJalaliDate, formatJalaliDateTime, toPersianDigits } from "@/lib/persianFormat";
import type { CalendarConnectedItemType, CalendarEvent, CalendarEventStatus, CalendarEventType, CalendarSettings, CalendarViewMode } from "@/types/calendar";
import { calendarEventTypeDefinitions } from "@/services/mock/calendar/calendarMockData";
import { createEvent as storeCreateEvent, getEventById as storeGetEventById, getEvents, getSelectedDate, getSelectedView, getSettings, saveEvents, saveSelectedDate, saveSelectedView, saveSettings } from "@/services/calendar/calendarStorageService";
import { MacToggle } from "@/components/mac/MacToggle";
import { PersianDatePicker, PersianDateTimePicker, PersianTimePicker } from "./PersianDatePicker";
import { AccessibleDialog } from "@/components/shared/AccessibleDialog";
import { WindowSplitView } from "@/components/shared/WindowSplitView";
type CalendarRouteMode = "main" | "day" | "my" | "team" | "organization" | "system" | "events" | "create" | "detail" | "edit" | "attendance" | "participants" | "reminders" | "audit" | "reports" | "settings";
const weekDaysFa = ["شنبه", "یکشنبه", "دوشنبه", "سه‌شنبه", "چهارشنبه", "پنجشنبه", "جمعه"];
const viewLabels: Record<CalendarViewMode, string> = { day: "روزانه", week: "هفتگی", month: "ماهانه", list: "لیستی", timeline: "تایم‌لاین", today: "امروز", upcoming: "رویدادهای آینده", overdue: "رویدادهای عقب‌افتاده" };
const statusLabels: Record<CalendarEventStatus, string> = { draft: "پیش‌نویس", published: "منتشرشده", scheduled: "زمان‌بندی‌شده", live: "در حال برگزاری", completed: "برگزارشده", cancelled: "لغوشده", overdue: "عقب‌افتاده", needs_attention: "نیازمند توجه" };
const roleLabels: Record<UserRole, string> = { super_admin: "مدیر کل سامانه", org_admin: "مدیر آموزش سازمان", manager: "مدیر واحد", employee: "کارکنان" };
export function EducationalCalendarPage({ role = "org_admin", mode = "main", eventId = "ev-2", initialDate, onToast }: { role?: UserRole; mode?: CalendarRouteMode; eventId?: string; initialDate?: string; onToast?: (message: string, tone?: "success" | "error") => void }) {
const [view, setViewState] = useState<CalendarViewMode>(mode === "day" ? "day" : "month");
const [anchorDate, setAnchorDateState] = useState(() => dateFromYmd(initialDate ?? "2026-06-22"));
const [events, setEvents] = useState<CalendarEvent[]>([]);
const [selectedEvent, setSelectedEvent] = useState<CalendarEvent | null>(null);
const [query, setQuery] = useState("");
const [createOpen, setCreateOpen] = useState(mode === "create");
const [settingsOpen, setSettingsOpen] = useState(mode === "settings");
const [prefillDate, setPrefillDate] = useState(toYmd(anchorDate));
const [toast, setToast] = useState("");
useEffect(() => {
const savedView = mode === "day" ? "day" : getSelectedView();
const savedDate = initialDate ?? getSelectedDate();
setViewState(savedView);
setAnchorDateState(dateFromYmd(savedDate));
setEvents(applyRoleScope(getEvents(), role, mode));
if (["detail", "edit"].includes(mode)) setSelectedEvent(storeGetEventById(eventId) ?? null);
}, [eventId, initialDate, mode, role]);
const showToast = (message: string) => {
setToast(message);
onToast?.(message, "success");
window.setTimeout(() => setToast(""), 2400);
};
const setView = (next: CalendarViewMode) => {
setViewState(next);
saveSelectedView(next);
};
const setAnchorDate = (date: Date) => {
setAnchorDateState(date);
saveSelectedDate(toYmd(date));
};
const visibleEvents = useMemo(() => {
const cleanQuery = query.trim();
return events.filter((event) => {
if (!cleanQuery) return true;
return [event.title, event.description, event.instructorFa, event.organizationFa, event.connectedItem?.title].filter(Boolean).join(" ").includes(cleanQuery);
});
}, [events, query]);
const persistEvents = (next: CalendarEvent[]) => {
setEvents(applyRoleScope(saveEvents(next), role, mode));
};
const createNewEvent = (payload: Partial<CalendarEvent>) => {
const created = storeCreateEvent(payload);
const next = getEvents();
setEvents(applyRoleScope(next, role, mode));
setSelectedEvent(created);
setCreateOpen(false);
showToast("رویداد ایجاد شد");
};
if (mode === "create") {
return (
<CalendarShell>
<CalendarEventCreateForm role={role} initialDate={initialDate ?? toYmd(anchorDate)} onCancel={() => window.history.back()} onCreate={createNewEvent} />
{toast && <Toast message={toast} />}
</CalendarShell>
);
}
if (mode === "settings") {
return (
<CalendarSettingsPage
role={role}
embedded
onSaved={(settings) => {
saveSettings(settings);
showToast("تنظیمات ذخیره شد");
}}
/>
);
}
if (mode === "detail") {
const event = selectedEvent ?? storeGetEventById(eventId);
return <CalendarShell>{event ? <CalendarEventDetail event={event} role={role} onClose={() => undefined} /> : <CalendarEmptyState />}</CalendarShell>;
}
return (
<CalendarShell>
<CalendarToolbar
role={role}
mode={mode}
anchorDate={anchorDate}
view={view}
query={query}
onQuery={setQuery}
onView={setView}
onToday={() => setAnchorDate(new Date())}
onNavigate={(direction) => setAnchorDate(addByView(anchorDate, view, direction))}
onCreate={() => {
setPrefillDate(toYmd(anchorDate));
setCreateOpen(true);
}}
onSettings={() => setSettingsOpen(true)}
/>
<section className="min-h-0 flex-1 overflow-hidden rounded-[28px] border border-white/20 bg-white/20 p-3 shadow-2xl backdrop-blur-2xl dark:bg-slate-950/30">
{view === "month" && (
<WindowSplitView
className="h-full"
sidebarLabel="جزئیات روز انتخاب‌شده"
sidebar={<CalendarSelectedDayPanel date={anchorDate} events={visibleEvents.filter((event) => isSameDay(event.startAt, anchorDate))} onCreate={() => { setPrefillDate(toYmd(anchorDate)); setCreateOpen(true); }} onSelectEvent={setSelectedEvent} />}
initialSidebarWidth={280}
>
<CalendarMonthView
events={visibleEvents}
anchorDate={anchorDate}
selectedDate={anchorDate}
onSelectDay={(date) => {
setAnchorDate(date);
setPrefillDate(toYmd(date));
}}
onQuickCreate={(date) => {
setPrefillDate(toYmd(date));
setCreateOpen(true);
}}
onSelectEvent={setSelectedEvent}
onEventsChange={persistEvents}
/>
</WindowSplitView>
)}
{view === "week" && <CalendarWeekView events={visibleEvents} anchorDate={anchorDate} onSelectEvent={setSelectedEvent} />}
{view === "day" && <CalendarDayView events={visibleEvents.filter((event) => isSameDay(event.startAt, anchorDate))} selectedDate={anchorDate} onCreate={() => { setPrefillDate(toYmd(anchorDate)); setCreateOpen(true); }} onSelectEvent={setSelectedEvent} />}
{view === "list" && <CalendarListView events={visibleEvents} onSelectEvent={setSelectedEvent} />}
{view === "timeline" && <CalendarTimelineView events={visibleEvents} onSelectEvent={setSelectedEvent} />}
{view === "today" && <CalendarDayView events={visibleEvents.filter((event) => isSameDay(event.startAt, new Date()))} selectedDate={new Date()} onCreate={() => { setPrefillDate(toYmd(new Date())); setCreateOpen(true); }} onSelectEvent={setSelectedEvent} />}
{view === "upcoming" && <CalendarListView events={visibleEvents.filter(isFutureEvent)} onSelectEvent={setSelectedEvent} />}
{view === "overdue" && <CalendarListView events={visibleEvents.filter(isPastEvent)} onSelectEvent={setSelectedEvent} />}
</section>
{createOpen && <CalendarEventCreateSheet role={role} initialDate={prefillDate} onClose={() => setCreateOpen(false)} onCreate={createNewEvent} />}
{settingsOpen && <CalendarSettingsSheet role={role} onClose={() => setSettingsOpen(false)} onSaved={(settings) => { saveSettings(settings); setSettingsOpen(false); showToast("تنظیمات ذخیره شد"); }} />}
{selectedEvent && <CalendarEventDetail event={selectedEvent} role={role} onClose={() => setSelectedEvent(null)} />}
{toast && <Toast message={toast} />}
</CalendarShell>
);
}
export function CalendarShell({ children }: { children: React.ReactNode }) {
return <main className="window-module-shell persian-digits w-full max-w-full" dir="rtl"><header className="mac-window-handle window-module-toolbar window-controls-safe-row flex min-h-11 cursor-move items-center py-1.5"><span className="truncate text-sm font-black">تقویم آموزشی</span></header><div className="flex min-h-0 flex-1 flex-col p-3">{children}</div></main>;
}
export function CalendarToolbar({ role, mode, anchorDate, view, query, onQuery, onView, onToday, onNavigate, onCreate, onSettings }: { role: UserRole; mode: CalendarRouteMode; anchorDate: Date; view: CalendarViewMode; query: string; onQuery: (value: string) => void; onView: (value: CalendarViewMode) => void; onToday: () => void; onNavigate: (direction: -1 | 1) => void; onCreate: () => void; onSettings: () => void }) {
const title = mode === "my" ? "تقویم من" : mode === "team" ? "تقویم تیم" : mode === "organization" ? "تقویم سازمان" : mode === "system" ? "تقویم کل سامانه" : "تقویم آموزشی";
return (
<header className="window-calendar-toolbar mb-3 grid min-h-14 shrink-0 grid-cols-[1fr_auto_1fr] items-center gap-3 rounded-[20px] border border-[var(--color-border)] bg-[var(--color-card)] px-3 py-2 shadow-sm">
<div className="flex min-w-0 items-center gap-2">
<button onClick={onToday} className="min-h-11 rounded-xl bg-[var(--color-selected)] px-3 text-sm font-black">امروز</button>
<select aria-label="نوع نمایش تقویم" value={view} onChange={(event) => onView(event.target.value as CalendarViewMode)} className="h-11 min-h-11 w-28 rounded-xl px-3 text-sm">
{(["day", "week", "month", "list", "timeline"] as CalendarViewMode[]).map((item) => <option key={item} value={item}>{viewLabels[item]}</option>)}
</select>
<label className="window-calendar-search ui-search-box relative hidden min-w-0 flex-1 lg:block">
<Search className="absolute right-3 top-1/2 -translate-y-1/2 text-slate-500" size={17} />
<input value={query} onChange={(event) => onQuery(event.target.value)} placeholder="جستجوی رویداد..." className="h-11 w-full rounded-xl py-2 pl-3 pr-10 text-sm" />
</label>
</div>
<div className="flex items-center gap-1" aria-label="ناوبری تقویم">
<button aria-label="ماه بعد" onClick={() => onNavigate(1)} className="ui-icon-button !h-11 !w-11"><ChevronRight size={18} /></button>
<div className="min-w-36 text-center"><h1 className="text-base font-black">{new Intl.DateTimeFormat("fa-IR", { month: "long", year: "numeric" }).format(anchorDate)}</h1><span className="sr-only">{title}، {roleLabels[role]}</span></div>
<button aria-label="ماه قبل" onClick={() => onNavigate(-1)} className="ui-icon-button !h-11 !w-11"><ChevronLeft size={18} /></button>
</div>
<div className="window-toolbar-actions flex min-w-0 items-center justify-end gap-1">
<button className="ui-icon-button !h-11 !w-11" aria-label="فیلترها"><Filter size={17} /></button>
<button className="ui-icon-button !hidden !h-11 !w-11 sm:!inline-flex" aria-label="خروجی گرفتن"><Download size={17} /></button>
<button onClick={onSettings} className="ui-icon-button !h-11 !w-11" aria-label="تنظیمات"><Settings size={17} /></button>
{role !== "employee" && <button onClick={onCreate} className="inline-flex min-h-11 items-center gap-2 rounded-xl bg-[var(--color-text)] px-3 text-sm font-black text-[var(--color-bg-soft)]"><Plus size={17} /><span className="window-toolbar-action-label hidden sm:inline">رویداد جدید</span></button>}
</div>
</header>
);
}
export function CalendarViewSwitcher({ view, onView }: { view: CalendarViewMode; onView: (view: CalendarViewMode) => void }) {
return <div className="mb-3 flex gap-2 overflow-auto pb-1 mac-scrollbar">{(["day", "week", "month", "list", "timeline"] as CalendarViewMode[]).map((item) => <button key={item} onClick={() => onView(item)} className={`shrink-0 rounded-2xl px-4 py-2 text-sm font-bold transition ${view === item ? "bg-slate-950 text-white dark:bg-white dark:text-slate-950" : "bg-white/35 hover:bg-white/55 dark:bg-white/10"}`}>{viewLabels[item]}</button>)}</div>;
}
export function CalendarMonthView({ events, anchorDate, selectedDate, onSelectDay, onQuickCreate, onSelectEvent, onEventsChange }: { events: CalendarEvent[]; anchorDate: Date; selectedDate: Date; onSelectDay: (date: Date) => void; onQuickCreate: (date: Date) => void; onSelectEvent: (event: CalendarEvent) => void; onEventsChange: (events: CalendarEvent[]) => void }) {
const days = buildMonthDays(anchorDate);
const [dragId, setDragId] = useState<string | null>(null);
return (
<div className="grid h-full min-h-0 grid-rows-[auto_1fr] overflow-hidden rounded-[20px] border border-[var(--color-border)] bg-[var(--color-surface-strong)] p-2">
<div className="grid grid-cols-7">{weekDaysFa.map((day) => <div key={day} title={day} className="p-2 text-center text-xs font-black text-[var(--color-text-muted)]">{day.slice(0, 1)}</div>)}</div>
<div className="grid min-h-0 grid-cols-7 grid-rows-6 gap-1.5">
{days.map((day) => {
const dayEvents = events.filter((event) => isSameDay(event.startAt, day.date));
const today = isSameDay(day.date, new Date());
const holiday = day.date.getDay() === 5;
return (
<div
key={day.date.toISOString()}
role="button"
tabIndex={0}
onClick={() => onSelectDay(day.date)}
onKeyDown={(event) => event.key === "Enter" && onSelectDay(day.date)}
onDragOver={(event) => event.preventDefault()}
onDrop={(dropEvent) => {
dropEvent.stopPropagation();
if (!dragId) return;
const next = getEvents().map((item) => item.id === dragId ? moveEventToDay(item, day.date) : item);
onEventsChange(next);
setDragId(null);
}}
aria-label={`${day.currentMonth ? "" : "خارج از ماه، "}${holiday ? "تعطیل، " : ""}${dayEvents.length} رویداد`}
className={`group flex min-h-0 flex-col overflow-hidden rounded-[14px] border border-[var(--color-border)] p-2 text-right outline-none transition hover:bg-[var(--color-selected)] focus:ring-2 focus:ring-[var(--color-focus-ring)] ${day.currentMonth ? "bg-[var(--color-card)]" : "bg-[var(--color-bg-soft)] text-[var(--color-text-muted)] opacity-55"} ${holiday ? "border-t-rose-400/70" : ""} ${isSameDay(day.date, selectedDate) ? "border-emerald-500 bg-emerald-500/10 ring-1 ring-inset ring-emerald-500" : ""}`}
>
<div className="flex shrink-0 items-center justify-between gap-2">
<span className={`inline-flex h-7 min-w-7 items-center justify-center rounded-full px-2 text-sm font-black ${today ? "bg-sky-600 text-white" : holiday ? "bg-rose-500/15 text-rose-700 dark:text-rose-300" : "bg-[var(--color-surface-strong)]"}`}>{toPersianDigits(day.date.getDate())}</span>
<button onClick={(event) => { event.stopPropagation(); onQuickCreate(day.date); }} className="hidden rounded-xl bg-[var(--color-selected)] px-2 py-1 text-xs font-bold text-[var(--color-accent)] opacity-0 transition-opacity group-hover:opacity-100 group-focus:opacity-100 md:inline-flex">ایجاد</button>
</div>
<div className="mt-2 min-h-0 flex-1 space-y-1 overflow-hidden rounded-2xl">
{dayEvents.slice(0, 2).map((event) => (
<CalendarEventCard
key={event.id}
event={event}
compact
onClick={(clickEvent) => {
clickEvent.stopPropagation();
onSelectEvent(event);
}}
onDragStart={(dragEvent) => {
dragEvent.stopPropagation();
setDragId(event.id);
}}
/>
))}
{dayEvents.length > 2 && <button onClick={(event) => { event.stopPropagation(); onSelectDay(day.date); }} className="w-full rounded-xl bg-[var(--color-selected)] px-2 py-1 text-xs font-bold text-[var(--color-accent)]">+{toPersianDigits(dayEvents.length - 2)} مورد</button>}
</div>
</div>
);
})}
</div>
</div>
);
}
function CalendarSelectedDayPanel({ date, events, onCreate, onSelectEvent }: { date: Date; events: CalendarEvent[]; onCreate: () => void; onSelectEvent: (event: CalendarEvent) => void }) {
return (
<aside className="popup-scroll hidden min-h-0 overflow-auto rounded-[20px] border border-[var(--color-border)] bg-[var(--color-card)] xl:block">
<div className="sticky top-0 z-10 flex items-center justify-between gap-2 border-b border-[var(--color-divider)] bg-[var(--color-surface-strong)] p-4">
<div>
<p className="text-xs font-bold text-[var(--color-text-muted)]">روز انتخابشده</p>
<h2 className="mt-1 font-black">{formatJalaliDate(date)}</h2>
</div>
<button type="button" onClick={onCreate} className="ui-icon-button !h-11 !w-11" aria-label="ایجاد رویداد در این روز"><Plus size={17} /></button>
</div>
<div className="space-y-2 p-4">
{events.length ? events.map((event) => (
<CalendarEventCard key={event.id} event={event} detailed onClick={(clickEvent) => { clickEvent.stopPropagation(); onSelectEvent(event); }} />
)) : (
<div className="grid min-h-52 place-items-center rounded-[18px] border border-dashed border-[var(--color-border)] bg-[var(--color-bg-soft)] p-4 text-center">
<div>
<CalendarDays className="mx-auto text-[var(--color-text-muted)]" size={30} />
<p className="mt-3 text-sm font-bold text-[var(--color-text-secondary)]">برای این روز برنامهای ثبت نشده است.</p>
<button type="button" onClick={onCreate} className="mt-4 min-h-11 rounded-xl bg-[var(--color-selected)] px-4 text-sm font-black text-[var(--color-accent)]">ایجاد رویداد</button>
</div>
</div>
)}
</div>
</aside>
);
}
export function CalendarDayView({ events, selectedDate, onCreate, onSelectEvent }: { events: CalendarEvent[]; selectedDate: Date; onCreate: () => void; onSelectEvent: (event: CalendarEvent) => void }) {
return (
<div className="popup-scroll h-full overflow-auto rounded-3xl border border-white/20 bg-white/20 p-4 dark:bg-white/5">
<div className="sticky top-0 z-10 -mx-4 -mt-4 mb-4 flex flex-wrap items-center justify-between gap-3 bg-white/70 p-4 backdrop-blur-xl dark:bg-slate-950/70">
<div>
<h2 className="text-xl font-black">{formatJalaliDate(selectedDate)}</h2>
<p className="mt-1 text-sm opacity-70">{events.length ? `${toPersianDigits(events.length)} رویداد` : "رویدادی برای این روز وجود ندارد"}</p>
</div>
<button onClick={onCreate} className="rounded-2xl bg-slate-950 px-4 py-2 text-sm font-black text-white dark:bg-white dark:text-slate-950">ایجاد رویداد جدید</button>
</div>
{events.length ? <div className="space-y-3">{events.map((event) => <CalendarEventCard key={event.id} event={event} detailed onClick={(clickEvent) => { clickEvent.stopPropagation(); onSelectEvent(event); }} />)}</div> : <CalendarEmptyState />}
</div>
);
}
export function CalendarWeekView({ events, anchorDate, onSelectEvent }: { events: CalendarEvent[]; anchorDate: Date; onSelectEvent: (event: CalendarEvent) => void }) {
const week = buildWeekDays(anchorDate);
return <div className="popup-scroll grid h-full min-h-[520px] grid-cols-7 overflow-auto rounded-3xl border border-white/20 bg-white/20 dark:bg-white/5">{week.map((day) => <div key={day.toISOString()} className="border-l border-white/15 p-3"><h3 className="sticky top-0 rounded-2xl bg-white/45 p-2 text-center text-xs font-black backdrop-blur-xl dark:bg-slate-950/45">{formatJalaliDate(day)}</h3><div className="mt-3 space-y-2">{events.filter((event) => isSameDay(event.startAt, day)).map((event) => <CalendarEventCard key={event.id} event={event} compact onClick={(clickEvent) => { clickEvent.stopPropagation(); onSelectEvent(event); }} />)}</div></div>)}</div>;
}
export function CalendarListView({ events, onSelectEvent }: { events: CalendarEvent[]; onSelectEvent: (event: CalendarEvent) => void }) {
if (!events.length) return <CalendarEmptyState />;
return <div className="popup-scroll h-full overflow-auto rounded-3xl border border-white/20 bg-white/20 p-3 dark:bg-white/5"><div className="space-y-2">{events.map((event) => <CalendarEventCard key={event.id} event={event} detailed onClick={(clickEvent) => { clickEvent.stopPropagation(); onSelectEvent(event); }} />)}</div></div>;
}
export function CalendarTimelineView({ events, onSelectEvent }: { events: CalendarEvent[]; onSelectEvent: (event: CalendarEvent) => void }) {
return <div className="popup-scroll h-full overflow-auto rounded-3xl border border-white/20 bg-white/20 p-4 dark:bg-white/5"><div className="relative space-y-4 before:absolute before:right-4 before:top-0 before:h-full before:w-px before:bg-slate-400/40">{events.map((event) => <button key={event.id} onClick={(clickEvent) => { clickEvent.stopPropagation(); onSelectEvent(event); }} className="relative block w-full pr-10 text-right"><span className="absolute right-2 top-4 h-4 w-4 rounded-full ring-4 ring-white/40" style={{ backgroundColor: event.color }} /><CalendarEventCard event={event} detailed /></button>)}</div></div>;
}
export function CalendarEventCard({ event, compact, detailed, className = "", onClick, onDragStart }: { event: CalendarEvent; compact?: boolean; detailed?: boolean; className?: string; onClick?: (event: React.MouseEvent) => void; onDragStart?: (event: React.DragEvent) => void }) {
const type = getType(event.type);
return (
<div draggable={Boolean(onDragStart)} onDragStart={onDragStart} role="button" tabIndex={0} onKeyDown={(keyboardEvent) => keyboardEvent.key === "Enter" && onClick?.(keyboardEvent as unknown as React.MouseEvent)} onClick={onClick} className={`group rounded-2xl border border-white/25 bg-white/45 text-right shadow-sm outline-none transition hover:-translate-y-0.5 hover:bg-white/65 focus:ring-2 focus:ring-sky-400/50 dark:bg-white/10 dark:hover:bg-white/15 ${compact ? "p-2 text-xs" : "p-3"} ${className}`}>
<div className="flex items-start gap-2">
<span className="mt-1 h-3 w-3 shrink-0 rounded-full" style={{ backgroundColor: event.color }} />
<div className="min-w-0 flex-1">
<p className={`truncate font-black ${compact ? "text-xs" : "text-sm"}`}>{event.title}</p>
{!compact && <p className="mt-1 text-xs text-slate-600 dark:text-white/65">{type.labelFa} · {statusLabels[event.status]}</p>}
{detailed && <p className="mt-2 text-xs leading-6 text-slate-600 dark:text-white/65">{formatJalaliDateTime(event.startAt)} · {event.instructorFa ?? "مدرس ثبت نشده"} · {toPersianDigits(event.participantsCount)} نفر</p>}
</div>
</div>
</div>
);
}
export function CalendarEventDetail({ event, role, onClose }: { event: CalendarEvent; role: UserRole; onClose: () => void }) {
useEffect(() => {
const handler = (keyboardEvent: KeyboardEvent) => keyboardEvent.key === "Escape" && onClose();
window.addEventListener("keydown", handler);
return () => window.removeEventListener("keydown", handler);
}, [onClose]);
const type = getType(event.type);
const canEdit = role === "super_admin" || role === "org_admin" || (role === "manager" && event.type === "team_session");
return (
<AccessibleDialog open title="جزئیات رویداد" onClose={onClose} showClose={false} panelClassName="popup-scroll max-w-2xl overflow-auto">
<section>
<div className="flex items-start justify-between gap-3">
<div><p className="text-sm font-bold" style={{ color: event.color }}>{type.labelFa}</p><h2 className="mt-1 text-2xl font-black">{event.title}</h2><p className="mt-2 text-sm opacity-70">{formatJalaliDateTime(event.startAt)} تا {formatJalaliDateTime(event.endAt)}</p></div>
<button onClick={onClose} className="rounded-2xl bg-white/30 p-2" aria-label="بستن"><X size={18} /></button>
</div>
<p className="mt-4 rounded-2xl bg-white/20 p-3 text-sm leading-7">{event.description || "توضیحی برای این رویداد ثبت نشده است."}</p>
<div className="mt-4 grid gap-3 md:grid-cols-2">
<Info label="عنوان رویداد" value={event.title} />
<Info label="نوع رویداد" value={type.labelFa} />
<Info label="وضعیت" value={statusLabels[event.status]} />
<Info label="اتصال آموزشی" value={event.connectedItem?.title ?? "هیچ‌کدام"} />
<Info label="مخاطبان" value={`${event.organizationFa ?? "سازمان"} · ${toPersianDigits(event.participantsCount)} نفر`} />
<Info label="یادآوری‌ها" value={event.reminders.length ? `${toPersianDigits(event.reminders.length)} یادآوری` : "یادآوری ثبت نشده"} />
{event.attendanceEnabled && <Info label="حضور و غیاب" value={`حاضر ${toPersianDigits(event.attendanceSummary.present)} · غایب ${toPersianDigits(event.attendanceSummary.absent)}`} />}
</div>
<CalendarEventActions event={event} />
<div className="mt-5 flex flex-wrap gap-2">
<button className="rounded-2xl bg-white/25 px-4 py-2 text-sm font-bold">مشاهده جزئیات</button>
{canEdit && <button className="rounded-2xl bg-white/25 px-4 py-2 text-sm font-bold">ویرایش</button>}
</div>
</section>
</AccessibleDialog>
);
}
export function CalendarEventActions({ event }: { event: CalendarEvent }) {
const action = getEventPrimaryAction(event);
const clickable = Boolean(action.href) && !action.disabled;
return (
<div className="mt-5 rounded-3xl bg-white/18 p-4">
<h3 className="font-black">اقدام رویداد</h3>
<button disabled={action.disabled} onClick={() => clickable && window.open(action.href, "_blank", "noopener,noreferrer")} className={`mt-3 rounded-2xl px-4 py-2 text-sm font-black ${action.disabled ? "bg-white/20 opacity-60" : "bg-slate-950 text-white dark:bg-white dark:text-slate-950"}`}>
{action.label}
</button>
{action.description && <p className="mt-2 text-sm opacity-70">{action.description}</p>}
</div>
);
}
export function CalendarEventCreateSheet({ role, initialDate, onClose, onCreate }: { role: UserRole; initialDate: string; onClose: () => void; onCreate: (payload: Partial<CalendarEvent>) => void }) {
return <AccessibleDialog open title="ایجاد رویداد" description="اطلاعات رویداد را در چهار مرحله کوتاه تکمیل کنید." onClose={onClose} panelClassName="flex h-[min(620px,calc(100%-16px))] max-w-3xl flex-col overflow-hidden"><CalendarEventCreateForm role={role} initialDate={initialDate} onCancel={onClose} onCreate={onCreate} /></AccessibleDialog>;
}
export function CalendarEventCreateForm({ role, initialDate, onCancel, onCreate }: { role: UserRole; initialDate: string; onCancel: () => void; onCreate: (payload: Partial<CalendarEvent>) => void }) {
const [title, setTitle] = useState("");
const [type, setType] = useState<CalendarEventType>("online_class");
const [description, setDescription] = useState("");
const [startDate, setStartDate] = useState(initialDate);
const [startTime, setStartTime] = useState("09:00");
const [endDate, setEndDate] = useState(initialDate);
const [endTime, setEndTime] = useState("10:00");
const [connectedType, setConnectedType] = useState("none");
const [onlineLink, setOnlineLink] = useState("");
const [recordingLink, setRecordingLink] = useState("");
const [audience, setAudience] = useState("کارکنان");
const [reminder, setReminder] = useState("۶۰");
const [status, setStatus] = useState<CalendarEventStatus>("scheduled");
const [warnings, setWarnings] = useState<string[]>([]);
const [step, setStep] = useState(0);
useEffect(() => {
setStartDate(initialDate);
setEndDate(initialDate);
}, [initialDate]);
const validate = (targetStep = step) => {
const nextWarnings: string[] = [];
if (targetStep === 0 && !title.trim()) nextWarnings.push("عنوان رویداد الزامی است.");
if (targetStep === 1 && !startDate) nextWarnings.push("تاریخ شروع الزامی است.");
if (targetStep === 1 && !startTime) nextWarnings.push("ساعت شروع الزامی است.");
const startAt = toIsoFromParts(startDate, startTime);
const endAt = toIsoFromParts(endDate || startDate, endTime || startTime);
if (targetStep === 1 && new Date(endAt) < new Date(startAt)) nextWarnings.push("تاریخ پایان نباید قبل از تاریخ شروع باشد.");
if (targetStep === 2 && type === "online_class" && !onlineLink.trim()) nextWarnings.push("برای کلاس آنلاین، لینک ورود یا لینک جلسه را وارد کنید.");
setWarnings(nextWarnings);
return { valid: nextWarnings.length === 0, startAt, endAt };
};
const submit = (event: React.FormEvent) => {
event.preventDefault();
if (step < 3) {
const result = validate(step);
if (result.valid) {
setWarnings([]);
setStep((value) => value + 1);
}
return;
}
const first = validate(0);
const timing = validate(1);
const connection = validate(2);
if (!first.valid || !timing.valid || !connection.valid) return;
const definition = getType(type);
onCreate({
title: title.trim(),
description,
type,
status,
startAt: timing.startAt,
endAt: timing.endAt,
allDay: false,
color: definition.color,
connectedItem: { type: connectedType as CalendarConnectedItemType, title: connectedType === "none" ? "هیچ‌کدام" : connectionLabel(connectedType) },
onlineLink: onlineLink || undefined,
recordingLink: recordingLink || undefined,
roleTargets: role === "manager" ? ["employee"] : ["manager", "employee"],
organizationFa: audience,
participantsCount: 1,
reminders: [{ id: `rem-${Date.now()}`, titleFa: `${toPersianDigits(reminder)} دقیقه قبل`, minutesBefore: Number(reminder), channels: ["in_app"], state: "pending", recipientsCount: 1 }],
attendanceEnabled: type === "online_class" || type === "webinar" || type === "in_person_class"
});
};
return (
<form onSubmit={submit} className="ui-event-wizard flex min-h-0 w-full max-w-full flex-1 flex-col">
<div className="mb-3 grid min-w-0 shrink-0 grid-cols-4 gap-1.5 sm:gap-2" aria-label="مراحل ایجاد رویداد">{["مشخصات", "زمان", "دسترسی", "مرور"].map((label, index) => <div key={label} title={label} className={`min-w-0 truncate rounded-xl px-1 py-2 text-center text-xs font-bold sm:px-2 ${index === step ? "bg-sky-600 text-white" : index < step ? "bg-emerald-600/15 text-emerald-700 dark:text-emerald-300" : "ui-card"}`} aria-current={index === step ? "step" : undefined}><span>{toPersianDigits(index + 1)}</span><span className="hidden sm:inline">. {label}</span><span className="sr-only">. {label}</span></div>)}</div>
{!!warnings.length && <div role="alert" className="mb-3 shrink-0 rounded-2xl bg-rose-500/15 p-3 text-sm font-bold leading-6">{warnings.map((warning) => <p key={warning}>{warning}</p>)}</div>}
<div className="min-h-0 min-w-0 flex-1 overflow-hidden">
{step === 0 && <div className="grid gap-3 md:grid-cols-2">
<TextField label="عنوان رویداد" value={title} onChange={setTitle} required />
<label className="text-sm font-bold">نوع رویداد<select value={type} onChange={(event) => setType(event.target.value as CalendarEventType)} className="mt-2 w-full rounded-2xl p-3">{calendarEventTypeDefinitions.map((item) => <option key={item.type} value={item.type}>{item.labelFa}</option>)}</select></label>
<label className="text-sm font-bold md:col-span-2">توضیحات<textarea value={description} onChange={(event) => setDescription(event.target.value)} className="mt-2 min-h-20 w-full rounded-2xl border border-[var(--color-input-border)] bg-[var(--color-input-bg)] p-3 outline-none focus:ring-2 focus:ring-[var(--color-focus-ring)]" /></label>
</div>}
{step === 1 && <div className="space-y-3"><PersianDateTimePicker dateLabel="تاریخ شروع" timeLabel="ساعت شروع" date={startDate} time={startTime} onDateChange={setStartDate} onTimeChange={setStartTime} /><div className="grid gap-3 md:grid-cols-2"><PersianDatePicker label="تاریخ پایان" value={endDate} onChange={setEndDate} /><PersianTimePicker label="ساعت پایان" value={endTime} onChange={setEndTime} /></div></div>}
{step === 2 && <div className="grid gap-3 md:grid-cols-2">
<label className="text-sm font-bold">اتصال آموزشی<select value={connectedType} onChange={(event) => setConnectedType(event.target.value)} className="mt-2 w-full rounded-2xl p-3">{["دوره", "میکروآموزش", "آزمون", "کلاس آنلاین", "بوتکمپ", "هیچ‌کدام"].map((item) => <option key={item} value={connectionValue(item)}>{item}</option>)}</select></label>
<TextField label="مخاطبان" value={audience} onChange={setAudience} />
<TextField label="لینک ورود به رویداد" value={onlineLink} onChange={setOnlineLink} />
<TextField label="لینک ضبط دوره" value={recordingLink} onChange={setRecordingLink} />
</div>}
{step === 3 && <div className="grid gap-3 md:grid-cols-2">
<label className="text-sm font-bold">یادآوری<select value={reminder} onChange={(event) => setReminder(event.target.value)} className="mt-2 w-full rounded-2xl p-3"><option value="15">۱۵ دقیقه قبل</option><option value="60">۱ ساعت قبل</option><option value="1440">۱ روز قبل</option></select></label>
<label className="text-sm font-bold">وضعیت انتشار<select value={status} onChange={(event) => setStatus(event.target.value as CalendarEventStatus)} className="mt-2 w-full rounded-2xl p-3"><option value="draft">پیشنویس</option><option value="scheduled">زمانبندیشده</option><option value="published">منتشرشده</option></select></label>
<div className="ui-card rounded-2xl p-3 text-sm md:col-span-2"><b>{title || "بدون عنوان"}</b><p className="mt-1 text-[var(--color-text-secondary)]">{startDate}، {startTime} تا {endTime} · {audience}</p></div>
</div>}
</div>
<div className="mt-3 flex shrink-0 justify-between gap-2 border-t border-[var(--color-divider)] pt-3"><button type="button" onClick={step === 0 ? onCancel : () => { setWarnings([]); setStep((value) => value - 1); }} className="min-h-11 rounded-2xl bg-[var(--color-card)] px-4 font-bold">{step === 0 ? "انصراف" : "مرحله قبل"}</button><button className="min-h-11 rounded-2xl bg-slate-950 px-5 font-black text-white dark:bg-white dark:text-slate-950">{step === 3 ? "ذخیره رویداد" : "مرحله بعد"}</button></div>
</form>
);
}
export function CalendarSettingsSheet({ role, onClose, onSaved }: { role: UserRole; onClose: () => void; onSaved: (settings: CalendarSettings) => void }) {
return <AccessibleDialog open title="تنظیمات تقویم" onClose={onClose} panelClassName="popup-scroll max-w-4xl overflow-auto"><CalendarSettingsForm role={role} onSaved={onSaved} /></AccessibleDialog>;
}
export function CalendarSettingsPage({ role, embedded, onSaved }: { role: UserRole; embedded?: boolean; onSaved?: (settings: CalendarSettings) => void }) {
return <CalendarShell><div className="mx-auto w-full max-w-5xl overflow-auto mac-scrollbar"><h1 className="text-2xl font-black">تنظیمات تقویم</h1><CalendarSettingsForm role={role} embedded={embedded} onSaved={onSaved} /></div></CalendarShell>;
}
function CalendarSettingsForm({ role, onSaved }: { role: UserRole; embedded?: boolean; onSaved?: (settings: CalendarSettings) => void }) {
const [settings, setSettings] = useState<CalendarSettings>(() => getSettings(role));
const patch = (payload: Partial<CalendarSettings>) => setSettings((current) => ({ ...current, ...payload }));
return (
<div className="mt-5 space-y-4">
<div className="grid gap-4 md:grid-cols-2">
<SettingSelect label="نمای پیش‌فرض تقویم" value={settings.defaultView} onChange={(value) => patch({ defaultView: value as CalendarViewMode })} options={["month", "week", "day", "list", "timeline"].map((value) => ({ value, label: viewLabels[value as CalendarViewMode] }))} />
<SettingSelect label="زمان یادآوری پیش‌فرض" value={String(settings.defaultReminderMinutes)} onChange={(value) => patch({ defaultReminderMinutes: Number(value) })} options={[{ value: "15", label: "۱۵ دقیقه قبل" }, { value: "60", label: "۱ ساعت قبل" }, { value: "1440", label: "۱ روز قبل" }]} />
<ToggleSetting label="نمایش/عدم نمایش رویدادهای تکمیل‌شده" checked={settings.showCompletedEvents} onChange={(checked) => patch({ showCompletedEvents: checked })} />
<ToggleSetting label="نمایش/عدم نمایش روزهای تعطیل" checked={settings.showWeekends} onChange={(checked) => patch({ showWeekends: checked })} />
<SettingSelect label="تراکم تقویم" value={settings.density} onChange={(value) => patch({ density: value as CalendarSettings["density"] })} options={[{ value: "comfortable", label: "راحت" }, { value: "compact", label: "فشرده" }, { value: "dense", label: "متراکم" }]} />
<ToggleSetting label="صدای اعلان‌ها" checked={settings.notificationSound} onChange={(checked) => patch({ notificationSound: checked })} />
<TextField label="ساعت کاری شروع" value={settings.workingHours.start} onChange={(value) => patch({ workingHours: { ...settings.workingHours, start: value } })} />
<TextField label="ساعت کاری پایان" value={settings.workingHours.end} onChange={(value) => patch({ workingHours: { ...settings.workingHours, end: value } })} />
</div>
<section className="liquid-glass rounded-[24px] p-4">
<h3 className="font-black">رنگبندی رویدادها</h3>
<div className="mt-3 grid gap-2 md:grid-cols-3">{calendarEventTypeDefinitions.slice(0, 9).map((item) => <label key={item.type} className="flex items-center justify-between gap-2 rounded-2xl bg-white/15 p-3 text-sm"><span>{item.labelFa}</span><input type="color" value={settings.eventColorPreferences[item.type] ?? item.color} onChange={(event) => patch({ eventColorPreferences: { ...settings.eventColorPreferences, [item.type]: event.target.value } })} /></label>)}</div>
</section>
<button onClick={() => onSaved?.(saveSettings(settings))} className="rounded-2xl bg-slate-950 px-5 py-3 font-black text-white dark:bg-white dark:text-slate-950">ذخیره</button>
</div>
);
}
export function CalendarEmptyState() {
return <div className="grid h-[360px] place-items-center rounded-3xl border border-dashed border-white/30 bg-white/15 text-center"><div><CalendarDays className="mx-auto opacity-60" size={42} /><h3 className="mt-4 font-black">رویدادی برای این روز وجود ندارد</h3></div></div>;
}
function TextField({ label, value, onChange, required }: { label: string; value: string; onChange: (value: string) => void; required?: boolean }) {
return <label className="text-sm font-bold">{label}<input required={required} value={value} onChange={(event) => onChange(event.target.value)} className="mt-2 w-full rounded-2xl border border-white/20 bg-white/20 p-3 outline-none focus:ring-2 focus:ring-sky-400/40" /></label>;
}
function SettingSelect({ label, value, onChange, options }: { label: string; value: string; onChange: (value: string) => void; options: { value: string; label: string }[] }) {
return <label className="liquid-glass rounded-[24px] p-4 text-sm font-bold">{label}<select value={value} onChange={(event) => onChange(event.target.value)} className="mt-2 w-full rounded-2xl p-3">{options.map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}</select></label>;
}
function ToggleSetting({ label, checked, onChange }: { label: string; checked: boolean; onChange: (checked: boolean) => void }) {
return <div className="liquid-glass flex items-center justify-between rounded-[24px] p-4 text-sm font-bold"><span>{label}</span><MacToggle checked={checked} onChange={onChange} /></div>;
}
function Info({ label, value }: { label: string; value: string }) {
return <div className="rounded-2xl bg-white/18 p-3"><p className="text-xs opacity-65">{label}</p><p className="mt-1 text-sm font-black">{toPersianDigits(value)}</p></div>;
}
function Toast({ message }: { message: string }) {
return <div className="fixed bottom-6 right-6 z-[70] rounded-2xl bg-slate-950 px-4 py-3 text-sm font-black text-white shadow-2xl dark:bg-white dark:text-slate-950">{message}</div>;
}
export function isPastEvent(event: CalendarEvent) {
return new Date(event.endAt).getTime() < Date.now();
}
export function isOngoingEvent(event: CalendarEvent) {
const now = Date.now();
return new Date(event.startAt).getTime() <= now && new Date(event.endAt).getTime() >= now;
}
export function isFutureEvent(event: CalendarEvent) {
return new Date(event.startAt).getTime() > Date.now();
}
export function getEventPrimaryAction(event: CalendarEvent) {
if (isPastEvent(event)) {
if (event.recordingLink) return { label: "مشاهده ضبط دوره", href: event.recordingLink, disabled: false };
return { label: "ضبط در دسترس نیست", disabled: true, description: "ضبط این رویداد هنوز در دسترس نیست" };
}
if (isOngoingEvent(event)) {
if (event.onlineLink) return { label: "ورود به رویداد", href: event.onlineLink, disabled: false };
return { label: "ورود در زمان شروع فعال می‌شود", disabled: true };
}
return { label: "افزودن یادآوری", disabled: false, description: "یادآوری تنظیم شد" };
}
function getType(type: CalendarEventType) {
return calendarEventTypeDefinitions.find((item) => item.type === type) ?? calendarEventTypeDefinitions[0];
}
function buildMonthDays(anchor: Date) {
const first = new Date(anchor.getFullYear(), anchor.getMonth(), 1);
const startOffset = (first.getDay() + 1) % 7;
const start = new Date(first);
start.setDate(first.getDate() - startOffset);
return Array.from({ length: 42 }, (_, index) => {
const date = new Date(start);
date.setDate(start.getDate() + index);
return { date, currentMonth: date.getMonth() === anchor.getMonth() };
});
}
function buildWeekDays(anchor: Date) {
const start = new Date(anchor);
start.setDate(anchor.getDate() - ((anchor.getDay() + 1) % 7));
return Array.from({ length: 7 }, (_, index) => {
const date = new Date(start);
date.setDate(start.getDate() + index);
return date;
});
}
function addByView(date: Date, view: CalendarViewMode, direction: -1 | 1) {
const next = new Date(date);
if (view === "month") next.setMonth(next.getMonth() + direction);
else if (view === "week") next.setDate(next.getDate() + direction * 7);
else next.setDate(next.getDate() + direction);
return next;
}
function isSameDay(value: string | Date, date: string | Date) {
const a = new Date(value);
const b = new Date(date);
return a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth() && a.getDate() === b.getDate();
}
function moveEventToDay(event: CalendarEvent, day: Date) {
const start = new Date(event.startAt);
const end = new Date(event.endAt);
const duration = end.getTime() - start.getTime();
start.setFullYear(day.getFullYear(), day.getMonth(), day.getDate());
const nextEnd = new Date(start.getTime() + duration);
return { ...event, startAt: start.toISOString(), endAt: nextEnd.toISOString() };
}
function toYmd(date: Date) {
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}`;
}
function dateFromYmd(value: string) {
return new Date(`${value}T09:00:00`);
}
function toIsoFromParts(date: string, time: string) {
return new Date(`${date}T${time}:00`).toISOString();
}
function connectionLabel(value: string) {
return ({ course: "دوره", micro_learning: "میکروآموزش", exam: "آزمون", online_class: "کلاس آنلاین", bootcamp: "بوتکمپ", none: "هیچ‌کدام" } as Record<string, string>)[value] ?? "هیچ‌کدام";
}
function connectionValue(label: string) {
return ({ "دوره": "course", "میکروآموزش": "micro_learning", "آزمون": "exam", "کلاس آنلاین": "online_class", "بوتکمپ": "bootcamp", "هیچ‌کدام": "none" } as Record<string, string>)[label] ?? "none";
}
function applyRoleScope(events: CalendarEvent[], role: UserRole, mode: CalendarRouteMode) {
if (mode === "system" && role !== "super_admin") return [];
if (role === "employee") return events.filter((event) => event.roleTargets?.includes("employee") || event.userIds?.length || event.id.startsWith("ev-local"));
if (role === "manager") return events.filter((event) => event.roleTargets?.includes("manager") || event.teamIds?.length || event.roleTargets?.includes("employee") || event.id.startsWith("ev-local"));
if (role === "org_admin") return events;
return events;
}
export const CalendarGridMonth = CalendarMonthView;
export const CalendarEventDetailWindow = CalendarEventDetail;
export const CalendarEventCreateWizard = CalendarEventCreateForm;
export const CalendarSearchBar = Search;
export const CalendarMobileView = CalendarDayView;
export const CalendarBottomSheet = CalendarEventDetail;
export const CalendarMacWindowIntegration = EducationalCalendarPage;