Merge branch 'develop'
This commit is contained in:
commit
7580a5285a
@ -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. Уведомляем автора объекта
|
||||
|
||||
@ -84,7 +84,7 @@ class ActionURLs:
|
||||
|
||||
TODO: реализовать прямые ссылки на конкретные документы
|
||||
"""
|
||||
return f'/documents?id={document_id}'
|
||||
return f'/documents?document={document_id}'
|
||||
|
||||
|
||||
# ===== Константы =====
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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}'
|
||||
|
||||
@ -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<string | null>(null);
|
||||
const [qrError, setQrError] = useState<string | null>(null);
|
||||
const [qrCopySuccess, setQrCopySuccess] = useState(false);
|
||||
const [categoryMode, setCategoryMode] = useState<"select" | "create">("select");
|
||||
const [newCategoryName, setNewCategoryName] = useState("");
|
||||
const handledLinkedEquipmentRef = useRef<number | null>(null);
|
||||
const equipmentMenuRef = useRef<HTMLDivElement | null>(null);
|
||||
const [equipmentMenuOpenId, setEquipmentMenuOpenId] = useState<number | null>(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() {
|
||||
<p className="app-text-muted text-sm font-semibold uppercase tracking-wide">Оборудование</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { void openCreateModal(); }}
|
||||
onClick={handleOpenCreateEquipmentModal}
|
||||
className="app-action-primary inline-flex items-center gap-1 rounded-lg px-3 py-2 text-sm font-medium"
|
||||
>
|
||||
<Plus size={14} /> Добавить оборудование
|
||||
@ -651,7 +680,7 @@ function EquipmentPageContent() {
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setEquipmentMenuOpenId(null);
|
||||
openEdit(item);
|
||||
handleOpenEditEquipmentModal(item);
|
||||
}}
|
||||
className="flex w-full items-center gap-2 px-3 py-2 text-left text-sm text-[var(--foreground)] transition hover:bg-[var(--surface-secondary)]"
|
||||
>
|
||||
@ -855,15 +884,16 @@ function EquipmentPageContent() {
|
||||
)}
|
||||
|
||||
{/* ===== Modal create/edit ===== */}
|
||||
<Modal isOpen={isModalOpen} onClose={closeModal} title={modalMode === "create" ? "Добавить оборудование" : "Редактировать оборудование"} size="md" footer={
|
||||
<Modal isOpen={isModalOpen} onClose={handleCloseEquipmentModal} title={modalMode === "create" ? "Добавить оборудование" : "Редактировать оборудование"} size="md" footer={
|
||||
<div className="flex flex-wrap items-center justify-end gap-2">
|
||||
<button type="button" onClick={closeModal} className="app-action-secondary rounded-lg px-3 py-2 text-sm font-medium">Отмена</button>
|
||||
<button type="button" onClick={handleCloseEquipmentModal} className="app-action-secondary rounded-lg px-3 py-2 text-sm font-medium">Отмена</button>
|
||||
<button type="button" onClick={() => handleSave(modalMode)} disabled={busyKey !== null} className="app-action-primary rounded-lg px-3 py-2 text-sm font-medium disabled:opacity-60">
|
||||
{modalMode === "create" ? "Добавить" : "Сохранить"}
|
||||
</button>
|
||||
</div>
|
||||
}>
|
||||
{actionError && <p className="mb-3 rounded-lg bg-red-50 px-3 py-2 text-sm text-red-700">{actionError}</p>}
|
||||
{actionSuccess && <p className="app-feedback-success mb-3 rounded-lg px-3 py-2 text-sm">{actionSuccess}</p>}
|
||||
|
||||
<div className="flex flex-col gap-3">
|
||||
{/* Название */}
|
||||
@ -877,14 +907,97 @@ function EquipmentPageContent() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Категория */}
|
||||
<SearchableSelectSingle
|
||||
label="Категория *"
|
||||
placeholder="Выберите категорию..."
|
||||
items={categories.map((c) => ({ id: c.id, name: c.name }))}
|
||||
selectedId={form.category}
|
||||
onSelect={(id) => setForm((p) => ({ ...p, category: id }))}
|
||||
/>
|
||||
<div className="app-surface-muted rounded-2xl p-4">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-semibold text-[var(--foreground)]">Категория *</p>
|
||||
<p className="app-text-muted mt-1 text-sm">
|
||||
Выберите существующую категорию или создайте новую, не выходя из формы.
|
||||
</p>
|
||||
</div>
|
||||
{canManageCategories && (
|
||||
<div className="inline-flex rounded-xl bg-[var(--surface-elevated)] p-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCategoryMode("select")}
|
||||
className={`rounded-lg px-3 py-1.5 text-xs font-medium transition ${
|
||||
categoryMode === "select"
|
||||
? "app-selected app-accent-text"
|
||||
: "app-text-muted hover:bg-[var(--surface-secondary)]"
|
||||
}`}
|
||||
>
|
||||
Выбрать
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCategoryMode("create")}
|
||||
className={`rounded-lg px-3 py-1.5 text-xs font-medium transition ${
|
||||
categoryMode === "create"
|
||||
? "app-selected app-accent-text"
|
||||
: "app-text-muted hover:bg-[var(--surface-secondary)]"
|
||||
}`}
|
||||
>
|
||||
Создать
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-4 space-y-3">
|
||||
<SearchableSelectSingle
|
||||
label="Справочник категорий"
|
||||
placeholder="Выберите категорию..."
|
||||
items={categories.map((c) => ({ id: c.id, name: c.name }))}
|
||||
selectedId={form.category}
|
||||
onSelect={(id) => setForm((p) => ({ ...p, category: id }))}
|
||||
disabled={busyKey === "create-category"}
|
||||
/>
|
||||
|
||||
{form.category ? (
|
||||
<div className="app-surface rounded-xl px-3 py-2.5">
|
||||
<p className="app-text-muted text-[11px] font-medium uppercase tracking-wide">Текущий выбор</p>
|
||||
<p className="mt-1 text-sm font-medium text-[var(--foreground)]">
|
||||
{categories.find((category) => category.id === form.category)?.name || `Категория #${form.category}`}
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{canManageCategories && categoryMode === "create" && (
|
||||
<div className="app-surface rounded-xl p-3">
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<label className="app-text-muted mb-2 block text-sm">Новая категория</label>
|
||||
<input
|
||||
value={newCategoryName}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<p className="app-text-muted text-xs">
|
||||
После создания категория сразу появится в списке и будет выбрана в форме.
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void submitNewCategory()}
|
||||
disabled={busyKey === "create-category" || !newCategoryName.trim()}
|
||||
className="app-action-primary shrink-0 rounded-lg px-3 py-2 text-sm font-medium disabled:opacity-60"
|
||||
>
|
||||
{busyKey === "create-category" ? "Создаём..." : "Создать"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isCreateMode && previewInventoryNumber && (
|
||||
<div className="app-surface-muted rounded-lg px-3 py-2 text-sm text-[var(--muted-foreground)]">
|
||||
|
||||
@ -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() {
|
||||
<div className="space-y-5 text-sm text-[var(--foreground)]">
|
||||
<div className="app-surface-muted rounded-xl p-4"><div className="space-y-3"><div className="flex flex-wrap items-center gap-2"><span className="app-badge app-badge-accent px-2.5 py-1 text-xs font-medium">{dtl}</span><span className={`inline-flex rounded-full px-2.5 py-1 text-xs ring-1 ${ds.className}`}>{ds.label}</span></div><h2 className="app-text-wrap text-lg font-semibold text-[var(--foreground)]">{dr.display_title || dr.title || "Без заголовка"}</h2>{(dr.comment || dr.description) ? <div className="app-text-wrap mt-3 whitespace-pre-wrap text-sm leading-relaxed text-[var(--foreground)]">{dr.comment || dr.description}</div> : <p className="app-text-muted mt-3 text-sm">Описание отсутствует</p>}</div></div>
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<div className="app-surface rounded-xl p-4"><p className="app-text-muted text-xs font-semibold uppercase tracking-wide">Даты</p><div className="mt-3 space-y-2"><div><p className="app-text-muted text-xs">Период</p><p className="mt-1 text-sm text-[var(--foreground)]">{dr.date_from ? formatDate(dr.date_from) : "—"}{dr.date_to ? ` — ${formatDate(dr.date_to)}` : ""}</p></div><div><p className="app-text-muted text-xs">Создано</p><p className="mt-1 text-sm text-[var(--foreground)]">{formatDate(dr.created_at) || "—"}</p></div><div><p className="app-text-muted text-xs">Обновлено</p><p className="mt-1 text-sm text-[var(--foreground)]">{formatDate(dr.updated_at) || "—"}</p></div></div></div>
|
||||
<div className="app-surface rounded-xl p-4"><p className="app-text-muted text-xs font-semibold uppercase tracking-wide">Даты</p><div className="mt-3 space-y-2"><div><p className="app-text-muted text-xs">Период</p><p className="mt-1 text-sm text-[var(--foreground)]">{dr.date_from ? formatDate(dr.date_from) : "—"}{dr.date_to ? ` — ${formatDate(dr.date_to)}` : ""}</p></div><div><p className="app-text-muted text-xs">Создано</p><p className="mt-1 text-sm text-[var(--foreground)]">{formatDateTime(dr.created_at) || "—"}</p></div><div><p className="app-text-muted text-xs">Обновлено</p><p className="mt-1 text-sm text-[var(--foreground)]">{formatDateTime(dr.updated_at) || "—"}</p></div></div></div>
|
||||
<div className="app-surface rounded-xl p-4"><p className="app-text-muted text-xs font-semibold uppercase tracking-wide">Участники</p><div className="mt-3 space-y-3"><div><p className="app-text-muted text-xs">Автор</p><div className="mt-2 flex flex-wrap gap-2">{da ? renderUserBadge(da as User, true) : <span className="app-text-muted text-sm">—</span>}</div></div><div><p className="app-text-muted text-xs">Решающий</p><div className="mt-2 flex flex-wrap gap-2">{dap ? renderUserBadge(dap as User, true) : <span className="app-text-muted text-sm">—</span>}</div></div></div></div>
|
||||
</div>
|
||||
<div className="app-surface rounded-xl p-4"><p className="app-text-muted text-xs font-semibold uppercase tracking-wide">Отделы</p>{(dr.departments || []).length > 0 ? <div className="mt-3 flex flex-wrap gap-2">{(dr.departments || []).map((id) => <span key={id} className="app-badge px-3 py-1.5 text-sm font-medium">{h.departmentNameMap.get(Number(id)) || `Отдел #${id}`}</span>)}</div> : <p className="app-text-muted mt-3 text-sm">Отделы не указаны</p>}</div>
|
||||
|
||||
@ -17,6 +17,7 @@ export type NotificationItem = {
|
||||
timestamp?: string;
|
||||
created_at?: string;
|
||||
action_url?: string;
|
||||
data?: Record<string, unknown> | null;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
|
||||
@ -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<Equipment[]>([]);
|
||||
@ -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,
|
||||
|
||||
@ -4,6 +4,8 @@ import { buildQuery, type RequestFn, type GetTokenFn } from './utils';
|
||||
export function createEquipmentApi(request: RequestFn, getToken: GetTokenFn) {
|
||||
return {
|
||||
getEquipmentCategories: (params?: Record<string, string | number>) => 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<string, string | number>) => request(`/api/v1/procurement/equipment/${buildQuery(params)}`),
|
||||
getEquipmentDetail: (id: number) => request(`/api/v1/procurement/equipment/${id}/`),
|
||||
getMyEquipment: (params?: Record<string, string | number>) => request(`/api/v1/procurement/equipment/my_equipment/${buildQuery(params)}`),
|
||||
|
||||
@ -5,20 +5,177 @@ type NotificationNavigationLike = {
|
||||
data?: Record<string, unknown> | 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, number | null>): 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;
|
||||
}
|
||||
|
||||
@ -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;
|
||||
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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";
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user