diff --git a/backend/communications/notifications/handlers.py b/backend/communications/notifications/handlers.py index bf1f1cd8..5bcc67e0 100644 --- a/backend/communications/notifications/handlers.py +++ b/backend/communications/notifications/handlers.py @@ -245,15 +245,15 @@ def _notify_comment(message): if model_name == 'Post': object_author = getattr(context_obj, 'author', None) - object_url = f"/feed/#{context_obj.id}" + object_url = f"/?post={context_obj.id}" object_type = "публикации" elif model_name == 'Document': object_author = getattr(context_obj, 'uploaded_by', None) - object_url = f"/documents/{context_obj.id}/" + object_url = f"/documents?document={context_obj.id}" object_type = "документа" elif hasattr(context_obj, 'employee'): # Request object_author = getattr(context_obj, 'employee', None) - object_url = f"/requests/{context_obj.id}/" + object_url = f"/requests?request={context_obj.id}" object_type = "заявки" # 1. Уведомляем автора объекта diff --git a/backend/documents/notifications/config.py b/backend/documents/notifications/config.py index 20a4949a..41c46c2c 100644 --- a/backend/documents/notifications/config.py +++ b/backend/documents/notifications/config.py @@ -84,7 +84,7 @@ class ActionURLs: TODO: реализовать прямые ссылки на конкретные документы """ - return f'/documents?id={document_id}' + return f'/documents?document={document_id}' # ===== Константы ===== diff --git a/backend/notifications/api/serializers.py b/backend/notifications/api/serializers.py index 0f570223..e72e7859 100644 --- a/backend/notifications/api/serializers.py +++ b/backend/notifications/api/serializers.py @@ -17,6 +17,7 @@ class NotificationItemSerializer(serializers.Serializer): category = serializers.CharField() action_url = serializers.CharField( required=False, allow_blank=True, allow_null=True) + data = serializers.JSONField(required=False, allow_null=True) verb = serializers.CharField() description = serializers.CharField() actor = NotificationActorSerializer(required=False, allow_null=True) diff --git a/backend/notifications/api/views.py b/backend/notifications/api/views.py index ab024c50..31d4fff7 100644 --- a/backend/notifications/api/views.py +++ b/backend/notifications/api/views.py @@ -132,6 +132,7 @@ def get_notifications(request): 'created_at': n.timestamp.isoformat(), 'category': n.verb, 'action_url': n.action_url, + 'data': n.data, # Дополнительные поля для обратной совместимости 'verb': n.verb, 'description': n.description, diff --git a/backend/requests_app/notifications/config.py b/backend/requests_app/notifications/config.py index c5f94aff..e6707567 100644 --- a/backend/requests_app/notifications/config.py +++ b/backend/requests_app/notifications/config.py @@ -168,4 +168,4 @@ class ActionURLs: @staticmethod def request_detail(request_id: int) -> str: """URL детальной страницы заявления.""" - return f'/requests/{request_id}' + return f'/requests?request={request_id}' diff --git a/frontend/src/app/requests/page.tsx b/frontend/src/app/requests/page.tsx index ec271247..12242a4b 100644 --- a/frontend/src/app/requests/page.tsx +++ b/frontend/src/app/requests/page.tsx @@ -13,7 +13,7 @@ import type { User } from "@/types/api"; import { ArrowUpDown, Ban, ChevronDown, ChevronRight, FileSignature, Filter, MessageSquare, Paperclip, Pencil, Plus, Search, ThumbsDown, ThumbsUp, Trash2, X, Zap } from "lucide-react"; import dynamic from "next/dynamic"; import { SearchableSelectMulti } from "@/components/shared/SearchableSelect"; -import { formatDate, displayUserName, userProfileLink } from "@/lib/shared"; +import { formatDate, formatDateTime, displayUserName, userProfileLink } from "@/lib/shared"; import { useRequestsPage, statusMeta, @@ -418,7 +418,7 @@ function RequestsPageContent() {
{dtl}{ds.label}

{dr.display_title || dr.title || "Без заголовка"}

{(dr.comment || dr.description) ?
{dr.comment || dr.description}
:

Описание отсутствует

}
-

Даты

Период

{dr.date_from ? formatDate(dr.date_from) : "—"}{dr.date_to ? ` — ${formatDate(dr.date_to)}` : ""}

Создано

