From decbadf47b5a8a15ae123f4a47d10f27bd26a239 Mon Sep 17 00:00:00 2001 From: lizerk Date: Fri, 27 Mar 2026 18:11:14 +0300 Subject: [PATCH] =?UTF-8?q?=D0=A3=D0=BD=D0=B8=D1=84=D0=B8=D0=BA=D0=B0?= =?UTF-8?q?=D1=86=D0=B8=D1=8F=20=D1=81=D0=BE=D1=80=D1=82=D0=B8=D1=80=D0=BE?= =?UTF-8?q?=D0=B2=D0=BA=D0=B8=20=D0=B8=20=D0=B2=D0=B8=D0=B7=D1=83=D0=B0?= =?UTF-8?q?=D0=BB=D1=8C=D0=BD=D0=BE=D0=B3=D0=BE=20=D1=81=D1=82=D0=B8=D0=BB?= =?UTF-8?q?=D1=8F=20=D0=BA=D0=B0=D1=80=D1=82=D0=BE=D1=87=D0=B5=D0=BA:=20?= =?UTF-8?q?=D0=B4=D0=BE=D0=B1=D0=B0=D0=B2=D0=BB=D0=B5=D0=BD=D1=8B=20=D1=81?= =?UTF-8?q?=D0=B5=D0=BB=D0=B5=D0=BA=D1=82=D0=BE=D1=80=D1=8B=20=D1=81=D0=BE?= =?UTF-8?q?=D1=80=D1=82=D0=B8=D1=80=D0=BE=D0=B2=D0=BA=D0=B8=20=D0=B8=20?= =?UTF-8?q?=D1=83=D0=B1=D1=80=D0=B0=D0=BD=D1=8B=20=D0=BB=D0=B8=D1=88=D0=BD?= =?UTF-8?q?=D0=B8=D0=B5=20=D1=82=D0=B5=D0=BD=D0=B8=20=D0=B2=20=D1=80=D0=B0?= =?UTF-8?q?=D0=B7=D0=B4=D0=B5=D0=BB=D0=B0=D1=85=20=D0=B7=D0=B0=D1=8F=D0=B2?= =?UTF-8?q?=D0=BB=D0=B5=D0=BD=D0=B8=D0=B9,=20=D0=B7=D0=B0=D0=BA=D1=83?= =?UTF-8?q?=D0=BF=D0=BE=D0=BA=20=D0=B8=20=D0=BE=D0=B1=D0=BE=D1=80=D1=83?= =?UTF-8?q?=D0=B4=D0=BE=D0=B2=D0=B0=D0=BD=D0=B8=D1=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/app/documents/page.tsx | 559 ++++++------ frontend/src/app/equipment/page.tsx | 1171 ++++++++++++++++++++----- frontend/src/app/messages/page.tsx | 19 +- frontend/src/app/procurement/page.tsx | 299 +++++-- frontend/src/app/requests/page.tsx | 558 +++++++----- frontend/src/lib/api.ts | 167 ++-- frontend/src/lib/permissions.ts | 47 + frontend/src/types/api.ts | 65 +- 8 files changed, 1958 insertions(+), 927 deletions(-) diff --git a/frontend/src/app/documents/page.tsx b/frontend/src/app/documents/page.tsx index 8f4eae72..55550bb6 100644 --- a/frontend/src/app/documents/page.tsx +++ b/frontend/src/app/documents/page.tsx @@ -15,7 +15,7 @@ import { Tags, LayoutGrid, CheckSquare, - SlidersHorizontal, + Filter, CheckCircle, AlertCircle, Calendar, @@ -107,7 +107,7 @@ export default function DocumentsPage() { console.log("📁 Загрузка документов с параметрами:", params); const response = await apiClient.getDocuments(params); console.log("📄 Получено документов:", response.results?.length || 0); - setDocuments(response.results || []); + setDocuments(response.results || response || []); } catch (err) { console.error("Ошибка загрузки документов:", err); setError("Не удалось загрузить документы"); @@ -226,91 +226,295 @@ export default function DocumentsPage() { return selectedFolder.path.split(' / '); }, [selectedFolder]); + const activeFilterCount = selectedTags.length + (dateFrom ? 1 : 0) + (dateTo ? 1 : 0); + return (
{/* Top Bar */}
-
- {/* Left Section: Navigation */} -
- {/* Folder Filter */} -
- +
+ +
+
+ + setSearch(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" + /> +
+ +
+ +
+
+ - {showFolderDropdown && ( -
-
- -
-
- { - console.log("🗂️ Выбрана папка:", id); - setSelectedFolderId(id); - setShowFolderDropdown(false); - }} - /> -
-
+ } + }} + > + + + ) : ( + )} -
-
- - {/* Right Section: Actions */} -
- + {showFolderDropdown && ( +
+
+ +
+
+ { + console.log("🗂️ Выбрана папка:", id); + setSelectedFolderId(id); + setShowFolderDropdown(false); + }} + /> +
+
+ )}
+ + {/* Active Filters Tags */} + {(selectedTags.length > 0 || dateFrom || dateTo) && ( +
+ {selectedTags.map(tagId => { + const tag = availableTags.find(t => t.id === tagId); + return tag ? ( + + + {tag.name} + + + ) : null; + })} + {dateFrom && ( + + + С: {new Date(dateFrom).toLocaleDateString('ru')} + + + )} + {dateTo && ( + + + До: {new Date(dateTo).toLocaleDateString('ru')} + + + )} + +
+ )} + + {/* Filters Panel */} + {showFilters && ( +
+ {/* Tags Filter */} +
+
+ + +
+ {availableTags.length > 0 ? ( +
+ {availableTags.map(tag => ( + + ))} +
+ ) : ( +

Нет доступных тегов

+ )} +
+ + {/* Date Range */} +
+
+ + setDateFrom(e.target.value)} + className="w-full rounded-lg border border-gray-200 bg-gray-50 px-3 py-2 text-sm text-gray-800 transition focus:border-sky-500 focus:bg-white focus:outline-none focus:ring-2 focus:ring-sky-100" + /> +
+
+ + setDateTo(e.target.value)} + className="w-full rounded-lg border border-gray-200 bg-gray-50 px-3 py-2 text-sm text-gray-800 transition focus:border-sky-500 focus:bg-white focus:outline-none focus:ring-2 focus:ring-sky-100" + /> +
+
+ + {/* Sorting */} +
+
+ + +
+
+ + +
+
+ + {activeFilterCount > 0 && ( + + )} +
+ )}
{/* Main Content */} @@ -326,199 +530,6 @@ export default function DocumentsPage() {
) : ( <> - {/* Search & Filters */} -
- {/* Search Bar */} -
-
- - setSearch(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" - /> -
- -
- - {/* Active Filters Tags */} - {(selectedTags.length > 0 || dateFrom || dateTo) && ( -
- {selectedTags.map(tagId => { - const tag = availableTags.find(t => t.id === tagId); - return tag ? ( - - - {tag.name} - - - ) : null; - })} - {dateFrom && ( - - - С: {new Date(dateFrom).toLocaleDateString('ru')} - - - )} - {dateTo && ( - - - До: {new Date(dateTo).toLocaleDateString('ru')} - - - )} - -
- )} - - {/* Filters Panel */} - {showFilters && ( -
- {/* Tags Filter */} -
-
- - -
- {availableTags.length > 0 ? ( -
- {availableTags.map(tag => ( - - ))} -
- ) : ( -

Нет доступных тегов

- )} -
- - {/* Date Range */} -
-
- - setDateFrom(e.target.value)} - className="w-full rounded-lg border border-gray-200 bg-gray-50 px-3 py-2 text-sm text-gray-800 transition focus:border-sky-500 focus:bg-white focus:outline-none focus:ring-2 focus:ring-sky-100" - /> -
-
- - setDateTo(e.target.value)} - className="w-full rounded-lg border border-gray-200 bg-gray-50 px-3 py-2 text-sm text-gray-800 transition focus:border-sky-500 focus:bg-white focus:outline-none focus:ring-2 focus:ring-sky-100" - /> -
-
- - {/* Sorting */} -
-
- - -
-
- - -
-
-
- )} -
- {/* Bulk Actions Toolbar */} {selection.selectedIds.length > 0 && (
@@ -918,16 +929,6 @@ export default function DocumentsPage() { - {/* Acknowledgements Report Modal */} - {showAcknowledgementsReport && ( - setShowAcknowledgementsReport(null)} - /> - )} - - {/* Tag Management Modal */} setShowTagManagement(false)} diff --git a/frontend/src/app/equipment/page.tsx b/frontend/src/app/equipment/page.tsx index 3ed19ac0..a0e923bf 100644 --- a/frontend/src/app/equipment/page.tsx +++ b/frontend/src/app/equipment/page.tsx @@ -3,49 +3,119 @@ import { AppShell } from "../../components/AppShell"; import { apiClient } from "@/lib/api"; import { useUser } from "@/contexts/UserContext"; -import { canManageRequests } from "@/lib/permissions"; -import { useEffect, useMemo, useRef, useState } from "react"; +import { canManageEquipment } from "@/lib/permissions"; +import { useDeferredValue, useEffect, useMemo, useRef, useState } from "react"; import Link from "next/link"; -import type { Equipment, EquipmentCategory, EquipmentComment, User, Department } from "@/types/api"; -import { ChevronDown, Filter, MessageSquare, Monitor, Paperclip, Pencil, Plus, Search, Trash2, X, FileSignature } from "lucide-react"; +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"; /* ──── form state ──── */ type EquipmentFormState = { name: string; - description: string; + notes: string; category: number | null; department: number | null; - assigned_to: number | null; + responsible_person: number | null; + quantity: number; serial_number: string; purchase_date: string; purchase_cost: string; location: string; - attachment: File | null; }; const emptyForm: EquipmentFormState = { name: "", - description: "", + notes: "", category: null, department: null, - assigned_to: null, + responsible_person: null, + quantity: 1, serial_number: "", purchase_date: "", purchase_cost: "", location: "", - attachment: null, }; +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 }> = [ + { value: "all", label: "Весь реестр" }, + { value: "mine", label: "Мое оборудование" }, + { value: "warranty", label: "Истекает гарантия" }, +]; + +const orderingOptions = [ + { value: "-created_at", label: "Сначала новые" }, + { value: "created_at", label: "Сначала старые" }, + { value: "name", label: "По названию" }, + { value: "purchase_date", label: "По дате покупки ↑" }, + { value: "-purchase_date", label: "По дате покупки ↓" }, +]; + /* ──── status badges ──── */ -const statusMeta: Record = { - available: { label: "Доступно", className: "bg-emerald-50 text-emerald-700 ring-emerald-100" }, - in_use: { label: "В использовании", className: "bg-sky-50 text-sky-700 ring-sky-100" }, - maintenance: { label: "На обслуживании", className: "bg-amber-50 text-amber-700 ring-amber-100" }, - retired: { label: "Списано", className: "bg-gray-100 text-gray-700 ring-gray-200" }, - broken: { label: "Сломано", className: "bg-rose-50 text-rose-700 ring-rose-100" }, +const statusMeta: Record = { + available: { + label: "Доступно", + className: "bg-emerald-50 text-emerald-700 ring-emerald-100", + accentClass: "bg-emerald-500", + surfaceClass: "border-emerald-100", + }, + in_use: { + label: "В использовании", + className: "bg-sky-50 text-sky-700 ring-sky-100", + accentClass: "bg-sky-500", + surfaceClass: "border-sky-100", + }, + maintenance: { + label: "На обслуживании", + className: "bg-amber-50 text-amber-700 ring-amber-100", + accentClass: "bg-amber-500", + surfaceClass: "border-amber-100", + }, + retired: { + label: "Списано", + className: "bg-gray-100 text-gray-700 ring-gray-200", + accentClass: "bg-gray-400", + surfaceClass: "border-gray-200", + }, + broken: { + label: "Сломано", + className: "bg-rose-50 text-rose-700 ring-rose-100", + accentClass: "bg-rose-500", + surfaceClass: "border-rose-100", + }, }; -const defaultStatusMeta = { label: "—", className: "bg-gray-50 text-gray-700 ring-gray-200" }; +const defaultStatusMeta = { + label: "—", + className: "bg-gray-50 text-gray-700 ring-gray-200", + accentClass: "bg-gray-300", + surfaceClass: "border-gray-200", +}; function formatDate(value?: string | null): string { if (!value) return ""; @@ -54,27 +124,56 @@ function formatDate(value?: string | null): string { return date.toLocaleDateString("ru-RU", { day: "2-digit", month: "2-digit", year: "numeric" }); } +function formatMoney(value?: string | number | null): string { + if (value === null || value === undefined || value === "") return "—"; + const amount = Number(value); + if (!Number.isFinite(amount)) return "—"; + return `${amount.toLocaleString("ru-RU")} ₽`; +} + /* ──── 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 [employeeFilter, setEmployeeFilter] = 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 [attachmentPreview, setAttachmentPreview] = useState<{ url: string; name: string } | null>(null); const [loading, setLoading] = useState(true); const [loadingMore, setLoadingMore] = useState(false); @@ -85,7 +184,8 @@ export default function EquipmentPage() { const [nextPage, setNextPage] = useState(null); const auth = user?.auth; - const canManage = canManageRequests(user); + const canManage = canManageEquipment(user); + const isCreateMode = createOpen && editingId === null; /* ──── helpers ──── */ const displayUserName = (person?: User | null) => { @@ -94,12 +194,6 @@ export default function EquipmentPage() { return full || (person as any)?.full_name || (person as any)?.display_name || person.email || "Пользователь"; }; - const userProfileLink = (person?: User | null) => { - if (!person?.id) return ""; - if (user?.id && person.id === user.id) return "/profile"; - return `/users/${person.id}`; - }; - const extractNextPage = (nextUrl?: string | null): number | null => { if (!nextUrl) return null; try { @@ -113,14 +207,18 @@ export default function EquipmentPage() { const buildParams = (page: number): Record => { const p: Record = { page }; - if (employeeFilter) p.assigned_to = employeeFilter; - if (dateFromFilter) p.purchase_date_after = dateFromFilter; - if (dateToFilter) p.purchase_date_before = dateToFilter; + 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 (typeof eq.category === "object" && eq.category?.name) return eq.category.name; if (eq.category_name) return eq.category_name; const cat = categories.find((c) => c.id === Number(eq.category)); return cat?.name || "—"; @@ -132,22 +230,190 @@ export default function EquipmentPage() { return dep?.name || "—"; }; - const getAssignedUser = (eq: Equipment): User | null => { - if (eq.assigned_to_details) return eq.assigned_to_details; - if (typeof eq.assigned_to === "object" && eq.assigned_to) return eq.assigned_to as User; - if (typeof eq.assigned_to === "number") { - return employees.find((e) => e.id === eq.assigned_to) || null; + 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 null; + 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 apiClient.getEquipment(buildParams(1)); + const res = await fetchEquipmentPage(1); const results = Array.isArray(res) ? res : (res.results || []); setItems(results); setNextPage(extractNextPage(res.next)); @@ -158,7 +424,17 @@ export default function EquipmentPage() { setLoading(false); } })(); - }, [employeeFilter, dateFromFilter, dateToFilter]); + }, [ + listMode, + deferredSearchQuery, + ordering, + statusFilter, + categoryFilter, + departmentFilter, + responsibleFilter, + dateFromFilter, + dateToFilter, + ]); useEffect(() => { async function loadAllPages(fetcher: (params: any) => Promise): Promise { @@ -190,20 +466,83 @@ export default function EquipmentPage() { })(); }, []); + 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(); - const sorted = [...items].sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime()); + 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 desc = (item.description || "").toLowerCase(); + const notes = (item.notes || "").toLowerCase(); const sn = (item.serial_number || "").toLowerCase(); - const assigned = displayUserName(getAssignedUser(item)).toLowerCase(); + const inv = (item.inventory_number || "").toLowerCase(); + const responsible = getResponsibleName(item).toLowerCase(); const cat = getCategoryName(item).toLowerCase(); - return name.includes(q) || desc.includes(q) || sn.includes(q) || assigned.includes(q) || cat.includes(q); + return name.includes(q) || notes.includes(q) || sn.includes(q) || inv.includes(q) || responsible.includes(q) || cat.includes(q); }); - }, [items, searchQuery, employees, categories, departments]); + }, [ + items, + listMode, + ordering, + searchQuery, + statusFilter, + categoryFilter, + departmentFilter, + responsibleFilter, + dateFromFilter, + dateToFilter, + employees, + categories, + departments, + ]); /* ──── form ──── */ const resetForm = () => setForm(emptyForm); @@ -213,17 +552,18 @@ export default function EquipmentPage() { setCreateOpen(false); setActionError(null); setActionSuccess(null); + setPreviewInventoryNumber(eq.inventory_number || ""); setForm({ name: eq.name || "", - description: eq.description || "", - category: typeof eq.category === "object" ? eq.category?.id ?? null : eq.category ?? null, + notes: eq.notes || "", + category: eq.category ?? null, department: eq.department ?? null, - assigned_to: typeof eq.assigned_to === "object" ? (eq.assigned_to as User)?.id ?? null : (eq.assigned_to as number) ?? 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 || "", - attachment: null, }); }; @@ -241,17 +581,17 @@ export default function EquipmentPage() { const payload: Record = { name: form.name, - description: form.description, + notes: form.notes, category: form.category, department: form.department, purchase_date: form.purchase_date, purchase_cost: form.purchase_cost, }; - if (form.assigned_to) payload.assigned_to = form.assigned_to; + 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 (form.attachment) payload.attachment = form.attachment; if (mode === "create") { await apiClient.createEquipment(payload); @@ -264,10 +604,7 @@ export default function EquipmentPage() { } resetForm(); - const res = await apiClient.getEquipment(buildParams(1)); - const results = Array.isArray(res) ? res : (res.results || []); - setItems(results); - setNextPage(extractNextPage(res.next)); + await reloadEquipmentList(); } catch (e: any) { const raw = String(e?.message || "Не удалось сохранить"); let readable = raw; @@ -285,7 +622,7 @@ export default function EquipmentPage() { if (!nextPage || loadingMore) return; try { setLoadingMore(true); - const res = await apiClient.getEquipment(buildParams(nextPage)); + const res = await fetchEquipmentPage(nextPage); const chunk = Array.isArray(res) ? res : (res.results || []); setItems((prev) => { const known = new Set(prev.map((r) => r.id)); @@ -304,6 +641,84 @@ export default function EquipmentPage() { 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 })); @@ -344,15 +759,14 @@ export default function EquipmentPage() { } }; - const fileInputRef = useRef(null); - /* ──── SearchableSelect (single) ──── */ - const SearchableSelectSingle = ({ label, items: selectItems, selectedId, onSelect, placeholder }: { + const SearchableSelectSingle = ({ label, items: selectItems, selectedId, onSelect, placeholder, disabled = false }: { label: string; items: { id: number; name: string }[]; selectedId: number | null; onSelect: (id: number | null) => void; placeholder?: string; + disabled?: boolean; }) => { const [open, setOpen] = useState(false); const [q, setQ] = useState(""); @@ -374,8 +788,9 @@ export default function EquipmentPage() { +
+ + + +
+
+ +
+ {listModeMeta.map((mode) => ( + + ))}
{/* Filters panel */} {filtersOpen && (
- +
+ + + + +
+ Дата покупки: setDateFromFilter(e.target.value)} className="flex-1 rounded-lg border border-gray-200 bg-white px-3 py-2 text-sm text-gray-800" placeholder="С" /> — setDateToFilter(e.target.value)} className="flex-1 rounded-lg border border-gray-200 bg-white px-3 py-2 text-sm text-gray-800" placeholder="По" />
{activeFilterCount > 0 && ( - )} @@ -504,142 +970,324 @@ export default function EquipmentPage() { )} {/* Items list */} -
+
{filteredItems.length === 0 ? (

Записи об оборудовании не найдены

) : ( - filteredItems.map((item) => { - const assignedUser = getAssignedUser(item); - const assignedName = displayUserName(assignedUser); - const assignedLink = userProfileLink(assignedUser); + <> +
+ Оборудование + Ответственный + Статус + Стоимость + Дата покупки + Действия +
+ + {filteredItems.map((item) => { + const responsibleName = getResponsibleName(item); + const responsibleId = typeof item.responsible_person === "number" ? item.responsible_person : null; + const responsibleLink = getResponsibleLink(item); const statusKey = String(item.status || "").toLowerCase(); - const status = statusMeta[statusKey] ?? defaultStatusMeta; - const isAuthor = Boolean(item.created_by?.id && user?.id && item.created_by.id === user.id); - const canDeleteThis = isAuthor || canManage; - const canEditThis = isAuthor || canManage; + const st = statusMeta[statusKey] ?? defaultStatusMeta; + const canEditThis = canManage; + const canDeleteThis = canManage; const comments = commentsMap[item.id] || []; + const rowOpen = Boolean(expandedRows[item.id]); const commentsOpen = Boolean(expandedComments[item.id]); + const commentsTotal = item.comments_count ?? comments.length; + const detailItem = detailsMap[item.id] || item; + const metaItems = getEquipmentMeta(detailItem); + const transferHistory = transferHistoryMap[item.id] || []; + const maintenanceRecords = maintenanceMap[item.id] || []; + const rowLoading = Boolean(loadingRowDetails[item.id]); return ( -
- {/* Assigned user + purchase date */} -
-
- {assignedUser ? ( - assignedLink ? ( - - {assignedUser.avatar ? ( - {assignedName} - ) : ( - - {(assignedUser.first_name?.[0] || assignedUser.last_name?.[0] || "?").toUpperCase()} - - )} - {assignedName} - - ) : ( -
- - {(assignedUser.first_name?.[0] || assignedUser.last_name?.[0] || "?").toUpperCase()} - - {assignedName} +
+
+
+ + +
+
+
+
+ +

{item.name || "Без названия"}

+
+
+ {item.inventory_number || "Без инв. номера"} + {item.serial_number && SN: {item.serial_number}} +
- ) + +
+ + {canEditThis && ( + + )} + {canDeleteThis && ( + + )} +
+
+ +
+ {statusKey && {st.label}} + {item.is_under_warranty && ( + + Гарантия + + )} +
+ +
+
+ Стоимость:{" "} + {formatMoney(item.purchase_cost)} +
+
+ Покупка:{" "} + {formatDate(item.purchase_date) || "—"} +
+ {responsibleId ? ( +
+ Ответственный:{" "} + + {responsibleName} + +
+ ) : ( +
Ответственный не назначен
+ )} +
+
+
+
+ +
+
+
+ +
+
+ +

{item.name || "Без названия"}

+
+
+ {item.inventory_number || "Без инв. номера"} + {item.serial_number && SN: {item.serial_number}} + {item.location && {item.location}} +
+
+
+
+ +
+ {responsibleId ? ( + + + {responsibleName[0]?.toUpperCase() || "?"} + + {responsibleName} + ) : ( - Не назначено + Не назначен )}
- {formatDate(item.purchase_date)} -
- {/* Name + status */} -
-

{item.name || "Без названия"}

- {statusKey && {status.label}} -
- - {/* Description */} - {item.description && ( -

{item.description}

- )} - - {/* Meta */} -
-

Категория: {getCategoryName(item)}

-

Отдел: {getDepartmentName(item)}

- {item.serial_number &&

Серийный номер: {item.serial_number}

} - {item.location &&

Расположение: {item.location}

} -

Стоимость: {item.purchase_cost ? `${Number(item.purchase_cost).toLocaleString("ru-RU")} ₽` : "—"}

-

Добавлено: {formatDate(item.created_at)}

- - {(item.image || item.attachment || item.attachment_url) && ( -
- -
- )} -
- - {/* Action buttons */} -
- - {canEditThis && ( - - )} - {canDeleteThis && ( - - )} -
- - {/* Comments */} - {commentsOpen && ( -
-
- {comments.length === 0 ? ( -

Комментариев пока нет

- ) : ( - comments.map((c) => { - const canDel = Boolean(c.author?.id && (user?.id === c.author.id || auth?.is_staff || auth?.is_superuser)); - return ( -
-
- {displayUserName(c.author)} -
- {formatDate(c.created_at)} - {canDel && } -
-
-

{c.text}

-
- ); - }) )}
-
- setCommentDrafts((p) => ({ ...p, [item.id]: e.target.value }))} placeholder="Добавить комментарий" className="flex-1 rounded-lg border border-gray-300 px-3 py-2 text-xs" /> - -
+
+ +
+

{formatMoney(item.purchase_cost)}

+
+ +
+

{formatDate(item.purchase_date) || "—"}

+
+ +
+ + {canEditThis && ( + + )} + {canDeleteThis && ( + + )} +
+
+ + {(rowOpen || commentsOpen) && ( +
+ {commentsOpen && ( +
+
+ {comments.length === 0 ? ( +

Комментариев пока нет

+ ) : ( + comments.map((c) => { + const canDel = Boolean(c.author?.id && (user?.id === c.author.id || auth?.is_staff || auth?.is_superuser)); + return ( +
+
+ {displayUserName(c.author)} +
+ {formatDate(c.created_at)} + {canDel && } +
+
+

{c.text}

+
+ ); + }) + )} +
+
+ setCommentDrafts((p) => ({ ...p, [item.id]: e.target.value }))} placeholder="Добавить комментарий" className="flex-1 rounded-lg border border-gray-300 px-3 py-2 text-xs" /> + +
+
+ )} + + {rowOpen && ( + <> + {rowLoading && ( +
+ Загружаем детали оборудования... +
+ )} + +
+ + + + {item.status !== "retired" && ( + + )} +
+ + {detailItem.notes && ( +
+

Заметки

+

{detailItem.notes}

+
+ )} + +
+ {metaItems.map((meta) => ( +
+

{meta.label}

+

{meta.value}

+
+ ))} +
+ +
+
+

История переводов

+ {transferHistory.length === 0 ? ( +

Переводы пока не выполнялись

+ ) : ( +
+ {transferHistory.map((entry) => ( +
+
+ {formatDate(entry.date)} + {entry.created_by || "—"} +
+

{entry.from_department || "—"} → {entry.to_department || "—"}

+ {(entry.from_person || entry.to_person) &&

{entry.from_person || "—"} → {entry.to_person || "—"}

} + {entry.reason &&

Причина: {entry.reason}

} +
+ ))} +
+ )} +
+ +
+

История обслуживания

+ {maintenanceRecords.length === 0 ? ( +

Записей обслуживания пока нет

+ ) : ( +
+ {maintenanceRecords.map((record) => ( +
+
+ {record.type_display || record.type} + {formatDate(record.date)} +
+ {record.description &&

{record.description}

} +
+ {record.performed_by_name || "—"} + {formatMoney(record.cost)} +
+
+ ))} +
+ )} +
+
+ + )} +
)}
); - }) + })} + )}
@@ -686,24 +1334,46 @@ export default function EquipmentPage() { onSelect={(id) => setForm((p) => ({ ...p, category: id }))} /> + {isCreateMode && previewInventoryNumber && ( +
+ Следующий инвентарный номер: {previewInventoryNumber} +
+ )} + {/* Отдел */} ({ id: d.id, name: d.name }))} + items={filteredDepartmentsForForm.map((d) => ({ id: d.id, name: d.name }))} selectedId={form.department} onSelect={(id) => setForm((p) => ({ ...p, department: id }))} + disabled={Boolean(isCreateMode && createOptions && !createOptions.can_choose_department)} /> - {/* Кому выдано */} + {/* Ответственный */} ({ id: emp.id, name: displayUserName(emp) }))} - selectedId={form.assigned_to} - onSelect={(id) => setForm((p) => ({ ...p, assigned_to: id }))} + items={filteredEmployeesForForm.map((emp) => ({ id: emp.id, name: displayUserName(emp) }))} + selectedId={form.responsible_person} + onSelect={(id) => setForm((p) => ({ ...p, responsible_person: id }))} + disabled={Boolean(isCreateMode && createOptions && !createOptions.can_choose_responsible)} /> + {modalMode === "create" && ( +
+ + setForm((p) => ({ ...p, quantity: Math.max(1, Math.min(100, Number(e.target.value) || 1)) }))} + className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-sky-400 focus:outline-none focus:ring-2 focus:ring-sky-100" + /> +
+ )} + {/* Дата покупки + стоимость */}
@@ -750,33 +1420,17 @@ export default function EquipmentPage() { />
- {/* Описание */} + {/* Заметки */}
- +