Merge branch 'develop'

This commit is contained in:
lizerk 2026-04-16 20:25:52 +03:00
commit 784b959aa3
26 changed files with 850 additions and 404 deletions

View File

@ -1,6 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
import "./.next/dev/types/routes.d.ts";
import "./.next/types/routes.d.ts";
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

View File

@ -7,6 +7,17 @@
*/
const SW_VERSION = '2.0.0';
const STATIC_CACHE = `eusrr-static-${SW_VERSION}`;
const PRECACHE_URLS = [
'/manifest.webmanifest',
'/favicon-source.svg',
'/logo.png',
'/logo.webp',
'/icon-192.png',
'/icon-512.png',
'/icon-512-maskable.png',
'/apple-touch-icon.png',
];
// Настройки по умолчанию для уведомлений
const DEFAULT_NOTIFICATION_OPTIONS = {
@ -22,8 +33,15 @@ const DEFAULT_NOTIFICATION_OPTIONS = {
*/
self.addEventListener('install', (event) => {
console.log('[SW] Service Worker installing, version:', SW_VERSION);
// Немедленно активировать новую версию
self.skipWaiting();
event.waitUntil(
caches
.open(STATIC_CACHE)
.then((cache) => cache.addAll(PRECACHE_URLS))
.catch((error) => {
console.error('[SW] Failed to precache static assets:', error);
})
.then(() => self.skipWaiting())
);
});
/**
@ -31,8 +49,56 @@ self.addEventListener('install', (event) => {
*/
self.addEventListener('activate', (event) => {
console.log('[SW] Service Worker activated, version:', SW_VERSION);
// Захватить все клиенты без перезагрузки
event.waitUntil(self.clients.claim());
event.waitUntil(
caches.keys().then((cacheKeys) =>
Promise.all(
cacheKeys.map((cacheKey) => {
if (cacheKey !== STATIC_CACHE && cacheKey.startsWith('eusrr-static-')) {
return caches.delete(cacheKey);
}
return Promise.resolve(false);
})
)
).then(() => self.clients.claim())
);
});
self.addEventListener('fetch', (event) => {
const { request } = event;
if (request.method !== 'GET') {
return;
}
const url = new URL(request.url);
if (url.origin !== self.location.origin) {
return;
}
const isApiRequest = url.pathname.startsWith('/api/');
const isStaticAsset =
url.pathname.startsWith('/_next/static/') ||
PRECACHE_URLS.includes(url.pathname) ||
['image', 'style', 'script', 'font', 'worker'].includes(request.destination);
if (isApiRequest || !isStaticAsset) {
return;
}
event.respondWith(
caches.open(STATIC_CACHE).then(async (cache) => {
const cachedResponse = await cache.match(request);
if (cachedResponse) {
return cachedResponse;
}
const networkResponse = await fetch(request);
if (networkResponse.ok) {
cache.put(request, networkResponse.clone()).catch(() => undefined);
}
return networkResponse;
})
);
});
/**

View File

@ -18,13 +18,33 @@ const geistMono = Geist_Mono({
export const metadata: Metadata = {
title: "EUSRR - Корпоративная система",
description: "Корпоративная система управления EUSRR",
applicationName: "EUSRR",
manifest: "/manifest.webmanifest",
appleWebApp: {
capable: true,
statusBarStyle: "default",
title: "EUSRR",
},
icons: {
icon: [
{ url: "/favicon-source.svg", type: "image/svg+xml", sizes: "any" },
{ url: "/icon-192.png", sizes: "192x192", type: "image/png" },
{ url: "/icon-512.png", sizes: "512x512", type: "image/png" },
],
shortcut: [{ url: "/favicon-source.svg", type: "image/svg+xml" }],
apple: [{ url: "/apple-touch-icon.png", sizes: "180x180", type: "image/png" }],
},
};
export const viewport: Viewport = {
width: 'device-width',
width: "device-width",
initialScale: 1,
maximumScale: 1,
interactiveWidget: 'resizes-content',
interactiveWidget: "resizes-content",
themeColor: [
{ media: "(prefers-color-scheme: light)", color: "#f4f8fc" },
{ media: "(prefers-color-scheme: dark)", color: "#111923" },
],
};
export default function RootLayout({

View File

@ -0,0 +1,32 @@
import type { MetadataRoute } from "next";
export default function manifest(): MetadataRoute.Manifest {
return {
name: "EUSRR",
short_name: "EUSRR",
description: "Корпоративная система управления EUSRR",
start_url: "/",
scope: "/",
display: "standalone",
background_color: "#111923",
theme_color: "#111923",
icons: [
{
src: "/icon-192.png",
sizes: "192x192",
type: "image/png",
},
{
src: "/icon-512.png",
sizes: "512x512",
type: "image/png",
},
{
src: "/icon-512-maskable.png",
sizes: "512x512",
type: "image/png",
purpose: "maskable",
},
],
};
}

View File

@ -7,6 +7,7 @@ import {
Bell,
Check,
Clock3,
Download,
Eye,
EyeOff,
KeyRound,
@ -21,6 +22,7 @@ import {
} from "lucide-react";
import { AppShell } from "@/components/AppShell";
import { usePwa } from "@/contexts/PwaContext";
import { useMobileNavPlacement } from "@/contexts/MobileNavPlacementContext";
import {
formatSessionDateTime,
@ -126,6 +128,7 @@ function SectionCard({
export default function SettingsPage() {
const { mobileNavPlacement, setMobileNavPlacement } = useMobileNavPlacement();
const { canInstall, install, isInstalled, isRegistrationReady } = usePwa();
const {
activeVerbCount,
avatarInputRef,
@ -400,6 +403,61 @@ export default function SettingsPage() {
</div>
</SectionCard>
<SectionCard
id="app"
title="Приложение"
description="Установка приложения на устройство и базовый PWA-статус."
>
<div className="grid gap-4 md:grid-cols-2">
<div className="app-surface-muted rounded-2xl p-4">
<div className="flex items-start gap-3">
<span
className={`app-badge flex h-10 w-10 shrink-0 items-center justify-center rounded-xl ${
isInstalled ? "app-badge-accent" : ""
}`}
>
<Download size={18} />
</span>
<div className="min-w-0">
<p className="text-sm font-semibold text-[var(--foreground)]">Статус установки</p>
<p className="app-text-muted mt-1 text-sm">
{isInstalled
? "Приложение уже установлено на это устройство."
: canInstall
? "Приложение можно установить через системный prompt браузера."
: "Установка через браузер сейчас недоступна."}
</p>
</div>
</div>
</div>
<div className="app-surface-muted rounded-2xl p-4">
<div className="flex items-start gap-3">
<span className="app-badge flex h-10 w-10 shrink-0 items-center justify-center rounded-xl">
<Smartphone size={18} />
</span>
<div className="min-w-0">
<p className="text-sm font-semibold text-[var(--foreground)]">PWA-компоненты</p>
<p className="app-text-muted mt-1 text-sm">
Service Worker: {isRegistrationReady ? "зарегистрирован" : "не зарегистрирован"}.
</p>
<div className="mt-3">
<button
type="button"
onClick={() => void install()}
disabled={!canInstall}
className="app-action-secondary inline-flex items-center gap-2 rounded-lg px-4 py-2 text-sm font-medium disabled:opacity-50"
>
<Download size={16} />
{isInstalled ? "Установлено" : "Установить приложение"}
</button>
</div>
</div>
</div>
</div>
</div>
</SectionCard>
<SectionCard
id="contacts"
title="Контакты"

View File

@ -1,6 +1,6 @@
"use client";
import { Building2, CalendarDays, FileSignature, FileText, Home as HomeIcon, Menu, MessageSquare, Monitor, Search, ShoppingCart, Users } from "lucide-react";
import { Building2, CalendarDays, Download, FileSignature, FileText, Home as HomeIcon, Menu, MessageSquare, Monitor, Search, ShoppingCart, Users } from "lucide-react";
import Link from "next/link";
import { usePathname, useRouter } from "next/navigation";
import { ReactNode, startTransition, useEffect, useRef, useState, useMemo } from "react";
@ -13,6 +13,7 @@ import { CalendarSidebar } from "@/components/calendar/CalendarSidebar";
import { useCalendarModals } from "@/hooks/useCalendarModals";
import { CalendarModals } from "@/components/layout/CalendarModals";
import { MobileLeftDrawer, MobileCalendarDrawer } from "@/components/layout/MobileDrawers";
import { usePwa } from "@/contexts/PwaContext";
type AppShellProps = {
children: ReactNode;
@ -51,6 +52,7 @@ const navItems = [
function Header({ mobileNavPlacement, suppressMobileChrome = false, onOpenLeftNav, onOpenCalendar }: HeaderProps) {
const router = useRouter();
const { user, logout } = useUser();
const { canInstall, install } = usePwa();
const [searchQuery, setSearchQuery] = useState("");
const [userMenuOpen, setUserMenuOpen] = useState(false);
const [isSearchOpen, setIsSearchOpen] = useState(false);
@ -147,6 +149,7 @@ function Header({ mobileNavPlacement, suppressMobileChrome = false, onOpenLeftNa
</button>
<Link href="/" className="flex items-center justify-center">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src="/logo.png"
alt="Логотип"
@ -200,6 +203,7 @@ function Header({ mobileNavPlacement, suppressMobileChrome = false, onOpenLeftNa
onClick={() => setUserMenuOpen((v) => !v)}
>
{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
@ -216,6 +220,18 @@ function Header({ mobileNavPlacement, suppressMobileChrome = false, onOpenLeftNa
className="app-action-ghost w-full px-4 py-2 text-left text-sm transition"
onClick={() => { setUserMenuOpen(false); router.push('/settings'); }}
>Настройки</button>
{canInstall ? (
<button
className="app-action-ghost flex w-full items-center gap-2 px-4 py-2 text-left text-sm transition"
onClick={() => {
setUserMenuOpen(false);
void install();
}}
>
<Download size={16} />
Установить
</button>
) : null}
<div className="app-divider my-1 border-t" />
<button
className="app-action-danger w-full px-4 py-2 text-left text-sm transition"

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,24 +42,26 @@ const localizer = dateFnsLocalizer({
locales,
});
// Типы для событий
type CalendarEvent = {
id: number;
title: string;
description?: string;
type CalendarDisplayEvent = Omit<CalendarEventData, "start" | "end"> & {
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;
};
function toEventDraft(event: CalendarEventData | CalendarDisplayEvent): CalendarEventDraft {
const normalizedId =
typeof event.id === "number"
? event.id
: typeof event.id === "string" && Number.isFinite(Number(event.id))
? Number(event.id)
: undefined;
return {
...event,
id: normalizedId,
};
}
// Кастомные сообщения на русском
const messages = {
allDay: "Весь день",
@ -67,7 +80,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 +154,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 +172,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 +205,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 +235,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 +308,7 @@ export function BigCalendar() {
// Создание события из модала просмотра дня
const handleCreateEventFromDay = useCallback(() => {
if (!selectedCalendarId) {
if (selectedCalendarId === null) {
alert("Сначала выберите календарь");
return;
}
@ -378,25 +326,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,
@ -409,7 +363,7 @@ export function BigCalendar() {
}
} else {
// Обычное событие - открываем детали
setViewingEvent(calEvent);
setViewingEvent(toEventDraft(calEvent));
setShowEventDetailsModal(true);
}
}, []);
@ -428,16 +382,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 +409,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 +441,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 +455,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,
@ -517,14 +476,14 @@ export function BigCalendar() {
console.error("Ошибка загрузки события:", err);
}
} else {
setViewingEvent(event);
setViewingEvent(toEventDraft(event));
setShowEventDetailsModal(true);
}
}, []);
// Стилизация событий по цвету
const eventStyleGetter = (event: CalendarEvent) => {
const eventStyleGetter = (event: CalendarDisplayEvent) => {
const backgroundColor = resolveEventColor(event.color_event);
return {
style: {
@ -562,7 +521,7 @@ export function BigCalendar() {
</div>
)}
{calendars.length > 0 && (
{hasCalendarSelection && (
<div className="relative z-30">
<button
type="button"
@ -602,12 +561,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 +605,7 @@ export function BigCalendar() {
className="hidden"
/>
{!isDepartmentCalendar ? (
{!departmentCalendar ? (
<>
<div className="app-divider my-1 border-t"></div>
<button
@ -680,7 +639,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 +714,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 +747,7 @@ export function BigCalendar() {
}}
event={editingEvent}
onSave={() => {
calendarService.clearCache();
loadEvents(); // ✅ Обновляем список событий
}}
showParticipants={true}
@ -821,7 +781,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 +809,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() || 'Сотрудник'}
@ -366,11 +340,11 @@ export default function CalendarParticipantsModal({
<p className="app-text-muted py-4 text-center text-sm">Нет участников</p>
) : (
participants.map((participant) => {
const user = participant.user || {};
const firstName = user.first_name || 'N';
const lastName = user.last_name || 'A';
const username = typeof user.username === 'string' ? user.username.trim() : '';
const secondaryLabel = user.email || (username ? `@${username}` : '');
const user = participant.user;
const firstName = user?.first_name || 'N';
const lastName = user?.last_name || 'A';
const username = typeof user?.username === 'string' ? user.username.trim() : '';
const secondaryLabel = user?.email || (username ? `@${username}` : '');
return (
<div
@ -378,7 +352,8 @@ export default function CalendarParticipantsModal({
className="app-surface-elevated flex items-center justify-between rounded-lg px-4 py-3 transition hover:bg-[var(--surface-secondary)]"
>
<div className="flex items-center gap-3">
{user.avatar ? (
{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

@ -1,7 +1,7 @@
"use client";
import { useState, useEffect } from "react";
import { Check, Bell, BellOff, Eye, EyeOff, Loader2 } from "lucide-react";
import { Bell, BellOff, Eye, EyeOff, Loader2 } from "lucide-react";
import { Modal } from "@/components/ui";
import { apiClient } from "@/lib/api";
@ -9,7 +9,7 @@ interface Calendar {
id: number;
name: string;
title?: string;
color: string;
color?: string;
is_subscribed?: boolean;
subscriber_count?: number;
visibility?: string;
@ -191,7 +191,7 @@ export function CalendarSubscriptionsModal({ isOpen, onClose, onUpdate }: Props)
<div className="flex items-center gap-3">
<div
className="h-4 w-4 rounded"
style={{ backgroundColor: cal.color }}
style={{ backgroundColor: cal.color || "var(--accent-primary)" }}
/>
<div>
<p className="font-medium text-[var(--foreground)]">{cal.name || cal.title}</p>

View File

@ -4,6 +4,7 @@ import { UserProvider } from "@/contexts/UserContext";
import { CalendarProvider } from "@/contexts/CalendarContext";
import { NotificationsProvider } from "@/contexts/NotificationsContext";
import { MobileNavPlacementProvider } from "@/contexts/MobileNavPlacementContext";
import { PwaProvider } from "@/contexts/PwaContext";
import { ThemeProvider, useTheme } from "@/contexts/ThemeContext";
import { ReactNode } from "react";
import { Toaster } from "sonner";
@ -21,8 +22,10 @@ export function Providers({ children }: { children: ReactNode }) {
<UserProvider>
<NotificationsProvider>
<CalendarProvider>
<AppToaster />
{children}
<PwaProvider>
<AppToaster />
{children}
</PwaProvider>
</CalendarProvider>
</NotificationsProvider>
</UserProvider>

View File

@ -6,21 +6,29 @@ 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,
toCalendarEventDraft,
} 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 +64,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 +75,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 +133,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);
@ -169,12 +154,12 @@ export const CalendarCard = memo(function CalendarCard({
if (event.is_recurring && event.event_id) {
try {
const fullEvent = await calendarService.loadFullEvent(event.event_id, event.start || undefined, event.end || undefined);
onOpenEventModal(fullEvent);
onOpenEventModal(toCalendarEventDraft(fullEvent));
} catch (err) {
console.error("Ошибка загрузки базового события:", err);
}
} else {
onOpenEventModal(event);
onOpenEventModal(toCalendarEventDraft(event));
}
}, [onOpenEventModal]);
@ -189,9 +174,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 +360,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 +409,7 @@ export const CalendarCard = memo(function CalendarCard({
className="hidden"
/>
{!isDepartmentCalendar ? (
{!departmentCalendar ? (
<>
<div className="app-divider my-1 border-t"></div>
<button
@ -643,7 +623,7 @@ export const CalendarCard = memo(function CalendarCard({
<div className="flex items-center gap-1">
<div
className="h-2 w-2 rounded-full flex-shrink-0"
style={{ backgroundColor: resolveEventColor(event.color_event || event.color) }}
style={{ backgroundColor: resolveEventColor(event.color_event) }}
/>
<p className="truncate text-xs font-medium text-[var(--foreground)]">{event.title}</p>
{(event.is_recurring || event.rule) && (

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

@ -0,0 +1,95 @@
"use client";
import { createContext, useCallback, useContext, useEffect, useMemo, useState, type ReactNode } from "react";
import {
type BeforeInstallPromptEvent,
isStandaloneDisplayMode,
registerAppServiceWorker,
} from "@/lib/pwa";
type InstallResult = "accepted" | "dismissed" | "unavailable";
type PwaContextValue = {
canInstall: boolean;
isInstalled: boolean;
isRegistrationReady: boolean;
install: () => Promise<InstallResult>;
};
const PwaContext = createContext<PwaContextValue | undefined>(undefined);
export function PwaProvider({ children }: { children: ReactNode }) {
const [deferredPrompt, setDeferredPrompt] = useState<BeforeInstallPromptEvent | null>(null);
const [isInstalled, setIsInstalled] = useState(() => isStandaloneDisplayMode());
const [isRegistrationReady, setIsRegistrationReady] = useState(false);
useEffect(() => {
void registerAppServiceWorker()
.then((registration) => {
setIsRegistrationReady(Boolean(registration));
})
.catch((error) => {
console.error("[PWA] Service Worker registration failed:", error);
setIsRegistrationReady(false);
});
}, []);
useEffect(() => {
const handleBeforeInstallPrompt = (event: Event) => {
const installEvent = event as BeforeInstallPromptEvent;
installEvent.preventDefault();
setDeferredPrompt(installEvent);
};
const handleAppInstalled = () => {
setDeferredPrompt(null);
setIsInstalled(true);
};
window.addEventListener("beforeinstallprompt", handleBeforeInstallPrompt);
window.addEventListener("appinstalled", handleAppInstalled);
return () => {
window.removeEventListener("beforeinstallprompt", handleBeforeInstallPrompt);
window.removeEventListener("appinstalled", handleAppInstalled);
};
}, []);
const install = useCallback(async (): Promise<InstallResult> => {
if (!deferredPrompt) {
return "unavailable";
}
await deferredPrompt.prompt();
const choice = await deferredPrompt.userChoice;
if (choice.outcome === "accepted") {
setIsInstalled(true);
setDeferredPrompt(null);
return "accepted";
}
return "dismissed";
}, [deferredPrompt]);
const value = useMemo<PwaContextValue>(
() => ({
canInstall: !isInstalled && deferredPrompt !== null,
isInstalled,
isRegistrationReady,
install,
}),
[deferredPrompt, install, isInstalled, isRegistrationReady],
);
return <PwaContext.Provider value={value}>{children}</PwaContext.Provider>;
}
export function usePwa() {
const context = useContext(PwaContext);
if (!context) {
throw new Error("usePwa must be used within a PwaProvider");
}
return context;
}

View File

@ -2,8 +2,13 @@
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 {
toCalendarEventDraft,
type CalendarEvent,
type CalendarEventDraft,
} from "@/services/calendarService";
export interface CalendarModalsState {
showCalendarModal: boolean;
@ -12,17 +17,10 @@ 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;
viewingEvent: CalendarEventDraft | null;
sidebarEvents: CalendarEvent[];
currentSelectedCalendarId: number | null;
eventsRefreshTrigger: number;
@ -36,17 +34,10 @@ 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 [viewingEvent, setViewingEvent] = useState<CalendarEventDraft | null>(null);
const [sidebarEvents, setSidebarEvents] = useState<CalendarEvent[]>([]);
const [currentSelectedCalendarId, setCurrentSelectedCalendarId] = useState<number | null>(null);
const [eventsRefreshTrigger, setEventsRefreshTrigger] = useState(0);
@ -68,15 +59,15 @@ 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);
} else if (event.id) {
setViewingEvent(event as CalendarEvent);
setViewingEvent(event);
setShowEventDetailsModal(true);
} else {
setEditingEvent(event as CalendarEvent);
setEditingEvent(event);
setShowEventModal(true);
}
}, []);
@ -93,15 +84,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]);
@ -118,17 +110,17 @@ export function useCalendarModals() {
if (event.is_recurring && event.event_id) {
try {
const fullEvent = await apiClient.getEvent(event.event_id);
setViewingEvent({
setViewingEvent(toCalendarEventDraft({
...fullEvent,
start: event.start,
end: event.end,
});
}));
setShowEventDetailsModal(true);
} catch (err) {
console.error("Ошибка загрузки события:", err);
}
} else {
setViewingEvent(event);
setViewingEvent(toCalendarEventDraft(event));
setShowEventDetailsModal(true);
}
}, []);
@ -137,14 +129,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

@ -2,6 +2,8 @@
* Web Push утилиты для управления подписками
*/
import { registerAppServiceWorker } from "@/lib/pwa";
/**
* Конвертирует base64 строку в Uint8Array для applicationServerKey
*/
@ -68,27 +70,12 @@ export async function requestNotificationPermission(): Promise<NotificationPermi
* Регистрирует Service Worker
*/
export async function registerServiceWorker(): Promise<ServiceWorkerRegistration> {
if (!('serviceWorker' in navigator)) {
const registration = await registerAppServiceWorker();
if (!registration) {
throw new Error('Service Worker не поддерживается');
}
const registration = await navigator.serviceWorker.register('/sw.js', {
scope: '/'
});
console.log('[PushNotifications] Service Worker зарегистрирован:', registration.scope);
// Ждем активации
if (registration.installing) {
await new Promise<void>((resolve) => {
registration.installing!.addEventListener('statechange', (e) => {
if ((e.target as ServiceWorker).state === 'activated') {
resolve();
}
});
});
}
return registration;
}

49
frontend/src/lib/pwa.ts Normal file
View File

@ -0,0 +1,49 @@
"use client";
export interface BeforeInstallPromptEvent extends Event {
readonly platforms: string[];
readonly userChoice: Promise<{ outcome: "accepted" | "dismissed"; platform: string }>;
prompt: () => Promise<void>;
}
export function isStandaloneDisplayMode(): boolean {
if (typeof window === "undefined") {
return false;
}
const standaloneMatch = window.matchMedia?.("(display-mode: standalone)").matches;
const navigatorStandalone =
typeof navigator !== "undefined" &&
"standalone" in navigator &&
Boolean((navigator as Navigator & { standalone?: boolean }).standalone);
return Boolean(standaloneMatch || navigatorStandalone);
}
export async function registerAppServiceWorker(): Promise<ServiceWorkerRegistration | null> {
if (typeof window === "undefined" || !("serviceWorker" in navigator)) {
return null;
}
const existingRegistration = await navigator.serviceWorker.getRegistration("/");
if (existingRegistration) {
await existingRegistration.update().catch(() => undefined);
return existingRegistration;
}
const registration = await navigator.serviceWorker.register("/sw.js", {
scope: "/",
});
if (registration.installing) {
await new Promise<void>((resolve) => {
registration.installing?.addEventListener("statechange", (event) => {
if ((event.target as ServiceWorker).state === "activated") {
resolve();
}
});
});
}
return registration;
}

View File

@ -4,23 +4,46 @@
*/
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;
};
export function toCalendarEventDraft(
event: CalendarEvent | CalendarEventDetails,
): CalendarEventDraft {
return {
...event,
id: typeof event.id === "number" ? event.id : undefined,
start: event.start ?? null,
end: event.end ?? null,
calendar:
typeof event.calendar === "number" ? event.calendar : event.calendar ?? null,
color_event: event.color_event ?? null,
rule: event.rule ?? null,
event_id: "event_id" in event ? event.event_id : undefined,
is_recurring: "is_recurring" in event ? event.is_recurring : false,
can_edit: event.can_edit,
can_delete: event.can_delete,
};
}
interface EventsCache {
@ -49,6 +72,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 +146,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 +181,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;