refactor: unify calendar frontend state and event loading

This commit is contained in:
lizerk 2026-04-16 19:12:36 +03:00
parent b4c6c96c53
commit 31d779bee3
11 changed files with 439 additions and 357 deletions

View File

@ -14,7 +14,18 @@ import { EventModal } from "@/components/EventModal";
import { ViewDayEventsModal } from "@/components/ViewDayEventsModal";
import { ViewEventDetailsModal } from "@/components/ViewEventDetailsModal";
import { DEFAULT_EVENT_COLOR, resolveEventColor } from "@/lib/calendar-event-colors";
import { calendarService } from "@/services/calendarService";
import {
buildCalendarOptions,
canOpenParticipantsModal,
isDepartmentCalendar,
readLinkedCalendarState,
resolveSelectedCalendar,
} from "@/lib/calendar/ui";
import {
calendarService,
type CalendarEvent as CalendarEventData,
type CalendarEventDraft,
} from "@/services/calendarService";
import "react-big-calendar/lib/css/react-big-calendar.css";
import "../app/calendar/calendar.css";
@ -31,22 +42,10 @@ const localizer = dateFnsLocalizer({
locales,
});
// Типы для событий
type CalendarEvent = {
id: number;
title: string;
description?: string;
type CalendarDisplayEvent = CalendarEventData & {
start: Date;
end: Date;
calendar: number;
color_event?: string;
rule?: number;
rule_description?: string;
isOccurrence?: boolean; // Флаг для повторяющихся событий
event_id?: number; // ID базового события для occurrence
allDay?: boolean; // Флаг целодневного события
can_edit?: boolean;
can_delete?: boolean;
allDay: boolean;
};
// Кастомные сообщения на русском
@ -67,7 +66,7 @@ const messages = {
};
// Кастомный тулбар с улучшенным UI
const CustomToolbar = ({ label, onNavigate, onView, view, date }: ToolbarProps<CalendarEvent, object>) => {
const CustomToolbar = ({ onNavigate, onView, view, date }: ToolbarProps<CalendarDisplayEvent, object>) => {
const viewLabels = {
month: "Месяц",
week: "Неделя",
@ -141,14 +140,14 @@ export function BigCalendar() {
canUseAllCalendarsMode,
reloadCalendars,
} = useCalendar();
const [events, setEvents] = useState<CalendarEvent[]>([]);
const [events, setEvents] = useState<CalendarEventData[]>([]);
const [loading, setLoading] = useState(false);
const [currentView, setCurrentView] = useState<View>("month");
const [currentDate, setCurrentDate] = useState(new Date());
// Модальные окна
const [showEventModal, setShowEventModal] = useState(false);
const [editingEvent, setEditingEvent] = useState<Partial<CalendarEvent> | null>(null);
const [editingEvent, setEditingEvent] = useState<CalendarEventDraft | null>(null);
const [showCalendarModal, setShowCalendarModal] = useState(false);
const [editingCalendar, setEditingCalendar] = useState<{ id?: number; name: string } | null>(null);
const [showCalendarActions, setShowCalendarActions] = useState(false);
@ -159,53 +158,26 @@ export function BigCalendar() {
const [showDayEventsModal, setShowDayEventsModal] = useState(false);
const [selectedDate, setSelectedDate] = useState<Date | null>(null);
const [showEventDetailsModal, setShowEventDetailsModal] = useState(false);
const [viewingEvent, setViewingEvent] = useState<CalendarEvent | null>(null);
const [viewingEvent, setViewingEvent] = useState<CalendarEventDraft | null>(null);
const eventsRequestIdRef = useRef(0);
const linkedEventId = Number(searchParams.get("event") || "");
const linkedCalendarId = Number(searchParams.get("calendar") || "");
const linkedCalendarName = searchParams.get("calendarName") || "";
const linkedCalendarType = searchParams.get("calendarType") || "";
const linkedContextType = searchParams.get("contextType") || "";
const linkedCalendarState = useMemo(
() => readLinkedCalendarState(searchParams),
[searchParams],
);
const selectedCalendar = useMemo(() => {
const calendar = calendars.find((item) => item.id === selectedCalendarId);
if (calendar) return calendar;
if (selectedCalendarId && linkedCalendarName) {
return {
id: selectedCalendarId,
name: linkedCalendarName,
slug: `linked-calendar-${selectedCalendarId}`,
type: linkedCalendarType || null,
context_type: linkedContextType || null,
can_create_events: true,
can_edit_calendar: false,
can_manage_participants: false,
};
}
return null;
}, [calendars, linkedCalendarName, linkedCalendarType, linkedContextType, selectedCalendarId]);
return resolveSelectedCalendar(calendars, selectedCalendarId, linkedCalendarState);
}, [calendars, linkedCalendarState, selectedCalendarId]);
const calendarOptions = useMemo(() => {
if (!selectedCalendar) return calendars;
if (calendars.some((item) => item.id === selectedCalendar.id)) {
return calendars;
}
return [...calendars, selectedCalendar];
return buildCalendarOptions(calendars, selectedCalendar);
}, [calendars, selectedCalendar]);
const hasCalendarSelection = calendarOptions.length > 0 || selectedCalendar !== null;
const canOpenParticipantsModal = Boolean(
selectedCalendar && (
selectedCalendar.can_manage_participants ||
selectedCalendar.type === "department" ||
selectedCalendar.context_type === "department"
)
);
const isDepartmentCalendar = Boolean(
selectedCalendar && (
selectedCalendar.type === "department" ||
selectedCalendar.context_type === "department"
)
);
const participantsModalAvailable = canOpenParticipantsModal(selectedCalendar);
const departmentCalendar = isDepartmentCalendar(selectedCalendar);
const clearEventParam = useCallback(() => {
if (!searchParams.get("event")) return;
@ -219,6 +191,29 @@ export function BigCalendar() {
setSelectedCalendarId(linkedCalendarId);
}, [linkedCalendarId, setSelectedCalendarId]);
const displayEvents = useMemo<CalendarDisplayEvent[]>(() => {
return events.reduce<CalendarDisplayEvent[]>((result, event) => {
if (!event.start || !event.end) {
return result;
}
const start = new Date(event.start);
const end = new Date(event.end);
if (Number.isNaN(start.getTime()) || Number.isNaN(end.getTime())) {
return result;
}
result.push({
...event,
start,
end,
allDay: false,
});
return result;
}, []);
}, [events]);
// Загрузка событий
const loadEvents = useCallback(async () => {
const requestId = ++eventsRequestIdRef.current;
@ -226,79 +221,18 @@ export function BigCalendar() {
try {
setLoading(true);
// Вычисляем диапазон для загрузки (месяц ±1 неделя)
const start = new Date(currentDate);
start.setDate(1);
const start = new Date(currentDate.getFullYear(), currentDate.getMonth(), 1);
start.setDate(start.getDate() - 7);
const end = new Date(currentDate);
end.setMonth(end.getMonth() + 1);
end.setDate(7);
const startStr = start.toISOString().split("T")[0];
const endStr = end.toISOString().split("T")[0];
// Загружаем обычные события и occurrences параллельно
// Если selectedCalendarId === null, загружаем все доступные события со всех календарей
const [eventsResult, occurrencesResult] = await Promise.all([
apiClient.getCalendarEvents({
calendar: requestedCalendarId || undefined,
scope: canUseAllCalendarsMode && calendarScope === "all" ? "all" : undefined,
start: startStr,
end: endStr,
}),
apiClient.getOccurrences({
calendar: requestedCalendarId || undefined,
scope: canUseAllCalendarsMode && calendarScope === "all" ? "all" : undefined,
start: startStr,
end: endStr,
}),
]);
// Обработка обычных событий (без правил повторения)
const eventsList = Array.isArray(eventsResult) ? eventsResult : (eventsResult?.results || []);
const regularEvents = eventsList
.filter((evt: any) => !evt.rule) // Только события без правил
.map((evt: any) => {
const startDate = new Date(evt.start);
const endDate = new Date(evt.end);
return {
...evt,
start: startDate,
end: endDate,
allDay: false, // Явно указываем что событие с временем
};
});
// Обработка occurrences (повторяющихся событий)
const occurrencesList = Array.isArray(occurrencesResult) ? occurrencesResult : (occurrencesResult?.results || []);
const occurrenceEvents = occurrencesList
.filter((occ: any) => occ && occ.is_recurring) // Только повторяющиеся (обычные события уже в regularEvents)
.map((occ: any) => ({
id: occ.id,
title: occ.title.startsWith('⟲') ? occ.title : `⟲ ${occ.title}`, // Добавляем индикатор повторения
description: occ.description,
start: new Date(occ.start),
end: new Date(occ.end),
allDay: false, // Явно указываем что событие с временем
calendar: occ.calendar, // Используем calendar из occurrence (важно для "Все события")
color_event: resolveEventColor(occ.color_event),
event_id: occ.event_id,
rule: occ.rule,
can_edit: occ.can_edit,
can_delete: occ.can_delete,
isOccurrence: true, // Помечаем как occurrence для корректного отображения
}));
// Объединяем обычные события и occurrences
let nextEvents = [...regularEvents, ...occurrenceEvents];
if (requestedCalendarId !== null) {
nextEvents = nextEvents.filter((event) => event.calendar === requestedCalendarId);
}
const end = new Date(currentDate.getFullYear(), currentDate.getMonth() + 1, 7);
const nextEvents = await calendarService.loadEvents(
start,
end,
requestedCalendarId,
calendarScope,
canUseAllCalendarsMode,
0,
);
// Если параллельно ушёл более новый запрос, его ответ приоритетнее.
if (requestId !== eventsRequestIdRef.current) {
@ -360,7 +294,7 @@ export function BigCalendar() {
// Создание события из модала просмотра дня
const handleCreateEventFromDay = useCallback(() => {
if (!selectedCalendarId) {
if (selectedCalendarId === null) {
alert("Сначала выберите календарь");
return;
}
@ -378,25 +312,31 @@ export function BigCalendar() {
const endDate = new Date(selectedDate);
endDate.setHours(11, 0, 0, 0);
setEditingEvent({
const nextDraft: CalendarEventDraft = {
title: "",
description: "",
start: startDate,
end: endDate,
calendar: selectedCalendarId,
color_event: DEFAULT_EVENT_COLOR,
});
};
setEditingEvent(nextDraft);
// Закрываем модал просмотра дня и открываем модал создания
setShowDayEventsModal(false);
setShowEventModal(true);
}, [selectedCalendar?.can_create_events, selectedCalendarId, selectedDate]);
const handleSelectEvent = useCallback(async (calEvent: CalendarEvent) => {
const handleSelectEvent = useCallback(async (calEvent: CalendarDisplayEvent) => {
// Если это occurrence (повторяющееся событие), загружаем базовое событие
if (calEvent.isOccurrence && calEvent.event_id) {
if (calEvent.is_recurring && calEvent.event_id) {
try {
const fullEvent = await apiClient.getEvent(calEvent.event_id);
const fullEvent = await calendarService.loadFullEvent(
calEvent.event_id,
calEvent.start.toISOString(),
calEvent.end.toISOString(),
);
// Устанавливаем время из occurrence, но остальные данные из базового события
setViewingEvent({
...fullEvent,
@ -428,16 +368,16 @@ export function BigCalendar() {
}, []);
const handleEditCalendar = useCallback(() => {
if (!selectedCalendar?.can_edit_calendar || isDepartmentCalendar) {
if (!selectedCalendar?.can_edit_calendar || departmentCalendar) {
return;
}
setEditingCalendar({ id: selectedCalendar.id, name: selectedCalendar.name });
setShowCalendarModal(true);
setShowCalendarActions(false);
}, [isDepartmentCalendar, selectedCalendar]);
}, [departmentCalendar, selectedCalendar]);
const handleDeleteCalendar = useCallback(async () => {
if (!selectedCalendar?.id || !selectedCalendar.can_edit_calendar || isDepartmentCalendar) {
if (!selectedCalendar?.id || !selectedCalendar.can_edit_calendar || departmentCalendar) {
return;
}
if (!confirm(`Удалить календарь "${selectedCalendar.name}"?`)) {
@ -455,7 +395,7 @@ export function BigCalendar() {
console.error("Не удалось удалить календарь:", error);
alert("Не удалось удалить календарь");
}
}, [isDepartmentCalendar, loadEvents, reloadCalendars, selectedCalendar, setSelectedCalendarId]);
}, [departmentCalendar, loadEvents, reloadCalendars, selectedCalendar, setSelectedCalendarId]);
const handleExportCalendar = useCallback(async () => {
if (!selectedCalendar?.id) return;
@ -487,10 +427,11 @@ export function BigCalendar() {
try {
const result = await calendarService.importFromICS(selectedCalendar.id, file);
alert(`Импорт завершен!\nИмпортировано: ${result.imported}\nПропущено: ${result.skipped}`);
calendarService.clearCache();
loadEvents();
} catch (error: any) {
} catch (error: unknown) {
console.error("Не удалось импортировать календарь:", error);
alert(`Ошибка импорта: ${error.message}`);
alert(`Ошибка импорта: ${error instanceof Error ? error.message : "Не удалось импортировать календарь"}`);
} finally {
if (fileInputRef.current) {
fileInputRef.current.value = "";
@ -500,13 +441,17 @@ export function BigCalendar() {
}, [loadEvents, selectedCalendar]);
// Клик на событие из модала просмотра дня
const handleEventClickFromDay = useCallback(async (event: any) => {
const handleEventClickFromDay = useCallback(async (event: CalendarEventData) => {
setShowDayEventsModal(false);
// Если это occurrence, загружаем базовое событие
if (event.isOccurrence && event.event_id) {
if (event.is_recurring && event.event_id) {
try {
const fullEvent = await apiClient.getEvent(event.event_id);
const fullEvent = await calendarService.loadFullEvent(
event.event_id,
typeof event.start === "string" ? event.start : undefined,
typeof event.end === "string" ? event.end : undefined,
);
setViewingEvent({
...fullEvent,
start: event.start,
@ -524,7 +469,7 @@ export function BigCalendar() {
// Стилизация событий по цвету
const eventStyleGetter = (event: CalendarEvent) => {
const eventStyleGetter = (event: CalendarDisplayEvent) => {
const backgroundColor = resolveEventColor(event.color_event);
return {
style: {
@ -562,7 +507,7 @@ export function BigCalendar() {
</div>
)}
{calendars.length > 0 && (
{hasCalendarSelection && (
<div className="relative z-30">
<button
type="button"
@ -602,12 +547,12 @@ export function BigCalendar() {
<button
type="button"
onClick={() => {
if (canOpenParticipantsModal) {
if (participantsModalAvailable && selectedCalendar) {
setShowParticipantsModal(true);
}
setShowCalendarActions(false);
}}
disabled={!canOpenParticipantsModal}
disabled={!participantsModalAvailable}
className="flex w-full items-center gap-2 px-4 py-2 text-sm text-[var(--foreground)] transition hover:bg-[var(--surface-secondary)]"
>
<Users size={16} />
@ -646,7 +591,7 @@ export function BigCalendar() {
className="hidden"
/>
{!isDepartmentCalendar ? (
{!departmentCalendar ? (
<>
<div className="app-divider my-1 border-t"></div>
<button
@ -680,7 +625,7 @@ export function BigCalendar() {
</div>
</div>
{calendars.length === 0 ? (
{!hasCalendarSelection ? (
<div className="flex flex-wrap items-center gap-2">
<p className="app-text-muted text-sm">Календарей нет.</p>
<button
@ -755,10 +700,10 @@ export function BigCalendar() {
<div className="calendar-shell h-full rounded-[1.4rem]">
<Calendar
localizer={localizer}
events={events}
events={displayEvents}
startAccessor="start"
endAccessor="end"
allDayAccessor={(event: CalendarEvent) => event.allDay || false}
allDayAccessor={(event: CalendarDisplayEvent) => event.allDay || false}
messages={messages}
culture="ru"
view={currentView}
@ -788,6 +733,7 @@ export function BigCalendar() {
}}
event={editingEvent}
onSave={() => {
calendarService.clearCache();
loadEvents(); // ✅ Обновляем список событий
}}
showParticipants={true}
@ -821,7 +767,7 @@ export function BigCalendar() {
if (!confirm("Удалить это событие?")) return;
try {
await apiClient.deleteEvent(viewingEvent.id);
await calendarService.deleteEvent(viewingEvent.id);
setShowEventDetailsModal(false);
setViewingEvent(null);
loadEvents();
@ -849,7 +795,6 @@ export function BigCalendar() {
onClose={() => setShowParticipantsModal(false)}
calendarId={selectedCalendar.id}
calendarName={selectedCalendar.name}
userRole={(selectedCalendar as any).user_role}
canManageParticipants={selectedCalendar.can_manage_participants}
calendarType={selectedCalendar.type}
contextType={selectedCalendar.context_type}

View File

@ -1,49 +1,18 @@
'use client';
import React, { useState, useEffect } from 'react';
import { useState, useEffect, useCallback } from 'react';
import { X, UserPlus, Trash2 } from 'lucide-react';
import api from '@/lib/api';
import { Modal } from '@/components/ui';
import { loadAllPages } from '@/lib/shared';
import { resolveMediaUrl } from '@/lib/url';
interface Participant {
id: number;
user: {
id: number;
username: string;
first_name: string;
last_name: string;
email?: string;
avatar?: string | null;
};
distinction: string;
}
interface Employee {
id: number;
username?: string;
first_name: string;
last_name: string;
email?: string;
avatar?: string | null;
departments?: Array<{
id: number;
name: string;
}>;
}
interface Department {
id: number;
name: string;
}
import type { CalendarParticipant, Department, User } from '@/types/api';
interface CalendarParticipantsModalProps {
isOpen: boolean;
onClose: () => void;
calendarId: number;
calendarName: string;
userRole?: string; // owner, editor, viewer
canManageParticipants?: boolean;
calendarType?: string | null;
contextType?: string | null;
@ -54,13 +23,12 @@ export default function CalendarParticipantsModal({
onClose,
calendarId,
calendarName,
userRole,
canManageParticipants = false,
calendarType,
contextType,
}: CalendarParticipantsModalProps) {
const [participants, setParticipants] = useState<Participant[]>([]);
const [employees, setEmployees] = useState<Employee[]>([]);
const [participants, setParticipants] = useState<CalendarParticipant[]>([]);
const [employees, setEmployees] = useState<User[]>([]);
const [departments, setDepartments] = useState<Department[]>([]);
const [selectedUserIds, setSelectedUserIds] = useState<number[]>([]);
const [selectedRole, setSelectedRole] = useState<string>('viewer');
@ -72,40 +40,45 @@ export default function CalendarParticipantsModal({
calendarType === 'department' || contextType === 'department';
const canManage = canManageParticipants && !isDepartmentCalendar;
useEffect(() => {
if (isOpen) {
loadParticipants();
loadEmployees();
loadDepartments();
}
}, [isOpen, calendarId]);
const loadParticipants = async () => {
const loadParticipants = useCallback(async () => {
try {
const data = await api.getCalendarParticipants(calendarId);
setParticipants(data);
} catch (error) {
console.error('Failed to load participants:', error);
}
};
}, [calendarId]);
const loadEmployees = async () => {
const loadEmployees = useCallback(async () => {
try {
const allEmployees = await loadAllPages<Employee>((params) => api.getEmployees(params));
const allEmployees = await loadAllPages<User>((params) => api.getEmployees(params));
setEmployees(allEmployees);
} catch (error) {
console.error('Failed to load employees:', error);
}
};
}, []);
const loadDepartments = async () => {
const loadDepartments = useCallback(async () => {
try {
const data = await api.getDepartments({ limit: 100 });
setDepartments(data.results || []);
} catch (error) {
console.error('Failed to load departments:', error);
}
};
}, []);
useEffect(() => {
if (!isOpen) {
return;
}
void loadParticipants();
if (canManage) {
void loadEmployees();
void loadDepartments();
}
}, [canManage, isOpen, loadDepartments, loadEmployees, loadParticipants]);
const handleAddParticipants = async () => {
if (selectedUserIds.length === 0) {
@ -174,7 +147,7 @@ export default function CalendarParticipantsModal({
}
};
const filteredEmployees = employees.filter(emp => {
const filteredEmployees = employees.filter((emp) => {
const fullName = `${emp.first_name} ${emp.last_name} ${emp.email || ''}`.toLowerCase();
const matchesSearch = fullName.includes(searchQuery.toLowerCase());
@ -301,6 +274,7 @@ export default function CalendarParticipantsModal({
className="h-4 w-4 rounded border-[var(--border-strong)] text-[var(--accent-primary)]"
/>
{emp.avatar ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={resolveMediaUrl(emp.avatar)}
alt={`${emp.first_name} ${emp.last_name}`.trim() || 'Сотрудник'}
@ -379,6 +353,7 @@ export default function CalendarParticipantsModal({
>
<div className="flex items-center gap-3">
{user.avatar ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={resolveMediaUrl(user.avatar)}
alt={`${firstName} ${lastName}`.trim() || 'Сотрудник'}
@ -415,7 +390,11 @@ export default function CalendarParticipantsModal({
{canManage && participant.distinction !== 'owner' && (
<button
onClick={() => handleRemoveParticipant(participant.user.id)}
onClick={() => {
if (participant.user) {
void handleRemoveParticipant(participant.user.id);
}
}}
disabled={loading}
className="app-action-danger rounded-lg p-2 transition disabled:opacity-50"
title="Удалить участника"

View File

@ -6,21 +6,28 @@ import { ChevronDown, ChevronLeft, ChevronRight, Pencil, Trash2, Users } from "l
import { apiClient } from "@/lib/api";
import { useCalendar } from "@/contexts/CalendarContext";
import { useCalendarEvents } from "@/hooks/calendar/useCalendarEvents";
import { calendarService, formatDateKey, type CalendarEvent } from "@/services/calendarService";
import {
calendarService,
formatDateKey,
type CalendarEvent,
type CalendarEventDraft,
} from "@/services/calendarService";
import { DEFAULT_EVENT_COLOR, resolveEventColor } from "@/lib/calendar-event-colors";
import {
buildCalendarOptions,
canOpenParticipantsModal,
isDepartmentCalendar,
readLinkedCalendarState,
resolveSelectedCalendar,
toParticipantsTarget,
type CalendarParticipantsTarget,
} from "@/lib/calendar/ui";
// Типы
interface CalendarCardProps {
onOpenCalendarModal: (calendar?: { id?: number; name: string }) => void;
onOpenEventModal: (event: any, date?: Date) => void;
onOpenParticipantsModal: (calendar: {
id: number;
name: string;
user_role?: string;
can_manage_participants?: boolean;
type?: string | null;
context_type?: string | null;
}) => void;
onOpenEventModal: (event: CalendarEventDraft, date?: Date) => void;
onOpenParticipantsModal: (calendar: CalendarParticipantsTarget) => void;
eventsRefreshTrigger: number;
setEventsRefreshTrigger: (value: number | ((prev: number) => number)) => void;
setSidebarEvents: (events: CalendarEvent[]) => void;
@ -56,9 +63,10 @@ export const CalendarCard = memo(function CalendarCard({
reloadCalendars,
} = useCalendar();
const searchParams = useSearchParams();
const linkedCalendarName = searchParams.get("calendarName") || "";
const linkedCalendarType = searchParams.get("calendarType") || "";
const linkedContextType = searchParams.get("contextType") || "";
const linkedCalendarState = useMemo(
() => readLinkedCalendarState(searchParams),
[searchParams],
);
const [monthDate, setMonthDate] = useState(() => {
const now = new Date();
@ -66,50 +74,27 @@ export const CalendarCard = memo(function CalendarCard({
});
const [showCalendarMenu, setShowCalendarMenu] = useState(false);
const [selectedDate, setSelectedDate] = useState<Date | null>(null);
const [eventsViewMode, setEventsViewMode] = useState<"week" | "month">("week");
const fileInputRef = useRef<HTMLInputElement>(null);
const selectedCalendar = useMemo(() => {
const calendar = calendars.find((item) => item.id === selectedCalendarId);
if (calendar) return calendar;
if (selectedCalendarId && linkedCalendarName) {
return {
id: selectedCalendarId,
name: linkedCalendarName,
slug: `linked-calendar-${selectedCalendarId}`,
type: linkedCalendarType || null,
context_type: linkedContextType || null,
can_create_events: true,
can_edit_calendar: false,
can_manage_participants: false,
};
}
return null;
}, [calendars, linkedCalendarName, linkedCalendarType, linkedContextType, selectedCalendarId]);
const calendarOptions = useMemo(() => {
if (!selectedCalendar) return calendars;
if (calendars.some((item) => item.id === selectedCalendar.id)) {
return calendars;
}
return [...calendars, selectedCalendar];
}, [calendars, selectedCalendar]);
const canOpenParticipantsModal = Boolean(
selectedCalendar && (
selectedCalendar.can_manage_participants ||
selectedCalendar.type === "department" ||
selectedCalendar.context_type === "department"
)
const selectedCalendar = useMemo(
() =>
resolveSelectedCalendar(
calendars,
selectedCalendarId,
linkedCalendarState,
),
[calendars, linkedCalendarState, selectedCalendarId],
);
const isDepartmentCalendar = Boolean(
selectedCalendar && (
selectedCalendar.type === "department" ||
selectedCalendar.context_type === "department"
)
const calendarOptions = useMemo(
() => buildCalendarOptions(calendars, selectedCalendar),
[calendars, selectedCalendar],
);
const participantsModalAvailable = canOpenParticipantsModal(selectedCalendar);
const departmentCalendar = isDepartmentCalendar(selectedCalendar);
// Загружаем события через хук
const { events, loading, error } = useCalendarEvents({
monthDate,
@ -147,7 +132,6 @@ export const CalendarCard = memo(function CalendarCard({
if (selectedCalendar?.can_create_events === false) {
return;
}
setSelectedDate(date);
const startDate = new Date(date);
startDate.setHours(10, 0, 0, 0);
const endDate = new Date(date);
@ -189,9 +173,9 @@ export const CalendarCard = memo(function CalendarCard({
const result = await calendarService.importFromICS(selectedCalendarId, file);
alert(`Импорт завершен!\nИмпортировано: ${result.imported}\nПропущено: ${result.skipped}`);
setEventsRefreshTrigger(prev => prev + 1);
} catch (error: any) {
} catch (error: unknown) {
console.error('Failed to import calendar:', error);
alert(`Ошибка импорта: ${error.message}`);
alert(`Ошибка импорта: ${error instanceof Error ? error.message : 'Не удалось импортировать календарь'}`);
} finally {
if (fileInputRef.current) {
fileInputRef.current.value = '';
@ -375,19 +359,14 @@ export const CalendarCard = memo(function CalendarCard({
<div className="app-divider my-1 border-t"></div>
<button
onClick={() => {
if (canOpenParticipantsModal) {
onOpenParticipantsModal({
id: selectedCalendar.id,
name: selectedCalendar.name,
user_role: (selectedCalendar as any).user_role,
can_manage_participants: selectedCalendar.can_manage_participants,
type: selectedCalendar.type,
context_type: selectedCalendar.context_type,
});
if (participantsModalAvailable) {
onOpenParticipantsModal(
toParticipantsTarget(selectedCalendar),
);
}
setShowCalendarMenu(false);
}}
disabled={!canOpenParticipantsModal}
disabled={!participantsModalAvailable}
className="flex w-full items-center gap-2 px-4 py-2 text-sm text-[var(--foreground)] transition hover:bg-[var(--surface-secondary)]"
>
<Users size={16} />
@ -429,7 +408,7 @@ export const CalendarCard = memo(function CalendarCard({
className="hidden"
/>
{!isDepartmentCalendar ? (
{!departmentCalendar ? (
<>
<div className="app-divider my-1 border-t"></div>
<button

View File

@ -2,22 +2,13 @@
import { memo } from "react";
import { CalendarCard } from "@/components/calendar/CalendarCard";
import type { CalendarEvent } from "@/services/calendarService";
import { type CalendarParticipantsTarget } from "@/lib/calendar/ui";
import type { CalendarEvent, CalendarEventDraft } from "@/services/calendarService";
interface CalendarSidebarProps {
onOpenCalendarModal: (calendar?: { id?: number; name: string }) => void;
onOpenEventModal: (
event: Partial<CalendarEvent> & { id?: number; calendar?: number | null },
date?: Date,
) => void;
onOpenParticipantsModal: (calendar: {
id: number;
name: string;
user_role?: string;
can_manage_participants?: boolean;
type?: string | null;
context_type?: string | null;
}) => void;
onOpenEventModal: (event: CalendarEventDraft, date?: Date) => void;
onOpenParticipantsModal: (calendar: CalendarParticipantsTarget) => void;
eventsRefreshTrigger: number;
setEventsRefreshTrigger: (value: number | ((prev: number) => number)) => void;
setSidebarEvents: (events: CalendarEvent[]) => void;

View File

@ -24,7 +24,6 @@ export function CalendarModals(props: CalendarModalsProps) {
onClose={props.closeParticipantsModal}
calendarId={props.participantsCalendar.id}
calendarName={props.participantsCalendar.name}
userRole={props.participantsCalendar.user_role}
canManageParticipants={props.participantsCalendar.can_manage_participants}
calendarType={props.participantsCalendar.type}
contextType={props.participantsCalendar.context_type}

View File

@ -5,7 +5,8 @@ import { useRouter } from "next/navigation";
import { X, ChevronDown } from "lucide-react";
import { useUser } from "@/contexts/UserContext";
import { CalendarCard } from "@/components/calendar/CalendarCard";
import type { CalendarEvent } from "@/services/calendarService";
import { type CalendarParticipantsTarget } from "@/lib/calendar/ui";
import type { CalendarEvent, CalendarEventDraft } from "@/services/calendarService";
/* ────────────── Left Navigation Drawer (mobile) ────────────── */
@ -60,6 +61,7 @@ export function MobileLeftDrawer({ isOpen, onClose, children }: LeftNavDrawerPro
>
<div className="app-avatar-fallback flex h-12 w-12 flex-shrink-0 items-center justify-center overflow-hidden rounded-full text-sm font-semibold">
{user?.avatar ? (
// eslint-disable-next-line @next/next/no-img-element
<img src={user.avatar} alt={userName} className="h-full w-full object-cover" />
) : (
userInitials
@ -103,15 +105,8 @@ interface CalendarDrawerProps {
isOpen: boolean;
onClose: () => void;
onOpenCalendarModal: (calendar?: { id?: number; name: string }) => void;
onOpenEventModal: (event: Partial<CalendarEvent> & { id?: number }, date?: Date) => void;
onOpenParticipantsModal: (calendar: {
id: number;
name: string;
user_role?: string;
can_manage_participants?: boolean;
type?: string | null;
context_type?: string | null;
}) => void;
onOpenEventModal: (event: CalendarEventDraft, date?: Date) => void;
onOpenParticipantsModal: (calendar: CalendarParticipantsTarget) => void;
eventsRefreshTrigger: number;
setEventsRefreshTrigger: (value: number | ((prev: number) => number)) => void;
setSidebarEvents: (events: CalendarEvent[]) => void;

View File

@ -2,8 +2,9 @@
import { useState, useCallback } from "react";
import { apiClient } from "@/lib/api";
import { type CalendarParticipantsTarget } from "@/lib/calendar/ui";
import { DEFAULT_EVENT_COLOR } from "@/lib/calendar-event-colors";
import type { CalendarEvent } from "@/services/calendarService";
import type { CalendarEvent, CalendarEventDraft } from "@/services/calendarService";
export interface CalendarModalsState {
showCalendarModal: boolean;
@ -12,15 +13,8 @@ export interface CalendarModalsState {
showDayEventsModal: boolean;
showEventDetailsModal: boolean;
editingCalendar: { id?: number; name: string } | null;
editingEvent: CalendarEvent | null;
participantsCalendar: {
id: number;
name: string;
user_role?: string;
can_manage_participants?: boolean;
type?: string | null;
context_type?: string | null;
} | null;
editingEvent: CalendarEventDraft | null;
participantsCalendar: CalendarParticipantsTarget | null;
selectedDateForModal: Date | null;
viewingEvent: CalendarEvent | null;
sidebarEvents: CalendarEvent[];
@ -36,15 +30,8 @@ export function useCalendarModals() {
const [showEventDetailsModal, setShowEventDetailsModal] = useState(false);
const [editingCalendar, setEditingCalendar] = useState<{ id?: number; name: string } | null>(null);
const [editingEvent, setEditingEvent] = useState<CalendarEvent | null>(null);
const [participantsCalendar, setParticipantsCalendar] = useState<{
id: number;
name: string;
user_role?: string;
can_manage_participants?: boolean;
type?: string | null;
context_type?: string | null;
} | null>(null);
const [editingEvent, setEditingEvent] = useState<CalendarEventDraft | null>(null);
const [participantsCalendar, setParticipantsCalendar] = useState<CalendarParticipantsTarget | null>(null);
const [selectedDateForModal, setSelectedDateForModal] = useState<Date | null>(null);
const [viewingEvent, setViewingEvent] = useState<CalendarEvent | null>(null);
const [sidebarEvents, setSidebarEvents] = useState<CalendarEvent[]>([]);
@ -68,7 +55,7 @@ export function useCalendarModals() {
setShowCalendarModal(true);
}, []);
const handleOpenEventModal = useCallback((event: Partial<CalendarEvent> & { id?: number }, date?: Date) => {
const handleOpenEventModal = useCallback((event: CalendarEventDraft, date?: Date) => {
if (date && !event.id) {
setSelectedDateForModal(date);
setShowDayEventsModal(true);
@ -76,7 +63,7 @@ export function useCalendarModals() {
setViewingEvent(event as CalendarEvent);
setShowEventDetailsModal(true);
} else {
setEditingEvent(event as CalendarEvent);
setEditingEvent(event);
setShowEventModal(true);
}
}, []);
@ -93,15 +80,16 @@ export function useCalendarModals() {
const endDate = new Date(selectedDateForModal);
endDate.setHours(11, 0, 0, 0);
setEditingEvent({
const draftEvent: CalendarEventDraft = {
title: "",
description: "",
start: startDate.toISOString(),
end: endDate.toISOString(),
calendar: currentSelectedCalendarId,
color_event: DEFAULT_EVENT_COLOR,
} as CalendarEvent);
};
setEditingEvent(draftEvent);
setShowDayEventsModal(false);
setShowEventModal(true);
}, [currentSelectedCalendarId, selectedDateForModal]);
@ -137,14 +125,7 @@ export function useCalendarModals() {
setEventsRefreshTrigger(prev => prev + 1);
}, []);
const handleOpenParticipantsModal = useCallback((calendar: {
id: number;
name: string;
user_role?: string;
can_manage_participants?: boolean;
type?: string | null;
context_type?: string | null;
}) => {
const handleOpenParticipantsModal = useCallback((calendar: CalendarParticipantsTarget) => {
setParticipantsCalendar(calendar);
setShowParticipantsModal(true);
}, []);

View File

@ -1,9 +1,21 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import type { RequestFn, GetTokenFn } from './utils';
import type {
Calendar,
CalendarEvent,
CalendarOccurrence,
CalendarParticipant,
PaginatedResponse,
} from '@/types/api';
type CalendarScopeParam = { scope?: "all" };
type CalendarQueryParams = { start?: string; end?: string; calendar?: number; scope?: "all" };
type CalendarListResponse = PaginatedResponse<Calendar> | Calendar[];
type CalendarEventsResponse = PaginatedResponse<CalendarEvent> | CalendarEvent[];
type CalendarRulesParams = Record<string, unknown>;
export function createCalendarApi(request: RequestFn, getToken: GetTokenFn) {
return {
getCalendarEvents: (params?: { start?: string; end?: string; calendar?: number; scope?: "all" }) => {
getCalendarEvents: (params?: CalendarQueryParams): Promise<CalendarEventsResponse> => {
const qp = new URLSearchParams();
if (params?.start) qp.append('start', params.start);
if (params?.end) qp.append('end', params.end);
@ -19,29 +31,29 @@ export function createCalendarApi(request: RequestFn, getToken: GetTokenFn) {
const qs = qp.toString();
return request(`/api/v1/schedule/events/my-events/${qs ? '?' + qs : ''}`);
},
getCalendars: (params?: { scope?: "all" }) => {
getCalendars: (params?: CalendarScopeParam): Promise<CalendarListResponse> => {
const qp = new URLSearchParams();
if (params?.scope) qp.append('scope', params.scope);
const qs = qp.toString();
return request(`/api/v1/schedule/calendars/${qs ? '?' + qs : ''}`);
},
getCalendar: (id: number) => request(`/api/v1/schedule/calendars/${id}/`),
createCalendar: (data: { name: string; slug?: string }) => request('/api/v1/schedule/calendars/', { method: 'POST', body: JSON.stringify(data) }),
updateCalendar: (id: number, data: { name?: string; slug?: string }) => request(`/api/v1/schedule/calendars/${id}/`, { method: 'PATCH', body: JSON.stringify(data) }),
getCalendar: (id: number): Promise<Calendar> => request(`/api/v1/schedule/calendars/${id}/`),
createCalendar: (data: { name: string; slug?: string }): Promise<Calendar> => request('/api/v1/schedule/calendars/', { method: 'POST', body: JSON.stringify(data) }),
updateCalendar: (id: number, data: { name?: string; slug?: string }): Promise<Calendar> => request(`/api/v1/schedule/calendars/${id}/`, { method: 'PATCH', body: JSON.stringify(data) }),
deleteCalendar: (id: number): Promise<void> => request(`/api/v1/schedule/calendars/${id}/`, { method: 'DELETE' }),
// Subscriptions
getCalendarSubscriptions: () => request('/api/v1/schedule/subscriptions/'),
subscribeToCalendar: (calendarId: number) => request('/api/v1/schedule/subscriptions/', { method: 'POST', body: JSON.stringify({ calendar: calendarId }) }),
unsubscribeFromCalendar: (subscriptionId: number) => request(`/api/v1/schedule/subscriptions/${subscriptionId}/`, { method: 'DELETE' }),
updateSubscription: (subscriptionId: number, data: Record<string, any>) => request(`/api/v1/schedule/subscriptions/${subscriptionId}/`, { method: 'PATCH', body: JSON.stringify(data) }),
updateSubscription: (subscriptionId: number, data: Record<string, unknown>) => request(`/api/v1/schedule/subscriptions/${subscriptionId}/`, { method: 'PATCH', body: JSON.stringify(data) }),
// Events
getEvent: (id: number) => request(`/api/v1/schedule/events/${id}/`),
createEvent: (data: { title: string; description?: string; start: string; end: string; calendar: number; color_event?: string; rule?: number | null }) =>
getEvent: (id: number): Promise<CalendarEvent> => request(`/api/v1/schedule/events/${id}/`),
createEvent: (data: { title: string; description?: string; start: string; end: string; calendar: number; color_event?: string; rule?: number | null }): Promise<CalendarEvent> =>
request('/api/v1/schedule/events/', { method: 'POST', body: JSON.stringify(data) }),
updateEvent: (id: number, data: Record<string, any>) => request(`/api/v1/schedule/events/${id}/`, { method: 'PATCH', body: JSON.stringify(data) }),
updateEvent: (id: number, data: Record<string, unknown>): Promise<CalendarEvent> => request(`/api/v1/schedule/events/${id}/`, { method: 'PATCH', body: JSON.stringify(data) }),
deleteEvent: (id: number): Promise<void> => request(`/api/v1/schedule/events/${id}/`, { method: 'DELETE' }),
// Participants
getCalendarParticipants: (calendarId: number) => request(`/api/v1/schedule/calendars/${calendarId}/participants/`),
getCalendarParticipants: (calendarId: number): Promise<CalendarParticipant[]> => request(`/api/v1/schedule/calendars/${calendarId}/participants/`),
addCalendarParticipant: (calendarId: number, userId: number, distinction: string = 'viewer') =>
request(`/api/v1/schedule/calendars/${calendarId}/add-participant/`, { method: 'POST', body: JSON.stringify({ user_id: userId, distinction }) }),
removeCalendarParticipant: (calendarId: number, userId: number): Promise<void> =>
@ -63,13 +75,13 @@ export function createCalendarApi(request: RequestFn, getToken: GetTokenFn) {
return response.json();
},
// Rules
createRule: (data: { name: string; description?: string; frequency: string; params?: any }) =>
createRule: (data: { name: string; description?: string; frequency: string; params?: CalendarRulesParams }) =>
request('/api/v1/schedule/rules/', { method: 'POST', body: JSON.stringify({ ...data, params: data.params ? JSON.stringify(data.params) : '{}' }) }),
updateRule: (ruleId: number, data: { name: string; description?: string; frequency: string; params?: any }) =>
updateRule: (ruleId: number, data: { name: string; description?: string; frequency: string; params?: CalendarRulesParams }) =>
request(`/api/v1/schedule/rules/${ruleId}/`, { method: 'PUT', body: JSON.stringify({ ...data, params: data.params ? JSON.stringify(data.params) : '{}' }) }),
getRules: () => request('/api/v1/schedule/rules/'),
getRule: (ruleId: number) => request(`/api/v1/schedule/rules/${ruleId}/`),
getOccurrences: (params: { start: string; end: string; calendar?: number; scope?: "all" }) => {
getOccurrences: (params: { start: string; end: string; calendar?: number; scope?: "all" }): Promise<CalendarOccurrence[]> => {
const qp = new URLSearchParams();
qp.append('start', params.start);
qp.append('end', params.end);

View File

@ -0,0 +1,114 @@
import type { Calendar } from "@/types/api";
type SearchParamsLike = {
get(name: string): string | null;
};
export interface LinkedCalendarState {
linkedCalendarId: number | null;
linkedCalendarName: string;
linkedCalendarType: string | null;
linkedContextType: string | null;
}
export interface CalendarParticipantsTarget {
id: number;
name: string;
can_manage_participants?: boolean;
type?: string | null;
context_type?: string | null;
}
function parsePositiveInt(value: string | null): number | null {
if (!value) return null;
const parsed = Number(value);
return Number.isInteger(parsed) && parsed > 0 ? parsed : null;
}
export function readLinkedCalendarState(
searchParams: SearchParamsLike,
): LinkedCalendarState {
return {
linkedCalendarId: parsePositiveInt(searchParams.get("calendar")),
linkedCalendarName: searchParams.get("calendarName") || "",
linkedCalendarType: searchParams.get("calendarType"),
linkedContextType: searchParams.get("contextType"),
};
}
export function resolveSelectedCalendar(
calendars: Calendar[],
selectedCalendarId: number | null,
linkedState: LinkedCalendarState,
): Calendar | null {
const calendar =
selectedCalendarId === null
? null
: calendars.find((item) => item.id === selectedCalendarId) || null;
if (calendar) {
return calendar;
}
if (selectedCalendarId && linkedState.linkedCalendarName) {
return {
id: selectedCalendarId,
name: linkedState.linkedCalendarName,
slug: `linked-calendar-${selectedCalendarId}`,
type: linkedState.linkedCalendarType || undefined,
context_type: linkedState.linkedContextType || undefined,
can_create_events: true,
can_edit_calendar: false,
can_manage_participants: false,
};
}
return null;
}
export function buildCalendarOptions(
calendars: Calendar[],
selectedCalendar: Calendar | null,
): Calendar[] {
if (!selectedCalendar) {
return calendars;
}
return calendars.some((item) => item.id === selectedCalendar.id)
? calendars
: [...calendars, selectedCalendar];
}
export function isDepartmentCalendar(
calendar: Pick<Calendar, "type" | "context_type"> | null | undefined,
): boolean {
return Boolean(
calendar &&
(calendar.type === "department" ||
calendar.context_type === "department"),
);
}
export function canOpenParticipantsModal(
calendar:
| Pick<Calendar, "can_manage_participants" | "type" | "context_type">
| null
| undefined,
): boolean {
return Boolean(
calendar &&
(calendar.can_manage_participants || isDepartmentCalendar(calendar)),
);
}
export function toParticipantsTarget(
calendar: Calendar,
): CalendarParticipantsTarget {
return {
id: calendar.id,
name: calendar.name,
can_manage_participants: calendar.can_manage_participants,
type: calendar.type,
context_type: calendar.context_type,
};
}

View File

@ -4,24 +4,28 @@
*/
import { apiClient } from "@/lib/api";
import type {
CalendarEvent as CalendarEventDetails,
CalendarListEvent,
CalendarOccurrence,
PaginatedResponse,
} from "@/types/api";
// Типы
export interface CalendarEvent {
id: number;
title: string;
start?: string | null;
end?: string | null;
allDay?: boolean;
color?: string | null;
export type CalendarEvent = CalendarListEvent;
export type CalendarEventDraft = Record<string, unknown> & {
id?: number;
title?: string;
description?: string;
start?: string | Date | null;
end?: string | Date | null;
calendar?: number | null;
color_event?: string | null;
rule?: number | null;
is_recurring?: boolean;
event_id?: number;
description?: string;
calendar?: number;
is_recurring?: boolean;
can_edit?: boolean;
can_delete?: boolean;
}
};
interface EventsCache {
key: string;
@ -49,6 +53,37 @@ export const startOfWeekMonday = (date: Date): Date => {
return d;
};
const extractResults = <T>(
response: PaginatedResponse<T> | T[],
): T[] => (Array.isArray(response) ? response : response.results || []);
function normalizeRegularEvent(event: CalendarEventDetails): CalendarEvent {
return {
...event,
color_event: event.color_event ?? null,
is_recurring: false,
};
}
function normalizeOccurrence(occurrence: CalendarOccurrence): CalendarEvent {
return {
id: occurrence.id,
title: occurrence.title.startsWith("⟲")
? occurrence.title
: `⟲ ${occurrence.title}`,
description: occurrence.description,
start: occurrence.start,
end: occurrence.end,
calendar: occurrence.calendar,
color_event: occurrence.color_event ?? null,
event_id: occurrence.event_id,
rule: occurrence.rule ?? null,
can_edit: occurrence.can_edit,
can_delete: occurrence.can_delete,
is_recurring: true,
};
}
// Сервис для работы с событиями календаря
class CalendarService {
/**
@ -92,11 +127,13 @@ class CalendarService {
]);
// Обрабатываем результаты
const eventsList = Array.isArray(eventsResult) ? eventsResult : (eventsResult?.results || []);
const regularEvents = eventsList.filter((evt: any) => !evt.rule);
const regularEvents = extractResults(eventsResult)
.filter((event) => !event.rule)
.map(normalizeRegularEvent);
const occurrencesList = Array.isArray(occurrencesResult) ? occurrencesResult : (occurrencesResult?.results || []);
const recurringOccurrences = occurrencesList.filter((occ: any) => occ.is_recurring);
const recurringOccurrences = extractResults(occurrencesResult)
.filter((occurrence) => occurrence.is_recurring)
.map(normalizeOccurrence);
// Объединяем оба типа событий
let allEvents = [...regularEvents, ...recurringOccurrences];
@ -125,7 +162,11 @@ class CalendarService {
/**
* Загружает полное событие по ID (для повторяющихся событий)
*/
async loadFullEvent(eventId: number, occurrenceStart?: string, occurrenceEnd?: string): Promise<CalendarEvent> {
async loadFullEvent(
eventId: number,
occurrenceStart?: string,
occurrenceEnd?: string,
): Promise<CalendarEventDetails> {
const fullEvent = await apiClient.getEvent(eventId);
// Если переданы время occurrence, используем их

View File

@ -720,6 +720,52 @@ export interface CalendarEvent {
can_delete?: boolean;
}
export interface CalendarOccurrence {
id: string;
title: string;
description?: string;
start: string;
end: string;
calendar: number;
color_event?: string | null;
event_id: number;
rule?: number | null;
is_recurring: boolean;
end_recurring_period?: string | null;
can_edit?: boolean;
can_delete?: boolean;
}
export interface CalendarListEvent {
id: number | string;
title: string;
description?: string;
start: string;
end: string;
calendar: number;
color_event?: string | null;
rule?: number | null;
event_id?: number;
is_recurring?: boolean;
can_edit?: boolean;
can_delete?: boolean;
}
export interface CalendarParticipantUser {
id: number;
username: string;
first_name: string;
last_name: string;
email?: string | null;
avatar?: string | null;
}
export interface CalendarParticipant {
id: number;
user: CalendarParticipantUser | null;
distinction: string;
}
export interface Calendar {
id: number;
name: string; // django-scheduler использует name (не title)
@ -728,7 +774,7 @@ export interface Calendar {
type?: string;
context_object_id?: number | null;
context_type?: string | null;
flags?: Record<string, any>;
flags?: Record<string, unknown>;
user_role?: string | null;
can_create_events?: boolean;
can_edit_calendar?: boolean;