Merge branch 'feature/calendar-frontend-refactor' into develop
This commit is contained in:
commit
7ccf69e8ef
@ -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}
|
||||
|
||||
@ -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="Удалить участника"
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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);
|
||||
}, []);
|
||||
|
||||
@ -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);
|
||||
|
||||
114
frontend/src/lib/calendar/ui.ts
Normal file
114
frontend/src/lib/calendar/ui.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@ -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, используем их
|
||||
|
||||
@ -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;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user