diff --git a/frontend/src/app/equipment/page.tsx b/frontend/src/app/equipment/page.tsx index a265fab2..3753c32a 100644 --- a/frontend/src/app/equipment/page.tsx +++ b/frontend/src/app/equipment/page.tsx @@ -1,74 +1,18 @@ "use client"; import { AppShell } from "../../components/AppShell"; -import { apiClient } from "@/lib/api"; import { useUser } from "@/contexts/UserContext"; -import { canManageEquipment } from "@/lib/permissions"; -import { useDeferredValue, useEffect, useMemo, useRef, useState } from "react"; import Link from "next/link"; -import type { - Department, - Equipment, - EquipmentCategory, - EquipmentComment, - EquipmentCreateOptions, - EquipmentTransferHistoryEntry, - MaintenanceRecord, - User, -} from "@/types/api"; import { Archive, ArrowRightLeft, ArrowUpDown, ChevronDown, Filter, MessageSquare, Monitor, Pencil, Plus, QrCode, Search, Shield, Trash2, Wrench, X } from "lucide-react"; import { SearchableSelectSingle } from "@/components/shared/SearchableSelect"; -import { formatDate as sharedFormatDate, formatMoney as sharedFormatMoney, displayUserName as sharedDisplayUserName, extractNextPage as sharedExtractNextPage, loadAllPages } from "@/lib/shared"; +import { formatDate, formatMoney } from "@/lib/shared"; +import { useEquipmentPage } from "@/hooks/useEquipmentPage"; -/* ──── form state ──── */ -type EquipmentFormState = { - name: string; - notes: string; - category: number | null; - department: number | null; - responsible_person: number | null; - quantity: number; - serial_number: string; - purchase_date: string; - purchase_cost: string; - location: string; -}; - -const emptyForm: EquipmentFormState = { - name: "", - notes: "", - category: null, - department: null, - responsible_person: null, - quantity: 1, - serial_number: "", - purchase_date: "", - purchase_cost: "", - location: "", -}; - -type EquipmentListMode = "all" | "mine" | "warranty"; - -type OperationModal = "transfer" | "writeoff" | "maintenance" | null; - -type TransferFormState = { - to_department: number | null; - to_person: number | null; - reason: string; -}; - -type MaintenanceFormState = { - type: string; - description: string; - cost: string; - date: string; -}; - -const listModeMeta: Array<{ value: EquipmentListMode; label: string }> = [ +const listModeMeta = [ { value: "all", label: "Весь реестр" }, { value: "mine", label: "Мое оборудование" }, { value: "warranty", label: "Истекает гарантия" }, -]; +] as const; const orderingOptions = [ { value: "-created_at", label: "Сначала новые" }, @@ -119,622 +63,91 @@ const defaultStatusMeta = { surfaceClass: "border-gray-200", }; -const formatDate = sharedFormatDate; -const formatMoney = sharedFormatMoney; - /* ──── main page ──── */ export default function EquipmentPage() { const { user } = useUser(); - const [items, setItems] = useState([]); - const [detailsMap, setDetailsMap] = useState>({}); - const [transferHistoryMap, setTransferHistoryMap] = useState>({}); - const [maintenanceMap, setMaintenanceMap] = useState>({}); - const [employees, setEmployees] = useState([]); - const [departments, setDepartments] = useState([]); - const [categories, setCategories] = useState([]); - const [createOptions, setCreateOptions] = useState(null); - const [previewInventoryNumber, setPreviewInventoryNumber] = useState(""); - const [commentsMap, setCommentsMap] = useState>({}); - const [expandedRows, setExpandedRows] = useState>({}); - const [expandedComments, setExpandedComments] = useState>({}); - const [commentDrafts, setCommentDrafts] = useState>({}); - const [loadingRowDetails, setLoadingRowDetails] = useState>({}); - - const [createOpen, setCreateOpen] = useState(false); - const [editingId, setEditingId] = useState(null); - const [form, setForm] = useState(emptyForm); - const [listMode, setListMode] = useState("all"); - const [ordering, setOrdering] = useState("-created_at"); - const [operationModal, setOperationModal] = useState(null); - const [selectedEquipment, setSelectedEquipment] = useState(null); - const [writeOffReason, setWriteOffReason] = useState(""); - const [transferForm, setTransferForm] = useState({ to_department: null, to_person: null, reason: "" }); - const [maintenanceForm, setMaintenanceForm] = useState({ - type: "repair", - description: "", - cost: "", - date: new Date().toISOString().slice(0, 10), - }); - - const [searchQuery, setSearch] = useState(""); - const deferredSearchQuery = useDeferredValue(searchQuery); - const [statusFilter, setStatusFilter] = useState(""); - const [categoryFilter, setCategoryFilter] = useState(""); - const [departmentFilter, setDepartmentFilter] = useState(""); - const [responsibleFilter, setResponsibleFilter] = useState(""); - const [dateFromFilter, setDateFromFilter] = useState(""); - const [dateToFilter, setDateToFilter] = useState(""); - const [filtersOpen, setFiltersOpen] = useState(false); - - const [loading, setLoading] = useState(true); - const [loadingMore, setLoadingMore] = useState(false); - const [error, setError] = useState(null); - const [actionError, setActionError] = useState(null); - const [actionSuccess, setActionSuccess] = useState(null); - const [busyKey, setBusyKey] = useState(null); - const [nextPage, setNextPage] = useState(null); - - const auth = user?.auth; - const canManage = canManageEquipment(user); - const isCreateMode = createOpen && editingId === null; - - /* ──── helpers ──── */ - const displayUserName = (person?: User | null) => sharedDisplayUserName(person); - - const extractNextPage = sharedExtractNextPage; - - const buildParams = (page: number): Record => { - const p: Record = { page }; - if (deferredSearchQuery.trim()) p.search = deferredSearchQuery.trim(); - if (ordering) p.ordering = ordering; - if (statusFilter) p.status = statusFilter; - if (categoryFilter) p.category = categoryFilter; - if (departmentFilter) p.department = departmentFilter; - if (responsibleFilter) p.responsible_person = responsibleFilter; - if (dateFromFilter) p.purchase_date__gte = dateFromFilter; - if (dateToFilter) p.purchase_date__lte = dateToFilter; - return p; - }; - - const getCategoryName = (eq: Equipment): string => { - if (eq.category_name) return eq.category_name; - const cat = categories.find((c) => c.id === Number(eq.category)); - return cat?.name || "—"; - }; - - const getDepartmentName = (eq: Equipment): string => { - if (eq.department_name) return eq.department_name; - const dep = departments.find((d) => d.id === Number(eq.department)); - return dep?.name || "—"; - }; - - const getResponsibleName = (eq: Equipment): string => { - if (eq.responsible_name) return eq.responsible_name; - if (eq.responsible_person && typeof eq.responsible_person === "number") { - const emp = employees.find((e) => e.id === eq.responsible_person); - return emp ? displayUserName(emp) : "—"; - } - return "—"; - }; - - const getResponsibleLink = (eq: Equipment): string => { - if (!eq.responsible_person || typeof eq.responsible_person !== "number") return ""; - if (eq.responsible_person === user?.id) return "/profile"; - return `/users/${eq.responsible_person}`; - }; - - const getEquipmentMeta = (eq: Equipment) => { - return [ - { label: "Категория", value: getCategoryName(eq) }, - { label: "Отдел", value: getDepartmentName(eq) }, - { label: "Ответственный", value: getResponsibleName(eq) }, - { label: "Серийный номер", value: eq.serial_number || "—" }, - { label: "Расположение", value: eq.location || "—" }, - { label: "Гарантия до", value: formatDate(eq.warranty_until) || "—" }, - { label: "Обслуживаний", value: String(eq.maintenance_count ?? "—") }, - { label: "Стоимость", value: formatMoney(eq.purchase_cost) }, - { label: "Добавлено", value: formatDate(eq.created_at) || "—" }, - ]; - }; - - const filteredDepartmentsForForm = useMemo(() => { - if (!createOptions || !isCreateMode || createOptions.allowed_departments.length === 0) { - return departments; - } - const allowedIds = new Set(createOptions.allowed_departments.map((dept) => dept.id)); - return departments.filter((dept) => allowedIds.has(dept.id)); - }, [createOptions, departments, isCreateMode]); - - const filteredEmployeesForForm = useMemo(() => { - if (!isCreateMode || !createOptions || createOptions.can_choose_responsible) { - return employees; - } - if (!createOptions.default_responsible) { - return []; - } - return employees.filter((employee) => employee.id === createOptions.default_responsible?.id); - }, [createOptions, employees, isCreateMode]); - - const applyCreateDefaults = (options: EquipmentCreateOptions | null) => { - if (!options) return; - - setForm((prev) => { - const nextDepartment = options.can_choose_department - ? prev.department - : options.allowed_departments[0]?.id ?? prev.department; - const nextResponsible = options.can_choose_responsible - ? prev.responsible_person - : options.default_responsible?.id ?? prev.responsible_person; - - return { - ...prev, - department: nextDepartment, - responsible_person: nextResponsible, - }; - }); - }; - - const loadRowDetails = async (equipmentId: number) => { - if (loadingRowDetails[equipmentId]) return; - - try { - setLoadingRowDetails((prev) => ({ ...prev, [equipmentId]: true })); - const results = await Promise.allSettled([ - detailsMap[equipmentId] ? Promise.resolve(detailsMap[equipmentId]) : apiClient.getEquipmentDetail(equipmentId), - transferHistoryMap[equipmentId] ? Promise.resolve(transferHistoryMap[equipmentId]) : apiClient.getEquipmentTransferHistory(equipmentId), - maintenanceMap[equipmentId] ? Promise.resolve(maintenanceMap[equipmentId]) : apiClient.getMaintenanceRecords({ equipment: equipmentId }), - ]); - - const [detailResult, transferResult, maintenanceResult] = results; - - if (detailResult.status === "fulfilled") { - setDetailsMap((prev) => ({ ...prev, [equipmentId]: detailResult.value })); - } - - if (transferResult.status === "fulfilled") { - setTransferHistoryMap((prev) => ({ ...prev, [equipmentId]: Array.isArray(transferResult.value) ? transferResult.value : transferResult.value.results || [] })); - } - - if (maintenanceResult.status === "fulfilled") { - setMaintenanceMap((prev) => ({ - ...prev, - [equipmentId]: Array.isArray(maintenanceResult.value) ? maintenanceResult.value : maintenanceResult.value.results || [], - })); - } - } catch (error) { - console.error("Ошибка загрузки деталей оборудования:", error); - } finally { - setLoadingRowDetails((prev) => ({ ...prev, [equipmentId]: false })); - } - }; - - const openCreateModal = async () => { - setEditingId(null); - resetForm(); - setActionError(null); - setActionSuccess(null); - setCreateOpen(true); - - try { - const [options, inventoryPreview] = await Promise.all([ - apiClient.getEquipmentCreateOptions(), - apiClient.generateEquipmentInventoryNumber(), - ]); - setCreateOptions(options); - setPreviewInventoryNumber(inventoryPreview.inventory_number || ""); - applyCreateDefaults(options); - } catch (error) { - console.error("Ошибка загрузки опций создания оборудования:", error); - } - }; - - const openOperationModal = (type: Exclude, equipment: Equipment) => { - setSelectedEquipment(equipment); - setOperationModal(type); - setActionError(null); - - if (type === "transfer") { - setTransferForm({ - to_department: equipment.department ?? null, - to_person: typeof equipment.responsible_person === "number" ? equipment.responsible_person : null, - reason: "", - }); - } - - if (type === "writeoff") { - setWriteOffReason(""); - } - - if (type === "maintenance") { - setMaintenanceForm({ - type: "repair", - description: "", - cost: "", - date: new Date().toISOString().slice(0, 10), - }); - } - }; - - const closeOperationModal = () => { - setOperationModal(null); - setSelectedEquipment(null); - setWriteOffReason(""); - setTransferForm({ to_department: null, to_person: null, reason: "" }); - setMaintenanceForm({ type: "repair", description: "", cost: "", date: new Date().toISOString().slice(0, 10) }); - }; - - /* ──── load data ──── */ - const fetchEquipmentPage = async (page: number) => { - if (listMode === "mine") { - return apiClient.getMyEquipment({ page }); - } - - if (listMode === "warranty") { - if (page > 1) { - return { results: [], next: null }; - } - return apiClient.getWarrantyExpiringEquipment(); - } - - return apiClient.getEquipment(buildParams(page)); - }; - - const reloadEquipmentList = async () => { - const res = await fetchEquipmentPage(1); - const results = Array.isArray(res) ? res : (res.results || []); - setItems(results); - setNextPage(extractNextPage((res as { next?: string | null }).next)); - }; - - useEffect(() => { - (async () => { - try { - setLoading(true); - setError(null); - const res = await fetchEquipmentPage(1); - const results = Array.isArray(res) ? res : (res.results || []); - setItems(results); - setNextPage(extractNextPage(res.next)); - } catch (e: any) { - console.error("Ошибка загрузки оборудования:", e); - setError("Не удалось загрузить оборудование"); - } finally { - setLoading(false); - } - })(); - }, [ - listMode, - deferredSearchQuery, - ordering, - statusFilter, - categoryFilter, - departmentFilter, - responsibleFilter, - dateFromFilter, - dateToFilter, - ]); - - useEffect(() => { - (async () => { - try { - const [allEmployees, allDepartments, allCategories] = await Promise.all([ - loadAllPages((p) => apiClient.getEmployees(p)), - loadAllPages((p) => apiClient.getDepartments(p)), - loadAllPages((p) => apiClient.getEquipmentCategories(p)), - ]); - setEmployees(allEmployees); - setDepartments(allDepartments); - setCategories(allCategories); - } catch (e) { - console.error("Ошибка загрузки справочников:", e); - } - })(); - }, []); - - useEffect(() => { - (async () => { - try { - const options = await apiClient.getEquipmentCreateOptions(); - setCreateOptions(options); - } catch (error) { - console.error("Ошибка загрузки create options:", error); - } - })(); - }, []); - - /* ──── filtered ──── */ - const sortItemsLocally = (source: Equipment[]) => { - const sorted = [...source]; - - sorted.sort((left, right) => { - switch (ordering) { - case "name": - return (left.name || "").localeCompare(right.name || "", "ru"); - case "purchase_date": - return new Date(left.purchase_date || 0).getTime() - new Date(right.purchase_date || 0).getTime(); - case "-purchase_date": - return new Date(right.purchase_date || 0).getTime() - new Date(left.purchase_date || 0).getTime(); - case "created_at": - return new Date(left.created_at || 0).getTime() - new Date(right.created_at || 0).getTime(); - case "-created_at": - default: - return new Date(right.created_at || 0).getTime() - new Date(left.created_at || 0).getTime(); - } - }); - - return sorted; - }; - - const filteredItems = useMemo(() => { - const q = searchQuery.trim().toLowerCase(); - let scopedItems = [...items]; - - if (listMode !== "all") { - scopedItems = scopedItems.filter((item) => { - const itemDate = item.purchase_date || ""; - if (statusFilter && item.status !== statusFilter) return false; - if (categoryFilter && Number(item.category) !== Number(categoryFilter)) return false; - if (departmentFilter && Number(item.department) !== Number(departmentFilter)) return false; - if (responsibleFilter && Number(item.responsible_person) !== Number(responsibleFilter)) return false; - if (dateFromFilter && itemDate && itemDate < dateFromFilter) return false; - if (dateToFilter && itemDate && itemDate > dateToFilter) return false; - return true; - }); - } - - const sorted = listMode === "all" ? items : sortItemsLocally(scopedItems); - if (!q) return sorted; - return sorted.filter((item) => { - const name = (item.name || "").toLowerCase(); - const notes = (item.notes || "").toLowerCase(); - const sn = (item.serial_number || "").toLowerCase(); - const inv = (item.inventory_number || "").toLowerCase(); - const responsible = getResponsibleName(item).toLowerCase(); - const cat = getCategoryName(item).toLowerCase(); - return name.includes(q) || notes.includes(q) || sn.includes(q) || inv.includes(q) || responsible.includes(q) || cat.includes(q); - }); - }, [ - items, - listMode, - ordering, - searchQuery, - statusFilter, - categoryFilter, - departmentFilter, - responsibleFilter, - dateFromFilter, - dateToFilter, - employees, + const { + auth, + actionError, + actionSuccess, + activeFilterCount, + busyKey, + canManage, categories, + categoryFilter, + closeModal, + closeOperationModal, + commentDrafts, + commentsMap, + createOptions, + dateFromFilter, + dateToFilter, + departmentFilter, departments, - ]); - - /* ──── form ──── */ - const resetForm = () => setForm(emptyForm); - - const openEdit = (eq: Equipment) => { - setEditingId(eq.id); - setCreateOpen(false); - setActionError(null); - setActionSuccess(null); - setPreviewInventoryNumber(eq.inventory_number || ""); - setForm({ - name: eq.name || "", - notes: eq.notes || "", - category: eq.category ?? null, - department: eq.department ?? null, - responsible_person: typeof eq.responsible_person === "number" ? eq.responsible_person : null, - quantity: 1, - serial_number: eq.serial_number || "", - purchase_date: eq.purchase_date || "", - purchase_cost: String(eq.purchase_cost || ""), - location: eq.location || "", - }); - }; - - const handleSave = async (mode: "create" | "edit") => { - try { - setBusyKey(`${mode}-save`); - setActionError(null); - setActionSuccess(null); - - if (!form.name.trim()) { setActionError("Укажите название оборудования."); return; } - if (!form.category) { setActionError("Выберите категорию."); return; } - if (!form.department) { setActionError("Выберите отдел."); return; } - if (!form.purchase_date) { setActionError("Укажите дату покупки."); return; } - if (!form.purchase_cost) { setActionError("Укажите стоимость."); return; } - - const payload: Record = { - name: form.name, - notes: form.notes, - category: form.category, - department: form.department, - purchase_date: form.purchase_date, - purchase_cost: form.purchase_cost, - }; - - if (form.responsible_person) payload.responsible_person = form.responsible_person; - if (mode === "create" && form.quantity > 1) payload.quantity = form.quantity; - if (form.serial_number) payload.serial_number = form.serial_number; - if (form.location) payload.location = form.location; - - if (mode === "create") { - await apiClient.createEquipment(payload); - setActionSuccess("Оборудование добавлено."); - setCreateOpen(false); - } else if (editingId) { - await apiClient.updateEquipment(editingId, payload); - setActionSuccess("Оборудование обновлено."); - setEditingId(null); - } - - resetForm(); - await reloadEquipmentList(); - } catch (e: any) { - const raw = String(e?.message || "Не удалось сохранить"); - let readable = raw; - try { - const parsed = JSON.parse(raw); - readable = Object.entries(parsed).map(([key, val]) => `${key}: ${Array.isArray(val) ? val.join(", ") : val}`).join(". "); - } catch {} - setActionError(readable); - } finally { - setBusyKey(null); - } - }; - - const handleLoadMore = async () => { - if (!nextPage || loadingMore) return; - try { - setLoadingMore(true); - const res = await fetchEquipmentPage(nextPage); - const chunk = Array.isArray(res) ? res : (res.results || []); - setItems((prev) => { - const known = new Set(prev.map((r) => r.id)); - return [...prev, ...chunk.filter((r: Equipment) => !known.has(r.id))]; - }); - setNextPage(extractNextPage(res.next)); - } catch { - setError("Не удалось загрузить ещё"); - } finally { - setLoadingMore(false); - } - }; - - /* ──── actions ──── */ - const handleDelete = async (id: number) => { - if (!confirm("Удалить это оборудование?")) return; - try { setBusyKey(`delete-${id}`); await apiClient.deleteEquipment(id); setItems((p) => p.filter((r) => r.id !== id)); } catch { setActionError("Не удалось удалить"); } finally { setBusyKey(null); } }; - - const toggleRow = (equipmentId: number) => { - setExpandedRows((prev) => { - const nextOpen = !prev[equipmentId]; - if (nextOpen) { - void loadRowDetails(equipmentId); - } - return { ...prev, [equipmentId]: nextOpen }; - }); - }; - - const handleTransfer = async () => { - if (!selectedEquipment) return; - try { - setBusyKey(`transfer-${selectedEquipment.id}`); - await apiClient.transferEquipment(selectedEquipment.id, { - to_department: transferForm.to_department, - to_person: transferForm.to_person, - reason: transferForm.reason, - }); - setActionSuccess("Оборудование переведено."); - closeOperationModal(); - await reloadEquipmentList(); - await loadRowDetails(selectedEquipment.id); - } catch (error: any) { - setActionError(String(error?.message || "Не удалось выполнить перевод")); - } finally { - setBusyKey(null); - } - }; - - const handleWriteOff = async () => { - if (!selectedEquipment) return; - try { - setBusyKey(`writeoff-${selectedEquipment.id}`); - await apiClient.writeOffEquipment(selectedEquipment.id, writeOffReason); - setActionSuccess("Оборудование списано."); - closeOperationModal(); - await reloadEquipmentList(); - await loadRowDetails(selectedEquipment.id); - } catch (error: any) { - setActionError(String(error?.message || "Не удалось списать оборудование")); - } finally { - setBusyKey(null); - } - }; - - const handleMaintenance = async () => { - if (!selectedEquipment) return; - try { - setBusyKey(`maintenance-${selectedEquipment.id}`); - await apiClient.addEquipmentMaintenance(selectedEquipment.id, { - type: maintenanceForm.type, - description: maintenanceForm.description, - cost: maintenanceForm.cost || undefined, - date: maintenanceForm.date, - }); - setActionSuccess("Запись обслуживания добавлена."); - closeOperationModal(); - await loadRowDetails(selectedEquipment.id); - } catch (error: any) { - setActionError(String(error?.message || "Не удалось добавить обслуживание")); - } finally { - setBusyKey(null); - } - }; - - const handleOpenQr = async (equipmentId: number) => { - try { - setBusyKey(`qr-${equipmentId}`); - const blobUrl = await apiClient.getEquipmentQrCodeBlobUrl(equipmentId); - window.open(blobUrl, "_blank", "noopener,noreferrer"); - } catch (error: any) { - setActionError(String(error?.message || "Не удалось получить QR-код")); - } finally { - setBusyKey(null); - } - }; - - const toggleComments = async (eqId: number) => { - const isOpen = Boolean(expandedComments[eqId]); - setExpandedComments((p) => ({ ...p, [eqId]: !isOpen })); - if (!isOpen && !commentsMap[eqId]) { - try { - const c = await apiClient.getEquipmentComments(eqId); - setCommentsMap((p) => ({ ...p, [eqId]: Array.isArray(c) ? c : c.results || [] })); - } catch { - setCommentsMap((p) => ({ ...p, [eqId]: [] })); - } - } - }; - - const handleAddComment = async (eqId: number) => { - const text = (commentDrafts[eqId] || "").trim(); - if (!text) return; - try { - setBusyKey(`comment-${eqId}`); - const saved = await apiClient.addEquipmentComment(eqId, text); - setCommentsMap((p) => ({ ...p, [eqId]: [...(p[eqId] || []), saved] })); - setCommentDrafts((p) => ({ ...p, [eqId]: "" })); - } catch { - setActionError("Не удалось добавить комментарий"); - } finally { - setBusyKey(null); - } - }; - - const handleDeleteComment = async (eqId: number, commentId: number) => { - try { - setBusyKey(`comment-delete-${commentId}`); - await apiClient.deleteEquipmentComment(eqId, commentId); - setCommentsMap((p) => ({ ...p, [eqId]: (p[eqId] || []).filter((c) => c.id !== commentId) })); - } catch { - setActionError("Не удалось удалить комментарий"); - } finally { - setBusyKey(null); - } - }; - - const modalMode: "create" | "edit" = editingId ? "edit" : "create"; - const isModalOpen = createOpen || editingId !== null; - - const closeModal = () => { - setCreateOpen(false); - setEditingId(null); - resetForm(); - setActionError(null); - }; - - const activeFilterCount = [statusFilter, categoryFilter, departmentFilter, responsibleFilter, dateFromFilter, dateToFilter].filter(Boolean).length; + detailsMap, + displayUserName, + employees, + error, + expandedComments, + expandedRows, + filteredDepartmentsForForm, + filteredEmployeesForForm, + filteredItems, + filtersOpen, + form, + getEquipmentMeta, + getResponsibleLink, + getResponsibleName, + handleAddComment, + handleDelete, + handleDeleteComment, + handleLoadMore, + handleMaintenance, + handleOpenQr, + handleSave, + handleTransfer, + handleWriteOff, + isCreateMode, + isModalOpen, + listMode, + loading, + loadingMore, + loadingRowDetails, + maintenanceForm, + maintenanceMap, + modalMode, + nextPage, + openCreateModal, + openEdit, + openOperationModal, + operationModal, + ordering, + previewInventoryNumber, + responsibleFilter, + searchQuery, + selectedEquipment, + setCategoryFilter, + setCommentDrafts, + setDateFromFilter, + setDateToFilter, + setDepartmentFilter, + setFiltersOpen, + setForm, + setListMode, + setMaintenanceForm, + setOrdering, + setResponsibleFilter, + setSearchQuery, + setStatusFilter, + setTransferForm, + setWriteOffReason, + statusFilter, + transferForm, + transferHistoryMap, + toggleComments, + toggleRow, + writeOffReason, + } = useEquipmentPage(user); /* ──── render ──── */ return ( @@ -770,7 +183,7 @@ export default function EquipmentPage() { setSearch(e.target.value)} + onChange={(e) => setSearchQuery(e.target.value)} placeholder="Поиск по оборудованию" className="w-full rounded-lg border border-gray-200 bg-gray-50 py-2.5 pl-9 pr-3 text-sm text-gray-800 transition focus:border-sky-500 focus:bg-white focus:outline-none focus:ring-2 focus:ring-sky-100" /> diff --git a/frontend/src/hooks/useEquipmentPage.ts b/frontend/src/hooks/useEquipmentPage.ts new file mode 100644 index 00000000..eb946853 --- /dev/null +++ b/frontend/src/hooks/useEquipmentPage.ts @@ -0,0 +1,868 @@ +"use client"; + +import { useCallback, useDeferredValue, useEffect, useMemo, useState } from "react"; +import { apiClient } from "@/lib/api"; +import { canManageEquipment } from "@/lib/permissions"; +import { displayUserName as sharedDisplayUserName, extractNextPage, formatDate, formatMoney, loadAllPages } from "@/lib/shared"; +import type { + Department, + Equipment, + EquipmentCategory, + EquipmentComment, + EquipmentCreateOptions, + EquipmentTransferHistoryEntry, + MaintenanceRecord, + User, +} from "@/types/api"; + +type EquipmentFormState = { + name: string; + notes: string; + category: number | null; + department: number | null; + responsible_person: number | null; + quantity: number; + serial_number: string; + purchase_date: string; + purchase_cost: string; + location: string; +}; + +type EquipmentListMode = "all" | "mine" | "warranty"; + +type OperationModal = "transfer" | "writeoff" | "maintenance" | null; + +type TransferFormState = { + to_department: number | null; + to_person: number | null; + reason: string; +}; + +type MaintenanceFormState = { + type: string; + description: string; + cost: string; + date: string; +}; + +type PaginatedLike = { + results?: T[]; + next?: string | null; +}; + +const emptyForm: EquipmentFormState = { + name: "", + notes: "", + category: null, + department: null, + responsible_person: null, + quantity: 1, + serial_number: "", + purchase_date: "", + purchase_cost: "", + location: "", +}; + +const createInitialMaintenanceForm = (): MaintenanceFormState => ({ + type: "repair", + description: "", + cost: "", + date: new Date().toISOString().slice(0, 10), +}); + +const getPaginatedResults = (response: unknown): T[] => { + if (Array.isArray(response)) { + return response as T[]; + } + + if (response && typeof response === "object") { + const results = (response as PaginatedLike).results; + if (Array.isArray(results)) { + return results; + } + } + + return []; +}; + +const getPaginatedNext = (response: unknown): number | null => { + if (!response || typeof response !== "object" || Array.isArray(response)) { + return null; + } + + return extractNextPage((response as PaginatedLike).next ?? null); +}; + +const getReadableError = (error: unknown, fallback: string): string => { + const raw = String((error as Error)?.message || fallback); + + try { + const parsed = JSON.parse(raw) as Record; + return Object.entries(parsed) + .map(([key, value]) => `${key}: ${Array.isArray(value) ? value.join(", ") : String(value)}`) + .join(". "); + } catch { + return raw; + } +}; + +export function useEquipmentPage(user: User | null) { + const canManage = canManageEquipment(user); + const auth = user?.auth; + + const [items, setItems] = useState([]); + const [detailsMap, setDetailsMap] = useState>({}); + const [transferHistoryMap, setTransferHistoryMap] = useState>({}); + const [maintenanceMap, setMaintenanceMap] = useState>({}); + const [employees, setEmployees] = useState([]); + const [departments, setDepartments] = useState([]); + const [categories, setCategories] = useState([]); + const [createOptions, setCreateOptions] = useState(null); + const [previewInventoryNumber, setPreviewInventoryNumber] = useState(""); + const [commentsMap, setCommentsMap] = useState>({}); + const [expandedRows, setExpandedRows] = useState>({}); + const [expandedComments, setExpandedComments] = useState>({}); + const [commentDrafts, setCommentDrafts] = useState>({}); + const [loadingRowDetails, setLoadingRowDetails] = useState>({}); + + const [createOpen, setCreateOpen] = useState(false); + const [editingId, setEditingId] = useState(null); + const [form, setForm] = useState(emptyForm); + const [listMode, setListMode] = useState("all"); + const [ordering, setOrdering] = useState("-created_at"); + const [operationModal, setOperationModal] = useState(null); + const [selectedEquipment, setSelectedEquipment] = useState(null); + const [writeOffReason, setWriteOffReason] = useState(""); + const [transferForm, setTransferForm] = useState({ to_department: null, to_person: null, reason: "" }); + const [maintenanceForm, setMaintenanceForm] = useState(createInitialMaintenanceForm); + + const [searchQuery, setSearchQuery] = useState(""); + const deferredSearchQuery = useDeferredValue(searchQuery); + const [statusFilter, setStatusFilter] = useState(""); + const [categoryFilter, setCategoryFilter] = useState(""); + const [departmentFilter, setDepartmentFilter] = useState(""); + const [responsibleFilter, setResponsibleFilter] = useState(""); + const [dateFromFilter, setDateFromFilter] = useState(""); + const [dateToFilter, setDateToFilter] = useState(""); + const [filtersOpen, setFiltersOpen] = useState(false); + + const [loading, setLoading] = useState(true); + const [loadingMore, setLoadingMore] = useState(false); + const [error, setError] = useState(null); + const [actionError, setActionError] = useState(null); + const [actionSuccess, setActionSuccess] = useState(null); + const [busyKey, setBusyKey] = useState(null); + const [nextPage, setNextPage] = useState(null); + + const isCreateMode = createOpen && editingId === null; + + const displayUserName = useCallback((person?: User | null) => sharedDisplayUserName(person), []); + + const buildParams = useCallback( + (page: number): Record => { + const params: Record = { page }; + if (deferredSearchQuery.trim()) params.search = deferredSearchQuery.trim(); + if (ordering) params.ordering = ordering; + if (statusFilter) params.status = statusFilter; + if (categoryFilter) params.category = categoryFilter; + if (departmentFilter) params.department = departmentFilter; + if (responsibleFilter) params.responsible_person = responsibleFilter; + if (dateFromFilter) params.purchase_date__gte = dateFromFilter; + if (dateToFilter) params.purchase_date__lte = dateToFilter; + return params; + }, + [categoryFilter, dateFromFilter, dateToFilter, deferredSearchQuery, departmentFilter, ordering, responsibleFilter, statusFilter], + ); + + const fetchEquipmentPage = useCallback( + async (page: number): Promise => { + if (listMode === "mine") { + return apiClient.getMyEquipment({ page }); + } + + if (listMode === "warranty") { + if (page > 1) { + return { results: [], next: null }; + } + return apiClient.getWarrantyExpiringEquipment(); + } + + return apiClient.getEquipment(buildParams(page)); + }, + [buildParams, listMode], + ); + + const reloadEquipmentList = useCallback(async () => { + const response = await fetchEquipmentPage(1); + setItems(getPaginatedResults(response)); + setNextPage(getPaginatedNext(response)); + }, [fetchEquipmentPage]); + + const getCategoryName = useCallback( + (equipment: Equipment): string => { + if (equipment.category_name) return equipment.category_name; + const category = categories.find((item) => item.id === Number(equipment.category)); + return category?.name || "—"; + }, + [categories], + ); + + const getDepartmentName = useCallback( + (equipment: Equipment): string => { + if (equipment.department_name) return equipment.department_name; + const department = departments.find((item) => item.id === Number(equipment.department)); + return department?.name || "—"; + }, + [departments], + ); + + const getResponsibleName = useCallback( + (equipment: Equipment): string => { + if (equipment.responsible_name) return equipment.responsible_name; + if (equipment.responsible_person && typeof equipment.responsible_person === "number") { + const employee = employees.find((item) => item.id === equipment.responsible_person); + return employee ? displayUserName(employee) : "—"; + } + return "—"; + }, + [displayUserName, employees], + ); + + const getResponsibleLink = useCallback( + (equipment: Equipment): string => { + if (!equipment.responsible_person || typeof equipment.responsible_person !== "number") return ""; + if (equipment.responsible_person === user?.id) return "/profile"; + return `/users/${equipment.responsible_person}`; + }, + [user?.id], + ); + + const getEquipmentMeta = useCallback( + (equipment: Equipment) => ([ + { label: "Категория", value: getCategoryName(equipment) }, + { label: "Отдел", value: getDepartmentName(equipment) }, + { label: "Ответственный", value: getResponsibleName(equipment) }, + { label: "Серийный номер", value: equipment.serial_number || "—" }, + { label: "Расположение", value: equipment.location || "—" }, + { label: "Гарантия до", value: formatDate(equipment.warranty_until) || "—" }, + { label: "Обслуживаний", value: String(equipment.maintenance_count ?? "—") }, + { label: "Стоимость", value: formatMoney(equipment.purchase_cost) }, + { label: "Добавлено", value: formatDate(equipment.created_at) || "—" }, + ]), + [getCategoryName, getDepartmentName, getResponsibleName], + ); + + const filteredDepartmentsForForm = useMemo(() => { + if (!createOptions || !isCreateMode || createOptions.allowed_departments.length === 0) { + return departments; + } + + const allowedIds = new Set(createOptions.allowed_departments.map((department) => department.id)); + return departments.filter((department) => allowedIds.has(department.id)); + }, [createOptions, departments, isCreateMode]); + + const filteredEmployeesForForm = useMemo(() => { + if (!isCreateMode || !createOptions || createOptions.can_choose_responsible) { + return employees; + } + if (!createOptions.default_responsible) { + return []; + } + return employees.filter((employee) => employee.id === createOptions.default_responsible?.id); + }, [createOptions, employees, isCreateMode]); + + const applyCreateDefaults = useCallback((options: EquipmentCreateOptions | null) => { + if (!options) return; + + setForm((previous) => { + const nextDepartment = options.can_choose_department + ? previous.department + : options.allowed_departments[0]?.id ?? previous.department; + const nextResponsible = options.can_choose_responsible + ? previous.responsible_person + : options.default_responsible?.id ?? previous.responsible_person; + + return { + ...previous, + department: nextDepartment, + responsible_person: nextResponsible, + }; + }); + }, []); + + const loadRowDetails = useCallback(async (equipmentId: number) => { + if (loadingRowDetails[equipmentId]) return; + + try { + setLoadingRowDetails((previous) => ({ ...previous, [equipmentId]: true })); + const results = await Promise.allSettled([ + detailsMap[equipmentId] ? Promise.resolve(detailsMap[equipmentId]) : apiClient.getEquipmentDetail(equipmentId), + transferHistoryMap[equipmentId] ? Promise.resolve(transferHistoryMap[equipmentId]) : apiClient.getEquipmentTransferHistory(equipmentId), + maintenanceMap[equipmentId] ? Promise.resolve(maintenanceMap[equipmentId]) : apiClient.getMaintenanceRecords({ equipment: equipmentId }), + ]); + + const [detailResult, transferResult, maintenanceResult] = results; + + if (detailResult.status === "fulfilled") { + setDetailsMap((previous) => ({ ...previous, [equipmentId]: detailResult.value as Equipment })); + } + + if (transferResult.status === "fulfilled") { + setTransferHistoryMap((previous) => ({ + ...previous, + [equipmentId]: getPaginatedResults(transferResult.value), + })); + } + + if (maintenanceResult.status === "fulfilled") { + setMaintenanceMap((previous) => ({ + ...previous, + [equipmentId]: getPaginatedResults(maintenanceResult.value), + })); + } + } catch (loadError) { + console.error("Ошибка загрузки деталей оборудования:", loadError); + } finally { + setLoadingRowDetails((previous) => ({ ...previous, [equipmentId]: false })); + } + }, [detailsMap, loadingRowDetails, maintenanceMap, transferHistoryMap]); + + const resetForm = useCallback(() => { + setForm(emptyForm); + }, []); + + const openCreateModal = useCallback(async () => { + setEditingId(null); + resetForm(); + setActionError(null); + setActionSuccess(null); + setCreateOpen(true); + + try { + const [options, inventoryPreview] = await Promise.all([ + apiClient.getEquipmentCreateOptions(), + apiClient.generateEquipmentInventoryNumber(), + ]); + setCreateOptions(options); + setPreviewInventoryNumber(inventoryPreview.inventory_number || ""); + applyCreateDefaults(options); + } catch (loadError) { + console.error("Ошибка загрузки опций создания оборудования:", loadError); + } + }, [applyCreateDefaults, resetForm]); + + const openEdit = useCallback((equipment: Equipment) => { + setEditingId(equipment.id); + setCreateOpen(false); + setActionError(null); + setActionSuccess(null); + setPreviewInventoryNumber(equipment.inventory_number || ""); + setForm({ + name: equipment.name || "", + notes: equipment.notes || "", + category: equipment.category ?? null, + department: equipment.department ?? null, + responsible_person: typeof equipment.responsible_person === "number" ? equipment.responsible_person : null, + quantity: 1, + serial_number: equipment.serial_number || "", + purchase_date: equipment.purchase_date || "", + purchase_cost: String(equipment.purchase_cost || ""), + location: equipment.location || "", + }); + }, []); + + const openOperationModal = useCallback((type: Exclude, equipment: Equipment) => { + setSelectedEquipment(equipment); + setOperationModal(type); + setActionError(null); + + if (type === "transfer") { + setTransferForm({ + to_department: equipment.department ?? null, + to_person: typeof equipment.responsible_person === "number" ? equipment.responsible_person : null, + reason: "", + }); + } + + if (type === "writeoff") { + setWriteOffReason(""); + } + + if (type === "maintenance") { + setMaintenanceForm(createInitialMaintenanceForm()); + } + }, []); + + const closeOperationModal = useCallback(() => { + setOperationModal(null); + setSelectedEquipment(null); + setWriteOffReason(""); + setTransferForm({ to_department: null, to_person: null, reason: "" }); + setMaintenanceForm(createInitialMaintenanceForm()); + }, []); + + useEffect(() => { + let cancelled = false; + + void (async () => { + try { + setLoading(true); + setError(null); + const response = await fetchEquipmentPage(1); + if (cancelled) return; + setItems(getPaginatedResults(response)); + setNextPage(getPaginatedNext(response)); + } catch (loadError) { + if (cancelled) return; + console.error("Ошибка загрузки оборудования:", loadError); + setError("Не удалось загрузить оборудование"); + } finally { + if (!cancelled) { + setLoading(false); + } + } + })(); + + return () => { + cancelled = true; + }; + }, [fetchEquipmentPage]); + + useEffect(() => { + let cancelled = false; + + void (async () => { + try { + const [allEmployees, allDepartments, allCategories] = await Promise.all([ + loadAllPages((page) => apiClient.getEmployees(page)), + loadAllPages((page) => apiClient.getDepartments(page)), + loadAllPages((page) => apiClient.getEquipmentCategories(page)), + ]); + + if (cancelled) return; + setEmployees(allEmployees); + setDepartments(allDepartments); + setCategories(allCategories); + } catch (loadError) { + if (!cancelled) { + console.error("Ошибка загрузки справочников:", loadError); + } + } + })(); + + return () => { + cancelled = true; + }; + }, []); + + useEffect(() => { + let cancelled = false; + + void (async () => { + try { + const options = await apiClient.getEquipmentCreateOptions(); + if (!cancelled) { + setCreateOptions(options); + } + } catch (loadError) { + if (!cancelled) { + console.error("Ошибка загрузки create options:", loadError); + } + } + })(); + + return () => { + cancelled = true; + }; + }, []); + + const sortItemsLocally = useCallback((source: Equipment[]) => { + const sorted = [...source]; + + sorted.sort((left, right) => { + switch (ordering) { + case "name": + return (left.name || "").localeCompare(right.name || "", "ru"); + case "purchase_date": + return new Date(left.purchase_date || 0).getTime() - new Date(right.purchase_date || 0).getTime(); + case "-purchase_date": + return new Date(right.purchase_date || 0).getTime() - new Date(left.purchase_date || 0).getTime(); + case "created_at": + return new Date(left.created_at || 0).getTime() - new Date(right.created_at || 0).getTime(); + case "-created_at": + default: + return new Date(right.created_at || 0).getTime() - new Date(left.created_at || 0).getTime(); + } + }); + + return sorted; + }, [ordering]); + + const filteredItems = useMemo(() => { + const query = searchQuery.trim().toLowerCase(); + let scopedItems = [...items]; + + if (listMode !== "all") { + scopedItems = scopedItems.filter((item) => { + const itemDate = item.purchase_date || ""; + if (statusFilter && item.status !== statusFilter) return false; + if (categoryFilter && Number(item.category) !== Number(categoryFilter)) return false; + if (departmentFilter && Number(item.department) !== Number(departmentFilter)) return false; + if (responsibleFilter && Number(item.responsible_person) !== Number(responsibleFilter)) return false; + if (dateFromFilter && itemDate && itemDate < dateFromFilter) return false; + if (dateToFilter && itemDate && itemDate > dateToFilter) return false; + return true; + }); + } + + const sorted = listMode === "all" ? items : sortItemsLocally(scopedItems); + if (!query) return sorted; + + return sorted.filter((item) => { + const name = (item.name || "").toLowerCase(); + const notes = (item.notes || "").toLowerCase(); + const serialNumber = (item.serial_number || "").toLowerCase(); + const inventoryNumber = (item.inventory_number || "").toLowerCase(); + const responsible = getResponsibleName(item).toLowerCase(); + const category = getCategoryName(item).toLowerCase(); + + return ( + name.includes(query) + || notes.includes(query) + || serialNumber.includes(query) + || inventoryNumber.includes(query) + || responsible.includes(query) + || category.includes(query) + ); + }); + }, [ + categoryFilter, + dateFromFilter, + dateToFilter, + departmentFilter, + getCategoryName, + getResponsibleName, + items, + listMode, + responsibleFilter, + searchQuery, + sortItemsLocally, + statusFilter, + ]); + + const handleSave = useCallback(async (mode: "create" | "edit") => { + try { + setBusyKey(`${mode}-save`); + setActionError(null); + setActionSuccess(null); + + if (!form.name.trim()) { + setActionError("Укажите название оборудования."); + return; + } + if (!form.category) { + setActionError("Выберите категорию."); + return; + } + if (!form.department) { + setActionError("Выберите отдел."); + return; + } + if (!form.purchase_date) { + setActionError("Укажите дату покупки."); + return; + } + if (!form.purchase_cost) { + setActionError("Укажите стоимость."); + return; + } + + const payload: Record = { + name: form.name, + notes: form.notes, + category: form.category, + department: form.department, + purchase_date: form.purchase_date, + purchase_cost: form.purchase_cost, + }; + + if (form.responsible_person) payload.responsible_person = form.responsible_person; + if (mode === "create" && form.quantity > 1) payload.quantity = form.quantity; + if (form.serial_number) payload.serial_number = form.serial_number; + if (form.location) payload.location = form.location; + + if (mode === "create") { + await apiClient.createEquipment(payload); + setActionSuccess("Оборудование добавлено."); + setCreateOpen(false); + } else if (editingId) { + await apiClient.updateEquipment(editingId, payload); + setActionSuccess("Оборудование обновлено."); + setEditingId(null); + } + + resetForm(); + await reloadEquipmentList(); + } catch (saveError) { + setActionError(getReadableError(saveError, "Не удалось сохранить")); + } finally { + setBusyKey(null); + } + }, [editingId, form, reloadEquipmentList, resetForm]); + + const handleLoadMore = useCallback(async () => { + if (!nextPage || loadingMore) return; + + try { + setLoadingMore(true); + const response = await fetchEquipmentPage(nextPage); + const chunk = getPaginatedResults(response); + setItems((previous) => { + const known = new Set(previous.map((item) => item.id)); + return [...previous, ...chunk.filter((item) => !known.has(item.id))]; + }); + setNextPage(getPaginatedNext(response)); + } catch { + setError("Не удалось загрузить ещё"); + } finally { + setLoadingMore(false); + } + }, [fetchEquipmentPage, loadingMore, nextPage]); + + const handleDelete = useCallback(async (id: number) => { + if (!window.confirm("Удалить это оборудование?")) return; + + try { + setBusyKey(`delete-${id}`); + await apiClient.deleteEquipment(id); + setItems((previous) => previous.filter((item) => item.id !== id)); + } catch { + setActionError("Не удалось удалить"); + } finally { + setBusyKey(null); + } + }, []); + + const toggleRow = useCallback((equipmentId: number) => { + const nextOpen = !expandedRows[equipmentId]; + setExpandedRows((previous) => ({ ...previous, [equipmentId]: nextOpen })); + if (nextOpen) { + void loadRowDetails(equipmentId); + } + }, [expandedRows, loadRowDetails]); + + const handleTransfer = useCallback(async () => { + if (!selectedEquipment) return; + + try { + setBusyKey(`transfer-${selectedEquipment.id}`); + await apiClient.transferEquipment(selectedEquipment.id, { + to_department: transferForm.to_department, + to_person: transferForm.to_person, + reason: transferForm.reason, + }); + setActionSuccess("Оборудование переведено."); + closeOperationModal(); + await reloadEquipmentList(); + await loadRowDetails(selectedEquipment.id); + } catch (transferError) { + setActionError(String((transferError as Error)?.message || "Не удалось выполнить перевод")); + } finally { + setBusyKey(null); + } + }, [closeOperationModal, loadRowDetails, reloadEquipmentList, selectedEquipment, transferForm]); + + const handleWriteOff = useCallback(async () => { + if (!selectedEquipment) return; + + try { + setBusyKey(`writeoff-${selectedEquipment.id}`); + await apiClient.writeOffEquipment(selectedEquipment.id, writeOffReason); + setActionSuccess("Оборудование списано."); + closeOperationModal(); + await reloadEquipmentList(); + await loadRowDetails(selectedEquipment.id); + } catch (writeOffError) { + setActionError(String((writeOffError as Error)?.message || "Не удалось списать оборудование")); + } finally { + setBusyKey(null); + } + }, [closeOperationModal, loadRowDetails, reloadEquipmentList, selectedEquipment, writeOffReason]); + + const handleMaintenance = useCallback(async () => { + if (!selectedEquipment) return; + + try { + setBusyKey(`maintenance-${selectedEquipment.id}`); + await apiClient.addEquipmentMaintenance(selectedEquipment.id, { + type: maintenanceForm.type, + description: maintenanceForm.description, + cost: maintenanceForm.cost || undefined, + date: maintenanceForm.date, + }); + setActionSuccess("Запись обслуживания добавлена."); + closeOperationModal(); + await loadRowDetails(selectedEquipment.id); + } catch (maintenanceError) { + setActionError(String((maintenanceError as Error)?.message || "Не удалось добавить обслуживание")); + } finally { + setBusyKey(null); + } + }, [closeOperationModal, loadRowDetails, maintenanceForm, selectedEquipment]); + + const handleOpenQr = useCallback(async (equipmentId: number) => { + try { + setBusyKey(`qr-${equipmentId}`); + const blobUrl = await apiClient.getEquipmentQrCodeBlobUrl(equipmentId); + window.open(blobUrl, "_blank", "noopener,noreferrer"); + } catch (qrError) { + setActionError(String((qrError as Error)?.message || "Не удалось получить QR-код")); + } finally { + setBusyKey(null); + } + }, []); + + const toggleComments = useCallback(async (equipmentId: number) => { + const isOpen = Boolean(expandedComments[equipmentId]); + setExpandedComments((previous) => ({ ...previous, [equipmentId]: !isOpen })); + + if (!isOpen && !commentsMap[equipmentId]) { + try { + const response = await apiClient.getEquipmentComments(equipmentId); + setCommentsMap((previous) => ({ + ...previous, + [equipmentId]: getPaginatedResults(response), + })); + } catch { + setCommentsMap((previous) => ({ ...previous, [equipmentId]: [] })); + } + } + }, [commentsMap, expandedComments]); + + const handleAddComment = useCallback(async (equipmentId: number) => { + const text = (commentDrafts[equipmentId] || "").trim(); + if (!text) return; + + try { + setBusyKey(`comment-${equipmentId}`); + const saved = await apiClient.addEquipmentComment(equipmentId, text); + setCommentsMap((previous) => ({ ...previous, [equipmentId]: [...(previous[equipmentId] || []), saved as EquipmentComment] })); + setCommentDrafts((previous) => ({ ...previous, [equipmentId]: "" })); + } catch { + setActionError("Не удалось добавить комментарий"); + } finally { + setBusyKey(null); + } + }, [commentDrafts]); + + const handleDeleteComment = useCallback(async (equipmentId: number, commentId: number) => { + try { + setBusyKey(`comment-delete-${commentId}`); + await apiClient.deleteEquipmentComment(equipmentId, commentId); + setCommentsMap((previous) => ({ + ...previous, + [equipmentId]: (previous[equipmentId] || []).filter((comment) => comment.id !== commentId), + })); + } catch { + setActionError("Не удалось удалить комментарий"); + } finally { + setBusyKey(null); + } + }, []); + + const modalMode: "create" | "edit" = editingId ? "edit" : "create"; + const isModalOpen = createOpen || editingId !== null; + + const closeModal = useCallback(() => { + setCreateOpen(false); + setEditingId(null); + resetForm(); + setActionError(null); + }, [resetForm]); + + const activeFilterCount = [statusFilter, categoryFilter, departmentFilter, responsibleFilter, dateFromFilter, dateToFilter].filter(Boolean).length; + + return { + auth, + actionError, + actionSuccess, + activeFilterCount, + busyKey, + canManage, + categories, + categoryFilter, + closeModal, + closeOperationModal, + commentDrafts, + commentsMap, + createOptions, + dateFromFilter, + dateToFilter, + departmentFilter, + departments, + detailsMap, + displayUserName, + editingId, + employees, + error, + expandedComments, + expandedRows, + filteredDepartmentsForForm, + filteredEmployeesForForm, + filteredItems, + filtersOpen, + form, + getEquipmentMeta, + getResponsibleLink, + getResponsibleName, + handleAddComment, + handleDelete, + handleDeleteComment, + handleLoadMore, + handleMaintenance, + handleOpenQr, + handleSave, + handleTransfer, + handleWriteOff, + isCreateMode, + isModalOpen, + listMode, + loading, + loadingMore, + loadingRowDetails, + maintenanceForm, + maintenanceMap, + modalMode, + nextPage, + openCreateModal, + openEdit, + openOperationModal, + operationModal, + ordering, + previewInventoryNumber, + responsibleFilter, + searchQuery, + selectedEquipment, + setCategoryFilter, + setCommentDrafts, + setDateFromFilter, + setDateToFilter, + setDepartmentFilter, + setFiltersOpen, + setForm, + setListMode, + setMaintenanceForm, + setOrdering, + setResponsibleFilter, + setSearchQuery, + setStatusFilter, + setTransferForm, + setWriteOffReason, + statusFilter, + transferForm, + transferHistoryMap, + toggleComments, + toggleRow, + writeOffReason, + }; +} \ No newline at end of file