From 993e26e8edc48066f81ac48661f1281819f8801f Mon Sep 17 00:00:00 2001 From: lizerk Date: Mon, 13 Apr 2026 04:02:24 +0300 Subject: [PATCH 1/2] fix(notifications): normalize action links and request timestamps --- .../communications/notifications/handlers.py | 6 +- backend/documents/notifications/config.py | 2 +- backend/notifications/api/serializers.py | 1 + backend/notifications/api/views.py | 1 + backend/requests_app/notifications/config.py | 2 +- frontend/src/app/requests/page.tsx | 4 +- .../src/contexts/NotificationsContext.tsx | 1 + frontend/src/lib/notifications/actionUrl.ts | 177 +++++++++++++++++- frontend/src/lib/shared/formatters.ts | 13 ++ frontend/src/lib/shared/index.ts | 2 +- 10 files changed, 191 insertions(+), 18 deletions(-) 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"; From bac7c650626c79a29be68a9ff8cc336cdee0db02 Mon Sep 17 00:00:00 2001 From: lizerk Date: Mon, 13 Apr 2026 04:14:10 +0300 Subject: [PATCH 2/2] feat(equipment): create categories inline in modal --- frontend/src/app/equipment/page.tsx | 147 ++++++++++++++++++++++--- frontend/src/hooks/useEquipmentPage.ts | 43 +++++++- frontend/src/lib/api/equipment.ts | 2 + frontend/src/lib/permissions.ts | 31 ++++++ 4 files changed, 205 insertions(+), 18 deletions(-) diff --git a/frontend/src/app/equipment/page.tsx b/frontend/src/app/equipment/page.tsx index b2c95b7f..d24a0df5 100644 --- a/frontend/src/app/equipment/page.tsx +++ b/frontend/src/app/equipment/page.tsx @@ -196,6 +196,7 @@ function EquipmentPageContent() { activeFilterCount, busyKey, canManage, + canManageCategories, categories, categoryFilter, closeModal, @@ -222,6 +223,7 @@ function EquipmentPageContent() { getResponsibleLink, getResponsibleName, handleAddComment, + handleCreateCategory, handleDelete, handleDeleteComment, handleLoadMore, @@ -276,6 +278,8 @@ function EquipmentPageContent() { const [qrDataUrl, setQrDataUrl] = useState(null); const [qrError, setQrError] = useState(null); const [qrCopySuccess, setQrCopySuccess] = useState(false); + const [categoryMode, setCategoryMode] = useState<"select" | "create">("select"); + const [newCategoryName, setNewCategoryName] = useState(""); const handledLinkedEquipmentRef = useRef(null); const equipmentMenuRef = useRef(null); const [equipmentMenuOpenId, setEquipmentMenuOpenId] = useState(null); @@ -323,11 +327,7 @@ function EquipmentPageContent() { }, [linkedEquipmentId, linkedEquipmentParam, openEquipmentById]); useEffect(() => { - if (!qrEquipment) { - setQrDataUrl(null); - setQrError(null); - return; - } + if (!qrEquipment) return; let cancelled = false; @@ -383,6 +383,8 @@ function EquipmentPageContent() { }, [equipmentMenuOpenId]); const handleOpenQrModal = useCallback((equipment: typeof filteredItems[number]) => { + setQrDataUrl(null); + setQrError(null); setQrCopySuccess(false); setQrEquipment(equipment); }, []); @@ -428,6 +430,33 @@ function EquipmentPageContent() { router.replace(nextQuery ? `${pathname}?${nextQuery}` : pathname, { scroll: false }); }, [pathname, router, searchParams]); + const resetCategoryCreateState = useCallback(() => { + setCategoryMode("select"); + setNewCategoryName(""); + }, []); + + const handleOpenCreateEquipmentModal = useCallback(() => { + resetCategoryCreateState(); + void openCreateModal(); + }, [openCreateModal, resetCategoryCreateState]); + + const handleOpenEditEquipmentModal = useCallback((equipment: Equipment) => { + resetCategoryCreateState(); + openEdit(equipment); + }, [openEdit, resetCategoryCreateState]); + + const handleCloseEquipmentModal = useCallback(() => { + resetCategoryCreateState(); + closeModal(); + }, [closeModal, resetCategoryCreateState]); + + const submitNewCategory = useCallback(async () => { + const created = await handleCreateCategory(newCategoryName); + if (!created) return; + setCategoryMode("select"); + setNewCategoryName(""); + }, [handleCreateCategory, newCategoryName]); + const qrLink = useMemo( () => (qrEquipment ? buildEquipmentLink(qrEquipment.id) : ""), [buildEquipmentLink, qrEquipment], @@ -451,7 +480,7 @@ function EquipmentPageContent() {

Оборудование

+
}> {actionError &&

{actionError}

} + {actionSuccess &&

{actionSuccess}

}
{/* Название */} @@ -877,14 +907,97 @@ function EquipmentPageContent() { />
- {/* Категория */} - ({ id: c.id, name: c.name }))} - selectedId={form.category} - onSelect={(id) => setForm((p) => ({ ...p, category: id }))} - /> +
+
+
+

Категория *

+

+ Выберите существующую категорию или создайте новую, не выходя из формы. +

