Refine search routing and deep links
This commit is contained in:
parent
a7abde279e
commit
9b23f480c2
@ -24,7 +24,6 @@ class SearchResultSerializer(serializers.Serializer):
|
||||
description = serializers.CharField(
|
||||
help_text="Описание/превью контента", allow_blank=True, required=False
|
||||
)
|
||||
url = serializers.CharField(help_text="Ссылка на объект", allow_blank=True)
|
||||
meta = serializers.DictField(
|
||||
help_text="Дополнительные метаданные", required=False, allow_null=True
|
||||
)
|
||||
|
||||
@ -53,7 +53,6 @@ def _format_employee(obj: Any) -> Dict[str, Any]:
|
||||
"description": getattr(obj.position, "name", "")
|
||||
if hasattr(obj, "position")
|
||||
else "",
|
||||
"url": f"/employees/{obj.pk}/",
|
||||
"meta": {
|
||||
"email": obj.email,
|
||||
"phone": str(obj.phone_number) if obj.phone_number else "",
|
||||
@ -68,7 +67,6 @@ def _format_department(obj: Any) -> Dict[str, Any]:
|
||||
"object_id": obj.pk,
|
||||
"title": obj.name,
|
||||
"description": obj.description or "",
|
||||
"url": f"/departments/{obj.pk}/",
|
||||
"meta": {
|
||||
"head": str(obj.head) if hasattr(obj, "head") and obj.head else None
|
||||
},
|
||||
@ -84,7 +82,6 @@ def _format_post(obj: Any) -> Dict[str, Any]:
|
||||
"description": obj.body[:200] + "..."
|
||||
if len(obj.body) > 200
|
||||
else obj.body,
|
||||
"url": f"/feed/{obj.pk}/",
|
||||
"meta": {
|
||||
"author": str(obj.author) if hasattr(obj, "author") else None,
|
||||
"created_at": obj.created_at.isoformat()
|
||||
@ -103,7 +100,6 @@ def _format_request(obj: Any) -> Dict[str, Any]:
|
||||
"description": obj.comment[:200] + "..."
|
||||
if obj.comment and len(obj.comment) > 200
|
||||
else (obj.comment or ""),
|
||||
"url": f"/requests/{obj.pk}/",
|
||||
"meta": {
|
||||
"status": obj.status,
|
||||
"type": obj.type,
|
||||
@ -119,7 +115,6 @@ def _format_chat(obj: Any) -> Dict[str, Any]:
|
||||
"object_id": obj.pk,
|
||||
"title": obj.name or f"Чат #{obj.pk}",
|
||||
"description": obj.description or "",
|
||||
"url": f"/messages?chat={obj.pk}",
|
||||
"meta": {
|
||||
"type": obj.type if hasattr(obj, "type") else None,
|
||||
},
|
||||
@ -137,14 +132,10 @@ def _format_message(obj: Any) -> Dict[str, Any]:
|
||||
"description": obj.content[:200] + "..."
|
||||
if len(obj.content) > 200
|
||||
else obj.content,
|
||||
"url": (
|
||||
f"/messages?chat={
|
||||
obj.chat.pk if hasattr(obj, 'chat') else ''
|
||||
}#msg-{obj.pk}"
|
||||
),
|
||||
"meta": {
|
||||
"author": str(obj.author) if hasattr(obj, "author") else None,
|
||||
"chat": str(obj.chat) if hasattr(obj, "chat") else None,
|
||||
"chat_id": obj.chat.pk if hasattr(obj, "chat") else None,
|
||||
},
|
||||
}
|
||||
|
||||
@ -156,7 +147,6 @@ def _format_event(obj: Any) -> Dict[str, Any]:
|
||||
"object_id": obj.pk,
|
||||
"title": obj.title,
|
||||
"description": obj.description or "",
|
||||
"url": f"/calendar?event={obj.pk}",
|
||||
"meta": {
|
||||
"start_date": obj.start_date.isoformat()
|
||||
if hasattr(obj, "start_date")
|
||||
@ -172,7 +162,6 @@ def _format_schedule_event(obj: Any) -> Dict[str, Any]:
|
||||
"object_id": obj.pk,
|
||||
"title": obj.title,
|
||||
"description": obj.description or "",
|
||||
"url": "/calendar/",
|
||||
"meta": {
|
||||
"start": obj.start.isoformat() if hasattr(obj, "start") else None,
|
||||
"end": obj.end.isoformat()
|
||||
@ -191,7 +180,6 @@ def _format_procurement_request(obj: Any) -> Dict[str, Any]:
|
||||
"description": obj.description[:200] + "..."
|
||||
if len(obj.description) > 200
|
||||
else obj.description,
|
||||
"url": f"/procurement/requests/{obj.pk}/",
|
||||
"meta": {
|
||||
"status": obj.status if hasattr(obj, "status") else None,
|
||||
"requestor": str(obj.requestor)
|
||||
@ -215,7 +203,6 @@ def _format_equipment(obj: Any) -> Dict[str, Any]:
|
||||
if obj.serial_number
|
||||
else f"Инв. №: {obj.inventory_number}"
|
||||
),
|
||||
"url": f"/procurement/equipment/{obj.pk}/",
|
||||
"meta": {
|
||||
"inventory_number": obj.inventory_number,
|
||||
"status": obj.status if hasattr(obj, "status") else None,
|
||||
@ -235,7 +222,6 @@ def _format_document(obj: Any) -> Dict[str, Any]:
|
||||
if obj.description and len(obj.description) > 200
|
||||
else (obj.description or "")
|
||||
),
|
||||
"url": f"/documents/{obj.pk}/",
|
||||
"meta": {
|
||||
"uploaded_by": str(obj.uploaded_by)
|
||||
if hasattr(obj, "uploaded_by") and obj.uploaded_by
|
||||
@ -247,23 +233,34 @@ def _format_document(obj: Any) -> Dict[str, Any]:
|
||||
}
|
||||
|
||||
|
||||
def _get_notification_search_title(obj: Any) -> str:
|
||||
"""Строит заголовок уведомления для поиска без legacy-зависимостей."""
|
||||
data = getattr(obj, "data", None) or {}
|
||||
if isinstance(data, dict) and data.get("title"):
|
||||
return str(data["title"])
|
||||
|
||||
verb = getattr(obj, "verb", "") or "notification"
|
||||
return str(verb).replace("_", " ").title()
|
||||
|
||||
|
||||
def _get_notification_search_message(obj: Any) -> str:
|
||||
"""Возвращает текст уведомления для результатов поиска."""
|
||||
return getattr(obj, "description", "") or ""
|
||||
|
||||
|
||||
def _format_notification(obj: Any) -> Dict[str, Any]:
|
||||
"""Форматирует уведомление для API."""
|
||||
message = _get_notification_search_message(obj)
|
||||
|
||||
return {
|
||||
"model_name": "notification",
|
||||
"object_id": obj.pk,
|
||||
"title": obj.title,
|
||||
"description": obj.short_message
|
||||
or (
|
||||
obj.message[:150] + "..." if len(obj.message) > 150 else obj.message
|
||||
),
|
||||
"url": obj.action_url
|
||||
if hasattr(obj, "action_url") and obj.action_url
|
||||
else "/notifications/",
|
||||
"title": _get_notification_search_title(obj),
|
||||
"description": message[:150] + "..." if len(message) > 150 else message,
|
||||
"meta": {
|
||||
"is_read": obj.is_read if hasattr(obj, "is_read") else False,
|
||||
"created_at": obj.created_at.isoformat()
|
||||
if hasattr(obj, "created_at")
|
||||
"is_read": not getattr(obj, "unread", True),
|
||||
"created_at": obj.timestamp.isoformat()
|
||||
if hasattr(obj, "timestamp") and obj.timestamp
|
||||
else None,
|
||||
},
|
||||
}
|
||||
@ -296,7 +293,6 @@ def _format_result(obj: Any, model_name: str) -> Dict[str, Any]:
|
||||
"object_id": obj.pk,
|
||||
"title": str(obj),
|
||||
"description": "",
|
||||
"url": "#",
|
||||
"meta": {},
|
||||
}
|
||||
|
||||
@ -339,7 +335,6 @@ def search_api_view(request: DRFRequest) -> Response:
|
||||
"object_id": 123,
|
||||
"title": "Заголовок",
|
||||
"description": "Описание",
|
||||
"url": "/path/to/object/",
|
||||
"meta": {...}
|
||||
},
|
||||
...
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
"""
|
||||
API endpoints для системы уведомлений v2.0
|
||||
|
||||
|
||||
Новая архитектура:
|
||||
- Notification с GenericForeignKey (actor/action_object/target)
|
||||
- verb вместо category/type
|
||||
@ -35,16 +36,6 @@ from .serializers import (
|
||||
)
|
||||
|
||||
|
||||
def _get_notification_title(notification):
|
||||
"""Получить заголовок уведомления из data или сгенерировать fallback"""
|
||||
# Приоритет: title из data (задается при создании уведомления)
|
||||
if notification.data and 'title' in notification.data:
|
||||
return notification.data['title']
|
||||
|
||||
# Fallback: генерируем простой заголовок из verb
|
||||
return notification.verb.replace('_', ' ').title()
|
||||
|
||||
|
||||
def _format_time_or_none(value):
|
||||
if value is None:
|
||||
return None
|
||||
@ -126,7 +117,11 @@ def get_notifications(request):
|
||||
{
|
||||
'id': n.id,
|
||||
# Frontend ожидает title, message, is_read, created_at
|
||||
'title': _get_notification_title(n),
|
||||
'title': (
|
||||
n.data['title']
|
||||
if n.data and 'title' in n.data
|
||||
else n.verb.replace('_', ' ').title()
|
||||
),
|
||||
'message': n.description,
|
||||
'short_message': (
|
||||
n.description[:100] + '...'
|
||||
|
||||
@ -10,6 +10,7 @@ from rest_framework.test import APIClient
|
||||
|
||||
from employees.models import Employee, Department, Position
|
||||
from feed.models import Post
|
||||
from notifications.models import Notification
|
||||
from requests_app.models import Request
|
||||
|
||||
|
||||
@ -251,7 +252,7 @@ class TestSearchAPI:
|
||||
assert 'model_name' in result
|
||||
assert 'object_id' in result
|
||||
assert 'title' in result
|
||||
assert 'url' in result
|
||||
assert 'url' not in result
|
||||
|
||||
def test_search_request_permissions(self, api_client, sample_data):
|
||||
"""Пользователи видят только свои заявления."""
|
||||
@ -296,6 +297,30 @@ class TestSearchAPI:
|
||||
posts = [r for r in response.data['results'] if r['model_name'] == 'post']
|
||||
assert len(posts) > 0
|
||||
|
||||
def test_search_formats_notification_v2_without_500(self, api_client, regular_user):
|
||||
"""Поиск по уведомлениям v2 не падает на legacy-ожиданиях формата."""
|
||||
from django.core.management import call_command
|
||||
|
||||
Notification.objects.create(
|
||||
recipient=regular_user,
|
||||
verb="system_notice",
|
||||
description="Это обычный текст уведомления для поиска",
|
||||
data={"title": "Важное уведомление"},
|
||||
action_url="/documents/5/",
|
||||
)
|
||||
call_command('buildwatson', verbosity=0)
|
||||
|
||||
response = api_client.get('/api/v1/search/?q=обычный')
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
notifications = [
|
||||
r for r in response.data['results'] if r['model_name'] == 'notification'
|
||||
]
|
||||
assert len(notifications) > 0
|
||||
assert notifications[0]['title'] == 'Важное уведомление'
|
||||
assert 'обычный текст уведомления' in notifications[0]['description'].lower()
|
||||
assert 'url' not in notifications[0]
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestSearchIndexing:
|
||||
|
||||
@ -4,6 +4,7 @@ import { AppShell } from "../../components/AppShell";
|
||||
import { apiClient } from "@/lib/api";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import dynamic from "next/dynamic";
|
||||
import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
||||
import type { Document } from "@/types/api";
|
||||
import {
|
||||
Search,
|
||||
@ -62,6 +63,9 @@ function formatDate(value?: string): string {
|
||||
}
|
||||
|
||||
export default function DocumentsPage() {
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const searchParams = useSearchParams();
|
||||
// State
|
||||
const [documents, setDocuments] = useState<Document[]>([]);
|
||||
const [folders, setFolders] = useState<FolderNode[]>([]);
|
||||
@ -95,6 +99,19 @@ export default function DocumentsPage() {
|
||||
|
||||
// Bulk selection
|
||||
const selection = useDocumentSelection(documents);
|
||||
const linkedDocumentId = Number(searchParams.get("document") || "");
|
||||
|
||||
const clearDocumentParam = () => {
|
||||
if (!searchParams.get("document")) return;
|
||||
const nextParams = new URLSearchParams(searchParams.toString());
|
||||
nextParams.delete("document");
|
||||
router.replace(nextParams.toString() ? `${pathname}?${nextParams.toString()}` : pathname, { scroll: false });
|
||||
};
|
||||
|
||||
const closeSelectedDocument = () => {
|
||||
setSelectedDocument(null);
|
||||
clearDocumentParam();
|
||||
};
|
||||
|
||||
const loadDocuments = async () => {
|
||||
try {
|
||||
@ -143,6 +160,26 @@ export default function DocumentsPage() {
|
||||
loadTags();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!linkedDocumentId || selectedDocument?.id === linkedDocumentId) return;
|
||||
|
||||
let cancelled = false;
|
||||
|
||||
apiClient.getDocument(linkedDocumentId)
|
||||
.then((document) => {
|
||||
if (!cancelled) {
|
||||
setSelectedDocument(document);
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("Ошибка deep-link документа:", error);
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [linkedDocumentId, selectedDocument?.id]);
|
||||
|
||||
const filteredDocuments = useMemo(() => {
|
||||
const q = search.trim().toLowerCase();
|
||||
|
||||
@ -813,7 +850,7 @@ export default function DocumentsPage() {
|
||||
<DocumentDetailModal
|
||||
document={selectedDocument}
|
||||
isOpen={!!selectedDocument}
|
||||
onClose={() => setSelectedDocument(null)}
|
||||
onClose={closeSelectedDocument}
|
||||
onUpdate={() => {
|
||||
loadDocuments();
|
||||
if (selectedDocument) {
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import React, { useEffect, useLayoutEffect, useMemo, useRef, useState, useCallback } from "react";
|
||||
import { useParams } from "next/navigation";
|
||||
import { useParams, usePathname, useRouter, useSearchParams } from "next/navigation";
|
||||
import { MessageCircle, ChevronDown, ChevronLeft, ChevronRight } from "lucide-react";
|
||||
import { AppShell } from "../../../components/AppShell";
|
||||
import ChatDialogHeader from "@/components/messages/ChatDialogHeader";
|
||||
@ -62,6 +62,9 @@ function formatDateInputValue(date: Date): string {
|
||||
|
||||
export default function MessageDialogPage() {
|
||||
const params = useParams<{ chatId: string }>();
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const searchParams = useSearchParams();
|
||||
const chatId = Number(params.chatId);
|
||||
const { user, loading: userLoading } = useUser();
|
||||
const { handleReconnectExhausted, resetReloadGuard } = useSilentChatReloadGuard(chatId || null);
|
||||
@ -154,6 +157,7 @@ export default function MessageDialogPage() {
|
||||
const [searchError, setSearchError] = useState<string | null>(null);
|
||||
const [nextSearchOffset, setNextSearchOffset] = useState<number | null>(null);
|
||||
const [highlightedMessageId, setHighlightedMessageId] = useState<number | null>(null);
|
||||
const linkedMessageId = Number(searchParams.get("message") || "");
|
||||
|
||||
// Sync settings from chat
|
||||
useEffect(() => {
|
||||
@ -657,6 +661,36 @@ export default function MessageDialogPage() {
|
||||
}, 2200);
|
||||
}, []);
|
||||
|
||||
const clearMessageParam = useCallback(() => {
|
||||
if (!searchParams.get("message")) return;
|
||||
const nextParams = new URLSearchParams(searchParams.toString());
|
||||
nextParams.delete("message");
|
||||
router.replace(nextParams.toString() ? `${pathname}?${nextParams.toString()}` : pathname, { scroll: false });
|
||||
}, [pathname, router, searchParams]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!linkedMessageId) {
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
|
||||
void (async () => {
|
||||
const jumped = await cm.jumpToMessage(linkedMessageId);
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
if (jumped) {
|
||||
highlightFoundMessage(linkedMessageId);
|
||||
}
|
||||
clearMessageParam();
|
||||
})();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [clearMessageParam, cm.jumpToMessage, highlightFoundMessage, linkedMessageId]);
|
||||
|
||||
const runSearch = useCallback(async (query: string, options?: { append?: boolean; offset?: number }) => {
|
||||
const normalizedQuery = query.trim();
|
||||
if (!chatId || normalizedQuery.length < 2) {
|
||||
|
||||
@ -6,6 +6,7 @@ import { Modal } from "@/components/ui";
|
||||
import { apiClient } from "@/lib/api";
|
||||
import { resolveMediaUrl } from "@/lib/url";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
||||
import type { Comment, Post } from "@/types/api";
|
||||
import { useUser } from "@/contexts/UserContext";
|
||||
|
||||
@ -19,6 +20,9 @@ type LikeUser = {
|
||||
|
||||
export default function Home() {
|
||||
const { user } = useUser();
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const searchParams = useSearchParams();
|
||||
const [posts, setPosts] = useState<Post[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
@ -77,6 +81,19 @@ export default function Home() {
|
||||
const auth = user?.auth;
|
||||
const authPerms = auth?.permissions || [];
|
||||
const authByApp = auth?.permissions_by_app || {};
|
||||
const linkedPostId = Number(searchParams.get("post") || "");
|
||||
|
||||
const clearPostParam = () => {
|
||||
if (!searchParams.get("post")) return;
|
||||
const nextParams = new URLSearchParams(searchParams.toString());
|
||||
nextParams.delete("post");
|
||||
router.replace(nextParams.toString() ? `${pathname}?${nextParams.toString()}` : pathname, { scroll: false });
|
||||
};
|
||||
|
||||
const closeCommentsModal = () => {
|
||||
setCommentsOpen(false);
|
||||
clearPostParam();
|
||||
};
|
||||
|
||||
const hasPermission = (perm: string) => {
|
||||
if (!perm) return false;
|
||||
@ -171,7 +188,7 @@ export default function Home() {
|
||||
if (!commentsOpen && !createPostOpen) return;
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") {
|
||||
setCommentsOpen(false);
|
||||
closeCommentsModal();
|
||||
}
|
||||
};
|
||||
|
||||
@ -296,6 +313,16 @@ export default function Home() {
|
||||
await loadComments(post.id);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!linkedPostId || posts.length === 0) return;
|
||||
if (commentsOpen && activePost?.id === linkedPostId) return;
|
||||
|
||||
const targetPost = posts.find((post) => post.id === linkedPostId);
|
||||
if (!targetPost) return;
|
||||
|
||||
void openComments(targetPost);
|
||||
}, [activePost?.id, commentsOpen, linkedPostId, posts]);
|
||||
|
||||
const handleCreateComment = async () => {
|
||||
if (!activePost) return;
|
||||
const text = newComment.trim();
|
||||
@ -550,7 +577,7 @@ export default function Home() {
|
||||
: `${Math.floor(diffHours / 24)} дн. назад`;
|
||||
|
||||
return (
|
||||
<article key={post.id} className="rounded-2xl bg-white p-5 shadow-sm ring-1 ring-gray-100">
|
||||
<article id={`post-${post.id}`} key={post.id} className="rounded-2xl bg-white p-5 shadow-sm ring-1 ring-gray-100">
|
||||
<header className="mb-3 flex items-start justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="relative h-10 w-10">
|
||||
@ -787,7 +814,7 @@ export default function Home() {
|
||||
|
||||
<Modal
|
||||
isOpen={commentsOpen && !!activePost}
|
||||
onClose={() => setCommentsOpen(false)}
|
||||
onClose={closeCommentsModal}
|
||||
title="Комментарии"
|
||||
size="lg"
|
||||
noPadding
|
||||
|
||||
@ -8,6 +8,41 @@ import { apiClient } from "@/lib/api";
|
||||
import type { SearchResponse, SearchResult, SearchModelType } from "@/types/api";
|
||||
import { Search, User, Building, FileText, MessageSquare, MessageCircle, Calendar, Inbox, ShoppingCart, Package, Bell } from "lucide-react";
|
||||
|
||||
function buildSearchHref(result: SearchResult): string {
|
||||
switch (result.model_name) {
|
||||
case "employee":
|
||||
return `/users/${result.object_id}`;
|
||||
case "department":
|
||||
return `/departments/${result.object_id}`;
|
||||
case "post":
|
||||
return `/?post=${result.object_id}`;
|
||||
case "request":
|
||||
return `/requests?request=${result.object_id}`;
|
||||
case "chat":
|
||||
return `/messages/${result.object_id}`;
|
||||
case "message": {
|
||||
const chatId = typeof result.meta?.chat_id === "number"
|
||||
? result.meta.chat_id
|
||||
: Number(result.meta?.chat_id || 0);
|
||||
return chatId ? `/messages/${chatId}?message=${result.object_id}` : "/messages";
|
||||
}
|
||||
case "event":
|
||||
return "/calendar";
|
||||
case "schedule_event":
|
||||
return `/calendar?event=${result.object_id}`;
|
||||
case "procurement_request":
|
||||
return "/procurement";
|
||||
case "equipment":
|
||||
return "/equipment";
|
||||
case "document":
|
||||
return `/documents?document=${result.object_id}`;
|
||||
case "notification":
|
||||
return "/notifications";
|
||||
default:
|
||||
return "/search";
|
||||
}
|
||||
}
|
||||
|
||||
// Иконки для разных типов результатов
|
||||
const modelIcons: Record<SearchModelType, React.ComponentType<{ size?: number; className?: string }>> = {
|
||||
employee: User,
|
||||
@ -166,7 +201,7 @@ function SearchPageContent() {
|
||||
{results.map((result) => (
|
||||
<Link
|
||||
key={`${result.model_name}-${result.object_id}`}
|
||||
href={result.url}
|
||||
href={buildSearchHref(result)}
|
||||
className="block rounded-lg border border-gray-200 p-3 transition-all hover:border-blue-300 hover:bg-blue-50/50 hover:shadow-sm"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
|
||||
@ -4,6 +4,7 @@ import { Calendar, dateFnsLocalizer, View, ToolbarProps, NavigateAction } from "
|
||||
import { format, parse, startOfWeek, getDay } from "date-fns";
|
||||
import { ru } from "date-fns/locale";
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
||||
import { apiClient } from "@/lib/api";
|
||||
import { useCalendar } from "@/contexts/CalendarContext";
|
||||
import { X, Plus, Trash2, Settings, ChevronLeft, ChevronRight } from "lucide-react";
|
||||
@ -122,6 +123,9 @@ const CustomToolbar = ({ label, onNavigate, onView, view, date }: ToolbarProps<C
|
||||
};
|
||||
|
||||
export function BigCalendar() {
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const searchParams = useSearchParams();
|
||||
const { calendars, selectedCalendarId, setSelectedCalendarId, reloadCalendars } = useCalendar();
|
||||
const [events, setEvents] = useState<CalendarEvent[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
@ -139,6 +143,14 @@ export function BigCalendar() {
|
||||
const [selectedDate, setSelectedDate] = useState<Date | null>(null);
|
||||
const [showEventDetailsModal, setShowEventDetailsModal] = useState(false);
|
||||
const [viewingEvent, setViewingEvent] = useState<CalendarEvent | null>(null);
|
||||
const linkedEventId = Number(searchParams.get("event") || "");
|
||||
|
||||
const clearEventParam = useCallback(() => {
|
||||
if (!searchParams.get("event")) return;
|
||||
const nextParams = new URLSearchParams(searchParams.toString());
|
||||
nextParams.delete("event");
|
||||
router.replace(nextParams.toString() ? `${pathname}?${nextParams.toString()}` : pathname, { scroll: false });
|
||||
}, [pathname, router, searchParams]);
|
||||
|
||||
// Загрузка событий
|
||||
const loadEvents = useCallback(async () => {
|
||||
@ -220,6 +232,32 @@ export function BigCalendar() {
|
||||
loadEvents();
|
||||
}, [loadEvents]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!linkedEventId || viewingEvent?.id === linkedEventId) return;
|
||||
|
||||
let cancelled = false;
|
||||
|
||||
apiClient.getEvent(linkedEventId)
|
||||
.then((event) => {
|
||||
if (cancelled) return;
|
||||
|
||||
setViewingEvent({
|
||||
...event,
|
||||
start: new Date(event.start),
|
||||
end: new Date(event.end),
|
||||
});
|
||||
setCurrentDate(new Date(event.start));
|
||||
setShowEventDetailsModal(true);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("Ошибка deep-link события календаря:", error);
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [linkedEventId, viewingEvent?.id]);
|
||||
|
||||
// Обработчики календаря
|
||||
const handleSelectSlot = useCallback(
|
||||
({ start }: { start: Date; end: Date }) => {
|
||||
@ -464,6 +502,7 @@ export function BigCalendar() {
|
||||
onClose={() => {
|
||||
setShowEventDetailsModal(false);
|
||||
setViewingEvent(null);
|
||||
clearEventParam();
|
||||
}}
|
||||
event={viewingEvent}
|
||||
onEdit={handleEditFromDetails}
|
||||
|
||||
@ -29,6 +29,7 @@ export function createRequestsApi(request: RequestFn, getToken: GetTokenFn) {
|
||||
const qs = qp.toString();
|
||||
return request(`/api/v1/requests/${qs ? '?' + qs : ''}`);
|
||||
},
|
||||
getRequest: (requestId: number) => request(`/api/v1/requests/${requestId}/`),
|
||||
createRequest: (data: Record<string, any>, saveAs?: 'draft' | 'submitted') => formDataRequest('/api/v1/requests/', 'POST', data, saveAs),
|
||||
updateRequest: (requestId: number, data: Record<string, any>, saveAs?: 'draft' | 'submitted') => formDataRequest(`/api/v1/requests/${requestId}/`, 'PATCH', data, saveAs),
|
||||
deleteRequest: (requestId: number): Promise<void> => request(`/api/v1/requests/${requestId}/`, { method: 'DELETE' }),
|
||||
|
||||
@ -676,7 +676,6 @@ export interface SearchResult {
|
||||
object_id: number;
|
||||
title: string;
|
||||
description?: string;
|
||||
url: string;
|
||||
meta?: Record<string, any>;
|
||||
}
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user