{formatDate(dr.created_at) || "—"}

Обновлено

{formatDate(dr.updated_at) || "—"}

+

Даты

Период

{dr.date_from ? formatDate(dr.date_from) : "—"}{dr.date_to ? ` — ${formatDate(dr.date_to)}` : ""}

Создано

{formatDateTime(dr.created_at) || "—"}

Обновлено

{formatDateTime(dr.updated_at) || "—"}

Участники

Автор

{da ? renderUserBadge(da as User, true) : —}

Решающий

{dap ? renderUserBadge(dap as User, true) : —}

Отделы

{(dr.departments || []).length > 0 ?
{(dr.departments || []).map((id) => {h.departmentNameMap.get(Number(id)) || `Отдел #${id}`})}
:

Отделы не указаны

}
diff --git a/frontend/src/contexts/NotificationsContext.tsx b/frontend/src/contexts/NotificationsContext.tsx index 94ba1f08..7e9735c8 100644 --- a/frontend/src/contexts/NotificationsContext.tsx +++ b/frontend/src/contexts/NotificationsContext.tsx @@ -17,6 +17,7 @@ export type NotificationItem = { timestamp?: string; created_at?: string; action_url?: string; + data?: Record | null; [key: string]: unknown; }; diff --git a/frontend/src/lib/notifications/actionUrl.ts b/frontend/src/lib/notifications/actionUrl.ts index 91de1cf9..843def08 100644 --- a/frontend/src/lib/notifications/actionUrl.ts +++ b/frontend/src/lib/notifications/actionUrl.ts @@ -5,20 +5,177 @@ type NotificationNavigationLike = { data?: Record | null; }; +function parseNumericId(value: unknown): number | null { + if (typeof value === "number" && Number.isFinite(value)) { + return value; + } + + if (typeof value === "string" && value.trim()) { + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : null; + } + + return null; +} + +function buildUrl(pathname: string, params: Record): string { + const searchParams = new URLSearchParams(); + + Object.entries(params).forEach(([key, value]) => { + if (value !== null) { + searchParams.set(key, String(value)); + } + }); + + const query = searchParams.toString(); + return query ? `${pathname}?${query}` : pathname; +} + +function normalizeRawActionUrl(rawActionUrl: string): string { + try { + const url = new URL(rawActionUrl, "http://localhost"); + return `${url.pathname}${url.search}${url.hash}`; + } catch { + return rawActionUrl; + } +} + export function resolveNotificationActionUrl( notification: NotificationNavigationLike, ): string | null { - const rawRequestId = notification.data?.request_id; - const requestId = typeof rawRequestId === "number" - ? rawRequestId - : typeof rawRequestId === "string" && rawRequestId.trim() - ? Number(rawRequestId) - : null; + const data = notification.data && typeof notification.data === "object" + ? notification.data + : null; - const categoryHint = `${notification.verb || ""} ${notification.category || ""}`.toLowerCase(); - if (requestId && Number.isFinite(requestId) && categoryHint.includes("procurement")) { - return `/procurement?request=${requestId}`; + const requestId = parseNumericId(data?.request_id); + const documentId = parseNumericId(data?.document_id); + const eventId = parseNumericId(data?.event_id); + const postId = parseNumericId(data?.post_id); + const chatId = parseNumericId(data?.chat_id); + const messageId = parseNumericId(data?.message_id); + const objectId = parseNumericId(data?.object_id); + const objectType = typeof data?.object_type === "string" + ? data.object_type.toLowerCase() + : ""; + const categoryHint = [ + notification.verb || "", + notification.category || "", + objectType, + ].join(" ").toLowerCase(); + + if (requestId !== null && categoryHint.includes("procurement")) { + return buildUrl("/procurement", { request: requestId }); } - return notification.action_url || null; + if ( + requestId !== null && ( + categoryHint.includes("request") + || objectType === "request" + ) + ) { + return buildUrl("/requests", { request: requestId }); + } + + if ( + documentId !== null && ( + categoryHint.includes("document") + || objectType === "document" + ) + ) { + return buildUrl("/documents", { document: documentId }); + } + + if (eventId !== null && categoryHint.includes("event")) { + return buildUrl("/calendar", { event: eventId }); + } + + if ( + postId !== null && ( + categoryHint.includes("feed") + || categoryHint.includes("post") + || objectType === "post" + ) + ) { + return buildUrl("/", { post: postId }); + } + + if (chatId !== null) { + return buildUrl(`/messages/${chatId}`, { message: messageId }); + } + + if (objectId !== null) { + if (objectType === "post") { + return buildUrl("/", { post: objectId }); + } + if (objectType === "document") { + return buildUrl("/documents", { document: objectId }); + } + if (objectType === "request") { + return buildUrl("/requests", { request: objectId }); + } + } + + const rawActionUrl = typeof notification.action_url === "string" + ? normalizeRawActionUrl(notification.action_url.trim()) + : ""; + + if (rawActionUrl) { + const url = new URL(rawActionUrl, "http://localhost"); + + if (url.pathname === "/procurement") { + const rawProcurementRequestId = parseNumericId(url.searchParams.get("request")); + return buildUrl("/procurement", { request: rawProcurementRequestId }); + } + + if (url.pathname === "/requests") { + const rawLinkedRequestId = parseNumericId( + url.searchParams.get("request") ?? url.searchParams.get("id"), + ); + return buildUrl("/requests", { request: rawLinkedRequestId }); + } + + if (url.pathname === "/documents") { + const rawLinkedDocumentId = parseNumericId( + url.searchParams.get("document") ?? url.searchParams.get("id"), + ); + return buildUrl("/documents", { document: rawLinkedDocumentId }); + } + + if (url.pathname === "/calendar") { + const rawLinkedEventId = parseNumericId(url.searchParams.get("event")); + return buildUrl("/calendar", { event: rawLinkedEventId }); + } + + if (url.pathname === "/") { + const rawLinkedPostId = parseNumericId(url.searchParams.get("post")); + return buildUrl("/", { post: rawLinkedPostId }); + } + } + + const requestMatch = rawActionUrl.match(/^\/requests\/(\d+)\/?(?:[#?].*)?$/); + if (requestMatch) { + return buildUrl("/requests", { request: Number(requestMatch[1]) }); + } + + const documentMatch = rawActionUrl.match(/^\/documents\/(\d+)\/?(?:[#?].*)?$/); + if (documentMatch) { + return buildUrl("/documents", { document: Number(documentMatch[1]) }); + } + + const calendarMatch = rawActionUrl.match(/^\/calendar\/events\/(\d+)\/?(?:[#?].*)?$/); + if (calendarMatch) { + return buildUrl("/calendar", { event: Number(calendarMatch[1]) }); + } + + const feedPathMatch = rawActionUrl.match(/^\/feed\/(?:posts?\/)?(\d+)\/?(?:[#?].*)?$/); + if (feedPathMatch) { + return buildUrl("/", { post: Number(feedPathMatch[1]) }); + } + + const feedHashMatch = rawActionUrl.match(/^\/feed\/?#(\d+)$/); + if (feedHashMatch) { + return buildUrl("/", { post: Number(feedHashMatch[1]) }); + } + + return rawActionUrl || null; } diff --git a/frontend/src/lib/shared/formatters.ts b/frontend/src/lib/shared/formatters.ts index ea8596cf..b0abecbe 100644 --- a/frontend/src/lib/shared/formatters.ts +++ b/frontend/src/lib/shared/formatters.ts @@ -13,6 +13,19 @@ export function formatDate(value?: string | null): string { }); } +export function formatDateTime(value?: string | null): string { + if (!value) return ""; + const date = new Date(value); + if (Number.isNaN(date.getTime())) return ""; + return date.toLocaleString("ru-RU", { + day: "2-digit", + month: "2-digit", + year: "numeric", + hour: "2-digit", + minute: "2-digit", + }); +} + export function formatMoney(value?: string | number | null): string { if (value === null || value === undefined || value === "") return "—"; const amount = Number(value); diff --git a/frontend/src/lib/shared/index.ts b/frontend/src/lib/shared/index.ts index 49093a9d..51f6c0a0 100644 --- a/frontend/src/lib/shared/index.ts +++ b/frontend/src/lib/shared/index.ts @@ -1,3 +1,3 @@ -export { formatDate, formatMoney } from "./formatters"; +export { formatDate, formatDateTime, formatMoney } from "./formatters"; export { displayUserName, userProfileLink } from "./userUtils"; export { extractNextPage, loadAllPages } from "./paginationUtils";