+
+ {canManageCategories && ( +
+ + +
+ )} +
+ +
+ ({ id: c.id, name: c.name }))} + selectedId={form.category} + onSelect={(id) => setForm((p) => ({ ...p, category: id }))} + disabled={busyKey === "create-category"} + /> + + {form.category ? ( +
+

Текущий выбор

+

+ {categories.find((category) => category.id === form.category)?.name || `Категория #${form.category}`} +

+
+ ) : null} + + {canManageCategories && categoryMode === "create" && ( +
+
+
+ + setNewCategoryName(e.target.value)} + onKeyDown={(event) => { + if (event.key === "Enter") { + event.preventDefault(); + void submitNewCategory(); + } + }} + placeholder="Например, Моноблоки" + className="app-input w-full rounded-lg px-3 py-2.5 text-sm" + /> +
+
+

+ После создания категория сразу появится в списке и будет выбрана в форме. +

+ +
+
+
+ )} +
+
{isCreateMode && previewInventoryNumber && (
diff --git a/frontend/src/hooks/useEquipmentPage.ts b/frontend/src/hooks/useEquipmentPage.ts index c9bc8e0f..0926a78e 100644 --- a/frontend/src/hooks/useEquipmentPage.ts +++ b/frontend/src/hooks/useEquipmentPage.ts @@ -2,7 +2,7 @@ import { useCallback, useDeferredValue, useEffect, useMemo, useState } from "react"; import { apiClient } from "@/lib/api"; -import { canManageEquipment } from "@/lib/permissions"; +import { canManageEquipment, canManageEquipmentCategories } from "@/lib/permissions"; import { displayUserName as sharedDisplayUserName, extractNextPage, formatDate, formatMoney, loadAllPages } from "@/lib/shared"; import type { Department, @@ -108,6 +108,7 @@ const getReadableError = (error: unknown, fallback: string): string => { export function useEquipmentPage(user: User | null) { const canManage = canManageEquipment(user); + const canManageCategories = canManageEquipmentCategories(user); const auth = user?.auth; const [items, setItems] = useState([]); @@ -610,6 +611,44 @@ export function useEquipmentPage(user: User | null) { } }, [editingId, form, reloadEquipmentList, resetForm]); + const handleCreateCategory = useCallback(async (name: string) => { + const normalizedName = name.trim(); + if (!normalizedName) { + setActionError("Укажите название категории."); + return null; + } + + const existing = categories.find( + (category) => category.name.trim().toLowerCase() === normalizedName.toLowerCase(), + ); + if (existing) { + setForm((previous) => ({ ...previous, category: existing.id })); + setActionSuccess(`Категория "${existing.name}" уже существует и выбрана.`); + setActionError(null); + return existing; + } + + try { + setBusyKey("create-category"); + setActionError(null); + setActionSuccess(null); + + const created = await apiClient.createEquipmentCategory({ name: normalizedName }) as EquipmentCategory; + + setCategories((previous) => ( + [...previous, created].sort((left, right) => left.name.localeCompare(right.name, "ru")) + )); + setForm((previous) => ({ ...previous, category: created.id })); + setActionSuccess(`Категория "${created.name}" создана.`); + return created; + } catch (categoryError) { + setActionError(getReadableError(categoryError, "Не удалось создать категорию")); + return null; + } finally { + setBusyKey(null); + } + }, [categories]); + const handleLoadMore = useCallback(async () => { if (!nextPage || loadingMore) return; @@ -801,6 +840,7 @@ export function useEquipmentPage(user: User | null) { activeFilterCount, busyKey, canManage, + canManageCategories, categories, categoryFilter, closeModal, @@ -828,6 +868,7 @@ export function useEquipmentPage(user: User | null) { getResponsibleLink, getResponsibleName, handleAddComment, + handleCreateCategory, handleDelete, handleDeleteComment, handleLoadMore, diff --git a/frontend/src/lib/api/equipment.ts b/frontend/src/lib/api/equipment.ts index 0baa6981..d1b81063 100644 --- a/frontend/src/lib/api/equipment.ts +++ b/frontend/src/lib/api/equipment.ts @@ -4,6 +4,8 @@ import { buildQuery, type RequestFn, type GetTokenFn } from './utils'; export function createEquipmentApi(request: RequestFn, getToken: GetTokenFn) { return { getEquipmentCategories: (params?: Record) => request(`/api/v1/procurement/equipment-categories/${buildQuery(params)}`), + createEquipmentCategory: (data: { name: string; parent?: number | null; description?: string; icon?: string }) => + request('/api/v1/procurement/equipment-categories/', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data) }), getEquipment: (params?: Record) => request(`/api/v1/procurement/equipment/${buildQuery(params)}`), getEquipmentDetail: (id: number) => request(`/api/v1/procurement/equipment/${id}/`), getMyEquipment: (params?: Record) => request(`/api/v1/procurement/equipment/my_equipment/${buildQuery(params)}`), diff --git a/frontend/src/lib/permissions.ts b/frontend/src/lib/permissions.ts index 482da9d2..fed452dd 100644 --- a/frontend/src/lib/permissions.ts +++ b/frontend/src/lib/permissions.ts @@ -130,6 +130,37 @@ export function canManageEquipment(user?: User | null): boolean { return false; } +export function canManageEquipmentCategories(user?: User | null): boolean { + if (!user) return false; + + const auth = user.auth; + if (!auth) return false; + + if (auth.is_staff || auth.is_superuser) { + return true; + } + + const perms = auth.permissions || []; + if ( + perms.includes("procurement.add_equipmentcategory") || + perms.includes("procurement.change_equipmentcategory") || + perms.includes("procurement.delete_equipmentcategory") + ) { + return true; + } + + const procPerms = auth.permissions_by_app?.["procurement"] || []; + if ( + procPerms.includes("add_equipmentcategory") || + procPerms.includes("change_equipmentcategory") || + procPerms.includes("delete_equipmentcategory") + ) { + return true; + } + + return false; +} + export function canManageSupplier(user?: User | null): boolean { if (!user) return false;