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;