Refactor equipment page orchestration

This commit is contained in:
lizerk 2026-04-01 18:39:10 +03:00
parent 19f6f660b0
commit d1574dfd31
2 changed files with 953 additions and 672 deletions

View File

@ -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<Equipment[]>([]);
const [detailsMap, setDetailsMap] = useState<Record<number, Equipment>>({});
const [transferHistoryMap, setTransferHistoryMap] = useState<Record<number, EquipmentTransferHistoryEntry[]>>({});
const [maintenanceMap, setMaintenanceMap] = useState<Record<number, MaintenanceRecord[]>>({});
const [employees, setEmployees] = useState<User[]>([]);
const [departments, setDepartments] = useState<Department[]>([]);
const [categories, setCategories] = useState<EquipmentCategory[]>([]);
const [createOptions, setCreateOptions] = useState<EquipmentCreateOptions | null>(null);
const [previewInventoryNumber, setPreviewInventoryNumber] = useState<string>("");
const [commentsMap, setCommentsMap] = useState<Record<number, EquipmentComment[]>>({});
const [expandedRows, setExpandedRows] = useState<Record<number, boolean>>({});
const [expandedComments, setExpandedComments] = useState<Record<number, boolean>>({});
const [commentDrafts, setCommentDrafts] = useState<Record<number, string>>({});
const [loadingRowDetails, setLoadingRowDetails] = useState<Record<number, boolean>>({});
const [createOpen, setCreateOpen] = useState(false);
const [editingId, setEditingId] = useState<number | null>(null);
const [form, setForm] = useState<EquipmentFormState>(emptyForm);
const [listMode, setListMode] = useState<EquipmentListMode>("all");
const [ordering, setOrdering] = useState("-created_at");
const [operationModal, setOperationModal] = useState<OperationModal>(null);
const [selectedEquipment, setSelectedEquipment] = useState<Equipment | null>(null);
const [writeOffReason, setWriteOffReason] = useState("");
const [transferForm, setTransferForm] = useState<TransferFormState>({ to_department: null, to_person: null, reason: "" });
const [maintenanceForm, setMaintenanceForm] = useState<MaintenanceFormState>({
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<string | null>(null);
const [actionError, setActionError] = useState<string | null>(null);
const [actionSuccess, setActionSuccess] = useState<string | null>(null);
const [busyKey, setBusyKey] = useState<string | null>(null);
const [nextPage, setNextPage] = useState<number | null>(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<string, string | number> => {
const p: Record<string, string | number> = { 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<OperationModal, null>, 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<User>((p) => apiClient.getEmployees(p)),
loadAllPages<Department>((p) => apiClient.getDepartments(p)),
loadAllPages<EquipmentCategory>((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<string, any> = {
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 (
<AppShell>
@ -770,7 +183,7 @@ export default function EquipmentPage() {
<Search size={16} className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-gray-400" />
<input
value={searchQuery}
onChange={(e) => 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"
/>

View File

@ -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<T> = {
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 = <T,>(response: unknown): T[] => {
if (Array.isArray(response)) {
return response as T[];
}
if (response && typeof response === "object") {
const results = (response as PaginatedLike<T>).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<unknown>).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<string, unknown>;
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<Equipment[]>([]);
const [detailsMap, setDetailsMap] = useState<Record<number, Equipment>>({});
const [transferHistoryMap, setTransferHistoryMap] = useState<Record<number, EquipmentTransferHistoryEntry[]>>({});
const [maintenanceMap, setMaintenanceMap] = useState<Record<number, MaintenanceRecord[]>>({});
const [employees, setEmployees] = useState<User[]>([]);
const [departments, setDepartments] = useState<Department[]>([]);
const [categories, setCategories] = useState<EquipmentCategory[]>([]);
const [createOptions, setCreateOptions] = useState<EquipmentCreateOptions | null>(null);
const [previewInventoryNumber, setPreviewInventoryNumber] = useState("");
const [commentsMap, setCommentsMap] = useState<Record<number, EquipmentComment[]>>({});
const [expandedRows, setExpandedRows] = useState<Record<number, boolean>>({});
const [expandedComments, setExpandedComments] = useState<Record<number, boolean>>({});
const [commentDrafts, setCommentDrafts] = useState<Record<number, string>>({});
const [loadingRowDetails, setLoadingRowDetails] = useState<Record<number, boolean>>({});
const [createOpen, setCreateOpen] = useState(false);
const [editingId, setEditingId] = useState<number | null>(null);
const [form, setForm] = useState<EquipmentFormState>(emptyForm);
const [listMode, setListMode] = useState<EquipmentListMode>("all");
const [ordering, setOrdering] = useState("-created_at");
const [operationModal, setOperationModal] = useState<OperationModal>(null);
const [selectedEquipment, setSelectedEquipment] = useState<Equipment | null>(null);
const [writeOffReason, setWriteOffReason] = useState("");
const [transferForm, setTransferForm] = useState<TransferFormState>({ to_department: null, to_person: null, reason: "" });
const [maintenanceForm, setMaintenanceForm] = useState<MaintenanceFormState>(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<string | null>(null);
const [actionError, setActionError] = useState<string | null>(null);
const [actionSuccess, setActionSuccess] = useState<string | null>(null);
const [busyKey, setBusyKey] = useState<string | null>(null);
const [nextPage, setNextPage] = useState<number | null>(null);
const isCreateMode = createOpen && editingId === null;
const displayUserName = useCallback((person?: User | null) => sharedDisplayUserName(person), []);
const buildParams = useCallback(
(page: number): Record<string, string | number> => {
const params: Record<string, string | number> = { 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<unknown> => {
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<Equipment>(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<EquipmentTransferHistoryEntry>(transferResult.value),
}));
}
if (maintenanceResult.status === "fulfilled") {
setMaintenanceMap((previous) => ({
...previous,
[equipmentId]: getPaginatedResults<MaintenanceRecord>(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<OperationModal, null>, 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<Equipment>(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<User>((page) => apiClient.getEmployees(page)),
loadAllPages<Department>((page) => apiClient.getDepartments(page)),
loadAllPages<EquipmentCategory>((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<string, unknown> = {
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<Equipment>(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<EquipmentComment>(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,
};
}