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";