diff --git a/backend/scheduling/services.py b/backend/scheduling/services.py index fd56bd7d..512094b1 100644 --- a/backend/scheduling/services.py +++ b/backend/scheduling/services.py @@ -361,7 +361,6 @@ def filter_events_for_user( return queryset.filter( Q(calendar_id__in=calendar_ids) | Q(id__in=participant_event_ids) - | Q(calendar__binding__type=CalendarBinding.BindingType.DEPARTMENT) | Q( calendar__binding__type=CalendarBinding.BindingType.DEFAULT, calendar__binding__access_scope=CalendarBinding.AccessScope.ALL, diff --git a/backend/tests/test_schedule_permissions.py b/backend/tests/test_schedule_permissions.py index 02f717de..9373b47f 100644 --- a/backend/tests/test_schedule_permissions.py +++ b/backend/tests/test_schedule_permissions.py @@ -605,6 +605,60 @@ class TestRecurringEvents: 'Admin event', 'Other event', } + + def test_admin_accessible_scope_excludes_unrelated_department_occurrences( + self, api_client, admin_user, user1 + ): + """Календарь отдела не становится доступным только из-за его типа.""" + department_calendar = Calendar.objects.create( + name='Sales', + slug='department-sales', + ) + CalendarBinding.objects.create( + calendar=department_calendar, + type=CalendarBinding.BindingType.DEPARTMENT, + ) + rule = Rule.objects.create( + name='Department daily', + description='Department recurrence', + frequency='DAILY', + params='', + ) + start = timezone.now().replace(microsecond=0, second=0) + range_end = start + timedelta(days=2) + Event.objects.create( + calendar=department_calendar, + title='Sales recurring event', + start=start, + end=start + timedelta(hours=1), + creator=user1, + rule=rule, + end_recurring_period=range_end, + ) + + api_client.force_authenticate(user=admin_user) + accessible_occurrences = api_client.get( + '/api/v1/schedule/events/occurrences/', + { + 'start': start.isoformat(), + 'end': range_end.isoformat(), + }, + ) + assert accessible_occurrences.status_code == status.HTTP_200_OK + assert accessible_occurrences.data == [] + + all_occurrences = api_client.get( + '/api/v1/schedule/events/occurrences/', + { + 'scope': 'all', + 'start': start.isoformat(), + 'end': range_end.isoformat(), + }, + ) + assert all_occurrences.status_code == status.HTTP_200_OK + assert {event['title'] for event in all_occurrences.data} == { + 'Sales recurring event', + } def test_create_rule(self, api_client, user1): """Создание правила повторения.""" diff --git a/frontend/src/components/ViewEventDetailsModal.tsx b/frontend/src/components/ViewEventDetailsModal.tsx index 1032d824..62531f03 100644 --- a/frontend/src/components/ViewEventDetailsModal.tsx +++ b/frontend/src/components/ViewEventDetailsModal.tsx @@ -1,14 +1,15 @@ "use client"; import { - Calendar, + CalendarDays, ChevronRight, - Clock, + Clock3, Edit2, FileText, Kanban, Link2, Loader2, + Repeat2, Trash2, Users, } from "lucide-react"; @@ -21,6 +22,7 @@ import { useState, useEffect, useCallback } from "react"; import { apiClient } from "@/lib/api"; import { displayUserName } from "@/lib/shared"; import { getTaskColumnDisplayName, getTaskDestinationColumns } from "@/lib/tasks/columns"; +import { getCalendarEventDisplayRange } from "@/lib/calendar/eventRange"; import type { TaskBoard, TaskCard, TaskPriority, User } from "@/types/api"; type EventRuleData = { @@ -38,6 +40,7 @@ type EventDetails = Record & { start?: string | Date | null; end?: string | Date | null; calendar?: number | null; + calendar_name?: string; color_event?: string | null; can_edit?: boolean; can_delete?: boolean; @@ -108,6 +111,13 @@ const WEEKDAY_LABELS: Record = { 6: "ВС", }; +const PARTICIPANT_ROLE_LABELS: Record = { + owner: "Владелец", + editor: "Редактор", + viewer: "Просмотр", + attendee: "Участник", +}; + // Нормализация byweekday в массив чисел function normalizeByweekday(byweekday: unknown): number[] { if (!byweekday) return []; @@ -204,22 +214,23 @@ export function ViewEventDetailsModal({ if (!isOpen || !event) return null; - const formatDateTime = (dateStr?: string | Date | null) => { - if (!dateStr) return ""; - const date = new Date(dateStr); - return format(date, "d MMMM yyyy, HH:mm", { locale: ru }); - }; - const formatDate = (dateStr?: string | Date | null) => { if (!dateStr) return ""; const date = new Date(dateStr); - return format(date, "d MMMM yyyy", { locale: ru }); + const label = format(date, "d MMMM yyyy", { locale: ru }); + return label.charAt(0).toUpperCase() + label.slice(1); }; - const formattedStart = formatDateTime(event.start); - const formattedEnd = formatDateTime(event.end); - const capitalizedStart = formattedStart.charAt(0).toUpperCase() + formattedStart.slice(1); - const capitalizedEnd = formattedEnd.charAt(0).toUpperCase() + formattedEnd.slice(1); + const formatTime = (dateStr?: string | Date | null) => { + if (!dateStr) return ""; + return format(new Date(dateStr), "HH:mm", { locale: ru }); + }; + + const displayRange = getCalendarEventDisplayRange(event); + const formattedStartDate = formatDate(event.start); + const formattedEndDate = formatDate(event.end); + const formattedStartTime = formatTime(event.start); + const formattedEndTime = formatTime(event.end); const canEdit = event.can_edit !== false; const canDelete = event.can_delete !== false; const ruleFrequency = typeof event.rule_data?.frequency === "string" @@ -361,132 +372,167 @@ export function ViewEventDetailsModal({ isOpen={isOpen} onClose={onClose} title={event.title} - size="sm" + size="md" + closeOnClickOutside footer={canEdit || canDelete || event.id ? ( -
- {event.id ? ( - - ) : null} - {canEdit ? ( - - ) : null} +
{canDelete ? ( ) : null} +
+ {event.id ? ( + + ) : null} + {canEdit ? ( + + ) : null} +
) : undefined} > -
- {/* Event meta */} -
- {event.rule && ( - - ⟲ - - )} -
-
- Календарь #{event.calendar} -
-
- {/* Time */} -
- -
-

Время

-

- Начало: {capitalizedStart} -

-

- Конец: {capitalizedEnd} -

+
+ {/* Основная информация */} +
+
+
+
+ + + {event.calendar_name || `Календарь #${event.calendar}`} + + {event.rule ? ( + + + Повторяется + + ) : null} + {displayRange?.isMultiDay ? ( + + {displayRange.dayCountLabel} + + ) : null}
+ +
+
+
+ + Начало +
+

{formattedStartDate || "Без даты"}

+ {formattedStartTime ?

{formattedStartTime}

: null} +
+
+
+ + Завершение +
+

{formattedEndDate || "Не указано"}

+ {formattedEndTime ?

{formattedEndTime}

: null} +
+
+ + {displayRange ? ( +

+ + {displayRange.timeLabel} + {displayRange.isMultiDay ? · {displayRange.dateLabel} : null} +

+ ) : null}
+
{/* Recurring Info */} {event.rule && event.rule_data && ( -
-
- - Повторяющееся событие -
-
-
- Частота: {ruleFrequencyLabel} +
+
+ + + +
+

Повторение

+

{ruleFrequencyLabel}

+
+
{Boolean(ruleParams?.byweekday) && ( -
- Дни недели: + + Дни: {normalizeByweekday(ruleParams?.byweekday).map((day: number) => WEEKDAY_LABELS[day]).join(", ")} -
+ )} {Boolean(ruleParams?.count) && ( -
- Повторений: {String(ruleParams?.count)} -
+ + Повторений: {String(ruleParams?.count)} + )} {event.end_recurring_period && ( -
- До: {formatDate(event.end_recurring_period)} -
+ + До: {formatDate(event.end_recurring_period)} + )}
- -
+ )} {/* Description */} {event.description && ( -
- -
-

Описание

-

+

+
+ +

Описание

+
+

{event.description}

-
-
+ )} {event.id ? ( -
- -
+
-

+ +

Связанные объекты

- + {linkedTasks.length}
@@ -561,22 +607,22 @@ export function ViewEventDetailsModal({

)} -
-
+ ) : null} {/* Participants */} {showParticipants && ( -
- -
-

+

+
+ +

Участники {participants.length > 0 && `(${participants.length})`} -

+

+
{loadingParticipants ? (

Загрузка...

) : participants.length > 0 ? ( -
+
{participants.map((participant) => (
- {participant.distinction || "attendee"} + {PARTICIPANT_ROLE_LABELS[participant.distinction || "attendee"] || participant.distinction}
@@ -601,8 +647,7 @@ export function ViewEventDetailsModal({ ) : (

Нет участников

)} -
-
+ )}
diff --git a/frontend/src/components/calendar/CalendarCard.tsx b/frontend/src/components/calendar/CalendarCard.tsx index 6fffe7d1..9329dd68 100644 --- a/frontend/src/components/calendar/CalendarCard.tsx +++ b/frontend/src/components/calendar/CalendarCard.tsx @@ -2,7 +2,7 @@ import { memo, useMemo, useState, useRef, useEffect, useCallback } from "react"; import { useSearchParams } from "next/navigation"; -import { Bell, ChevronDown, ChevronLeft, ChevronRight, Pencil, ShieldCheck, Trash2, Users } from "lucide-react"; +import { Bell, CalendarDays, ChevronDown, ChevronLeft, ChevronRight, Clock3, Pencil, ShieldCheck, Trash2, Users } from "lucide-react"; import { apiClient } from "@/lib/api"; import { useCalendar } from "@/contexts/CalendarContext"; import { useCalendarEvents } from "@/hooks/calendar/useCalendarEvents"; @@ -19,6 +19,7 @@ import { DEFAULT_EVENT_COLOR, resolveEventColor } from "@/lib/calendar-event-col import { calendarEventOverlapsRange, formatLocalDateKey, + getCalendarEventDisplayRange, getCalendarEventDateKeys, } from "@/lib/calendar/eventRange"; import { @@ -109,6 +110,11 @@ export const CalendarCard = memo(function CalendarCard({ [calendars, selectedCalendar], ); + const calendarNamesById = useMemo( + () => new Map(calendars.map((calendar) => [calendar.id, calendar.name])), + [calendars], + ); + const participantsModalAvailable = canOpenParticipantsModal(selectedCalendar); const departmentCalendar = isDepartmentCalendar(selectedCalendar); @@ -670,19 +676,10 @@ export const CalendarCard = memo(function CalendarCard({ return (
{visibleEvents.map((event) => { - const start = event.start ? new Date(event.start) : null; const eventId = getLinkedCalendarEventId(event); const linkedTasks = eventId ? linkedTasksByEventId[eventId] || [] : []; - const dateLabel = - start && !Number.isNaN(start.getTime()) - ? start.toLocaleString("ru-RU", { - weekday: "short", - day: "2-digit", - month: "2-digit", - hour: "2-digit", - minute: "2-digit", - }) - : "Без даты"; + const displayRange = getCalendarEventDisplayRange(event); + const calendarName = calendarNamesById.get(event.calendar); return (
)}
-

{dateLabel}

+ {displayRange ? ( +
+
+
+
+
+
+ ) : ( +

Без даты

+ )} {linkedTasks.length > 0 ? ( diff --git a/frontend/src/lib/calendar/eventRange.test.js b/frontend/src/lib/calendar/eventRange.test.js index 927f1450..015f9c63 100644 --- a/frontend/src/lib/calendar/eventRange.test.js +++ b/frontend/src/lib/calendar/eventRange.test.js @@ -4,6 +4,7 @@ import test from "node:test"; import { calendarEventOverlapsRange, getCalendarEventDateKeys, + getCalendarEventDisplayRange, } from "./eventRange.ts"; test("event crossing the week start is included in the current week", () => { @@ -73,3 +74,57 @@ test("calendar day markers include a day when the event continues after midnight ["2026-08-31", "2026-09-01"], ); }); + +test("calendar event display range marks events spanning several days", () => { + const range = getCalendarEventDisplayRange({ + start: "2026-08-28T10:00:00", + end: "2026-08-30T18:00:00", + }); + + assert.ok(range); + assert.equal(range.isMultiDay, true); + assert.equal(range.dayCount, 3); + assert.equal(range.dayCountLabel, "3 дня"); + assert.match(range.dateLabel, /28/); + assert.match(range.dateLabel, /30/); + assert.match(range.timeLabel, /10:00/); + assert.match(range.timeLabel, /18:00/); +}); + +test("midnight end does not add an empty calendar day", () => { + const range = getCalendarEventDisplayRange({ + start: "2026-08-28T00:00:00", + end: "2026-08-30T00:00:00", + }); + + assert.ok(range); + assert.equal(range.isMultiDay, true); + assert.equal(range.dayCount, 2); + assert.equal(range.dayCountLabel, "2 дня"); + assert.equal(range.timeLabel, "Весь день"); +}); + +test("single-day event stays compact", () => { + const range = getCalendarEventDisplayRange({ + start: "2026-08-28T10:00:00", + end: "2026-08-28T11:30:00", + }); + + assert.ok(range); + assert.equal(range.isMultiDay, false); + assert.equal(range.dayCount, 1); + assert.equal(range.dayCountLabel, "1 день"); + assert.match(range.timeLabel, /10:00/); + assert.match(range.timeLabel, /11:30/); +}); + +test("full calendar day is labeled as all-day", () => { + const range = getCalendarEventDisplayRange({ + start: "2026-09-01T00:00:00", + end: "2026-09-01T23:59:00", + }); + + assert.ok(range); + assert.equal(range.isMultiDay, false); + assert.equal(range.timeLabel, "Весь день"); +}); diff --git a/frontend/src/lib/calendar/eventRange.ts b/frontend/src/lib/calendar/eventRange.ts index 3dfbdd4c..912f49ae 100644 --- a/frontend/src/lib/calendar/eventRange.ts +++ b/frontend/src/lib/calendar/eventRange.ts @@ -3,6 +3,14 @@ export type CalendarEventInterval = { end?: string | Date | null; }; +export type CalendarEventDisplayRange = { + dateLabel: string; + timeLabel: string; + dayCount: number; + dayCountLabel: string; + isMultiDay: boolean; +}; + const pad = (value: number) => String(value).padStart(2, "0"); export const formatLocalDateKey = (date: Date): string => @@ -14,6 +22,70 @@ const parseEventDate = (value: string | Date | null | undefined): Date | null => return Number.isNaN(date.getTime()) ? null : date; }; +const formatShortDate = (date: Date, includeWeekday = false) => + date.toLocaleDateString("ru-RU", { + ...(includeWeekday ? { weekday: "short" as const } : {}), + day: "numeric", + month: "short", + }); + +const formatTime = (date: Date) => + date.toLocaleTimeString("ru-RU", { + hour: "2-digit", + minute: "2-digit", + }); + +const formatDayCount = (count: number) => { + const mod100 = count % 100; + if (mod100 >= 11 && mod100 <= 14) return `${count} дней`; + const mod10 = count % 10; + if (mod10 === 1) return `${count} день`; + if (mod10 >= 2 && mod10 <= 4) return `${count} дня`; + return `${count} дней`; +}; + +/** + * Компактное, но полное представление периода события для карточек. + * Для границы окончания в 00:00 показывается последний фактически занятый день. + */ +export function getCalendarEventDisplayRange( + event: CalendarEventInterval, +): CalendarEventDisplayRange | null { + const start = parseEventDate(event.start); + if (!start) return null; + + const parsedEnd = parseEventDate(event.end); + const end = parsedEnd && parsedEnd > start ? parsedEnd : start; + const dateKeys = getCalendarEventDateKeys(event); + const dayCount = Math.max(1, dateKeys.length); + const isMultiDay = dayCount > 1; + + let lastOccupiedDay = end; + if (parsedEnd && parsedEnd > start) { + lastOccupiedDay = new Date(parsedEnd.getTime() - 1); + } + + const dateLabel = isMultiDay + ? `${formatShortDate(start)} — ${formatShortDate(lastOccupiedDay)}` + : formatShortDate(start, true); + const startsAtMidnight = start.getHours() === 0 && start.getMinutes() === 0; + const endsAtMidnight = end.getHours() === 0 && end.getMinutes() === 0; + const endsAtDayEnd = end.getHours() === 23 && end.getMinutes() === 59; + const timeLabel = startsAtMidnight && (endsAtMidnight || endsAtDayEnd) + ? "Весь день" + : end > start + ? `${formatTime(start)}–${formatTime(end)}` + : formatTime(start); + + return { + dateLabel, + timeLabel, + dayCount, + dayCountLabel: formatDayCount(dayCount), + isMultiDay, + }; +} + /** * Проверяет пересечение события с полуоткрытым диапазоном [start, end). * Для событий без корректного окончания используется дата начала как момент времени.