Merge branch 'develop'
This commit is contained in:
commit
784b959aa3
2
frontend/next-env.d.ts
vendored
2
frontend/next-env.d.ts
vendored
@ -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.
|
||||
|
||||
BIN
frontend/public/apple-touch-icon.png
Normal file
BIN
frontend/public/apple-touch-icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 7.3 KiB |
BIN
frontend/public/icon-192.png
Normal file
BIN
frontend/public/icon-192.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 8.3 KiB |
BIN
frontend/public/icon-512-maskable.png
Normal file
BIN
frontend/public/icon-512-maskable.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 18 KiB |
BIN
frontend/public/icon-512.png
Normal file
BIN
frontend/public/icon-512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
@ -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;
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
|
||||
@ -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({
|
||||
|
||||
32
frontend/src/app/manifest.ts
Normal file
32
frontend/src/app/manifest.ts
Normal 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",
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
@ -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="Контакты"
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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="Удалить участника"
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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) && (
|
||||
|
||||
@ -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;
|
||||
|
||||
95
frontend/src/contexts/PwaContext.tsx
Normal file
95
frontend/src/contexts/PwaContext.tsx
Normal 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;
|
||||
}
|
||||
@ -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);
|
||||
}, []);
|
||||
|
||||
@ -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,
|
||||
};
|
||||
}
|
||||
@ -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
49
frontend/src/lib/pwa.ts
Normal 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;
|
||||
}
|
||||
@ -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, используем их
|
||||
|
||||
@ -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