diff --git a/frontend/.github/copilot-instructions.md b/frontend/.github/copilot-instructions.md deleted file mode 100644 index b6b9e950..00000000 --- a/frontend/.github/copilot-instructions.md +++ /dev/null @@ -1,110 +0,0 @@ - - - [x] Verify that the copilot-instructions.md file in the .github directory is created. - -- [x] Clarify Project Requirements - - -- [x] Scaffold the Project - - -- [ ] Customize the Project - - -- [ ] Install Required Extensions - - - - [x] Compile the Project - - - - [x] Create and Run Task - - - - [x] Launch the Project - - -- [ ] Ensure Documentation is Complete - - - -- Work through each checklist item systematically. -- Keep communication concise and focused. -- Follow development best practices. diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 8f475e40..a4b0518a 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -71,7 +71,6 @@ "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "dev": true, - "peer": true, "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", @@ -497,7 +496,6 @@ "resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-svg-core/-/fontawesome-svg-core-6.7.2.tgz", "integrity": "sha512-yxtOBWDrdi5DD5o1pmVdq3WMCvnobT0LU6R8RyyVXPvFRd2o79/0NCuQoCjNTeZz9EzA9xS3JxNWfv54RIHFEA==", "license": "MIT", - "peer": true, "dependencies": { "@fortawesome/fontawesome-common-types": "6.7.2" }, @@ -1602,7 +1600,6 @@ "version": "19.2.14", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", - "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -1676,7 +1673,6 @@ "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.56.0.tgz", "integrity": "sha512-IgSWvLobTDOjnaxAfDTIHaECbkNlAlKv2j5SjpB2v7QHKv1FIfjwMy8FsDbVfDX/KjmCmYICcw7uGaXLhtsLNg==", "dev": true, - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.56.0", "@typescript-eslint/types": "8.56.0", @@ -2157,7 +2153,6 @@ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -2475,7 +2470,6 @@ "url": "https://github.com/sponsors/ai" } ], - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -3078,7 +3072,6 @@ "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.2.tgz", "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", "dev": true, - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -3256,7 +3249,6 @@ "resolved": "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.32.0.tgz", "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", "dev": true, - "peer": true, "dependencies": { "@rtsao/scc": "^1.1.0", "array-includes": "^3.1.9", @@ -5372,7 +5364,6 @@ "version": "19.2.3", "resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz", "integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -5409,7 +5400,6 @@ "version": "19.2.3", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.3.tgz", "integrity": "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==", - "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -6090,7 +6080,6 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, - "peer": true, "engines": { "node": ">=12" }, @@ -6242,7 +6231,6 @@ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -6527,7 +6515,6 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", "dev": true, - "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/frontend/src/app/employees/[id]/page.tsx b/frontend/src/app/employees/[id]/page.tsx index 08fca45a..f3d763af 100644 --- a/frontend/src/app/employees/[id]/page.tsx +++ b/frontend/src/app/employees/[id]/page.tsx @@ -6,25 +6,9 @@ import Link from "next/link"; import Image from "next/image"; import { AppShell } from "../../../components/AppShell"; import { apiClient } from "@/lib/api"; +import { resolveMediaUrl } from "@/lib/url"; import type { User } from "@/types/api"; -const BACKEND_URL = (process.env.NEXT_PUBLIC_BACKEND_URL || "").trim(); - -function resolveMediaUrl(url?: string | null): string { - const raw = (url || "").trim(); - if (!raw) return ""; - if (raw.startsWith("data:")) return raw; - if (/^https?:\/\//i.test(raw)) return raw; - if (raw.startsWith("/")) return raw; - - if (BACKEND_URL) { - const base = BACKEND_URL.replace(/\/$/, ""); - return `${base}/${raw.replace(/^\/+/, "")}`; - } - - return `/${raw.replace(/^\/+/, "")}`; -} - export default function EmployeeDetailPage() { const params = useParams<{ id: string }>(); const employeeId = Number(params?.id); diff --git a/frontend/src/app/equipment/page.tsx b/frontend/src/app/equipment/page.tsx new file mode 100644 index 00000000..3ed19ac0 --- /dev/null +++ b/frontend/src/app/equipment/page.tsx @@ -0,0 +1,825 @@ +"use client"; + +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 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"; + +/* ──── form state ──── */ +type EquipmentFormState = { + name: string; + description: string; + category: number | null; + department: number | null; + assigned_to: number | null; + serial_number: string; + purchase_date: string; + purchase_cost: string; + location: string; + attachment: File | null; +}; + +const emptyForm: EquipmentFormState = { + name: "", + description: "", + category: null, + department: null, + assigned_to: null, + serial_number: "", + purchase_date: "", + purchase_cost: "", + location: "", + attachment: null, +}; + +/* ──── 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 defaultStatusMeta = { label: "—", className: "bg-gray-50 text-gray-700 ring-gray-200" }; + +function formatDate(value?: string | null): string { + if (!value) return ""; + const date = new Date(value); + if (Number.isNaN(date.getTime())) return ""; + return date.toLocaleDateString("ru-RU", { day: "2-digit", month: "2-digit", year: "numeric" }); +} + +/* ──── main page ──── */ +export default function EquipmentPage() { + const { user } = useUser(); + const [items, setItems] = useState([]); + const [employees, setEmployees] = useState([]); + const [departments, setDepartments] = useState([]); + const [categories, setCategories] = useState([]); + const [commentsMap, setCommentsMap] = useState>({}); + const [expandedComments, setExpandedComments] = useState>({}); + const [commentDrafts, setCommentDrafts] = useState>({}); + + const [createOpen, setCreateOpen] = useState(false); + const [editingId, setEditingId] = useState(null); + const [form, setForm] = useState(emptyForm); + + const [searchQuery, setSearch] = useState(""); + const [employeeFilter, setEmployeeFilter] = 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); + 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 = canManageRequests(user); + + /* ──── helpers ──── */ + const displayUserName = (person?: User | null) => { + if (!person) return "—"; + const full = `${person.last_name || ""} ${person.first_name || ""}`.trim(); + 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 { + const parsed = new URL(nextUrl, window.location.origin); + const num = Number(parsed.searchParams.get("page")); + return Number.isFinite(num) && num > 0 ? num : null; + } catch { + return null; + } + }; + + 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; + 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 || "—"; + }; + + 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 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; + } + return null; + }; + + /* ──── load data ──── */ + useEffect(() => { + (async () => { + try { + setLoading(true); + setError(null); + const res = await apiClient.getEquipment(buildParams(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); + } + })(); + }, [employeeFilter, dateFromFilter, dateToFilter]); + + useEffect(() => { + async function loadAllPages(fetcher: (params: any) => Promise): Promise { + const all: T[] = []; + let page = 1; + while (true) { + const res = await fetcher({ page, limit: 200 }); + const results = Array.isArray(res) ? res : (res.results || []); + all.push(...results); + if (Array.isArray(res) || !res.next) break; + page++; + } + return all; + } + + (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); + } + })(); + }, []); + + /* ──── filtered ──── */ + 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()); + if (!q) return sorted; + return sorted.filter((item) => { + const name = (item.name || "").toLowerCase(); + const desc = (item.description || "").toLowerCase(); + const sn = (item.serial_number || "").toLowerCase(); + const assigned = displayUserName(getAssignedUser(item)).toLowerCase(); + const cat = getCategoryName(item).toLowerCase(); + return name.includes(q) || desc.includes(q) || sn.includes(q) || assigned.includes(q) || cat.includes(q); + }); + }, [items, searchQuery, employees, categories, departments]); + + /* ──── form ──── */ + const resetForm = () => setForm(emptyForm); + + const openEdit = (eq: Equipment) => { + setEditingId(eq.id); + setCreateOpen(false); + setActionError(null); + setActionSuccess(null); + setForm({ + name: eq.name || "", + description: eq.description || "", + category: typeof eq.category === "object" ? eq.category?.id ?? null : 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, + serial_number: eq.serial_number || "", + purchase_date: eq.purchase_date || "", + purchase_cost: String(eq.purchase_cost || ""), + location: eq.location || "", + attachment: null, + }); + }; + + 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, + description: form.description, + 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.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); + setActionSuccess("Оборудование добавлено."); + setCreateOpen(false); + } else if (editingId) { + await apiClient.updateEquipment(editingId, payload); + setActionSuccess("Оборудование обновлено."); + setEditingId(null); + } + + resetForm(); + const res = await apiClient.getEquipment(buildParams(1)); + const results = Array.isArray(res) ? res : (res.results || []); + setItems(results); + setNextPage(extractNextPage(res.next)); + } 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 apiClient.getEquipment(buildParams(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 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 fileInputRef = useRef(null); + + /* ──── SearchableSelect (single) ──── */ + const SearchableSelectSingle = ({ label, items: selectItems, selectedId, onSelect, placeholder }: { + label: string; + items: { id: number; name: string }[]; + selectedId: number | null; + onSelect: (id: number | null) => void; + placeholder?: string; + }) => { + const [open, setOpen] = useState(false); + const [q, setQ] = useState(""); + const ref = useRef(null); + + useEffect(() => { + const handler = (e: MouseEvent) => { + if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false); + }; + document.addEventListener("mousedown", handler); + return () => document.removeEventListener("mousedown", handler); + }, []); + + const filtered = selectItems.filter((i) => i.name.toLowerCase().includes(q.toLowerCase())); + const selectedName = selectItems.find((i) => i.id === selectedId)?.name; + + return ( +
+ + + {open && ( +
+
+ setQ(e.target.value)} placeholder="Поиск..." className="w-full rounded border border-gray-200 px-2 py-1.5 text-sm focus:border-sky-400 focus:outline-none" autoFocus /> +
+
+ {selectedId && ( + + )} + {filtered.length === 0 ? ( +

Ничего не найдено

+ ) : ( + filtered.map((item) => ( + + )) + )} +
+
+ )} +
+ ); + }; + + const modalMode: "create" | "edit" = editingId ? "edit" : "create"; + const isModalOpen = createOpen || editingId !== null; + + const closeModal = () => { + setCreateOpen(false); + setEditingId(null); + resetForm(); + setActionError(null); + }; + + const activeFilterCount = [employeeFilter, dateFromFilter, dateToFilter].filter(Boolean).length; + + /* ──── render ──── */ + return ( + + {loading ? ( +
+
+

Загрузка оборудования...

+
+ ) : error ? ( +
+

{error}

+
+ ) : ( +
+ {/* Header */} +
+

Оборудование

+ +
+ + {actionError &&

{actionError}

} + {actionSuccess &&

{actionSuccess}

} + + {/* Search + filter toggle */} +
+
+ + 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" + /> +
+ +
+ + {/* 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 && ( + + )} +
+ )} + + {/* Items list */} +
+ {filteredItems.length === 0 ? ( +
+ +

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

+
+ ) : ( + filteredItems.map((item) => { + const assignedUser = getAssignedUser(item); + const assignedName = displayUserName(assignedUser); + const assignedLink = userProfileLink(assignedUser); + 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 comments = commentsMap[item.id] || []; + const commentsOpen = Boolean(expandedComments[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} +
+ ) + ) : ( + Не назначено + )} +
+ {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" /> + +
+
+ )} +
+ ); + }) + )} +
+ + {/* Load more */} + {nextPage && ( +
+ +
+ )} +
+ )} + + {/* ===== Modal create/edit ===== */} + {isModalOpen && ( +
{ if (e.target === e.currentTarget) closeModal(); }}> +
+
+

{modalMode === "create" ? "Добавить оборудование" : "Редактировать оборудование"}

+ +
+ + {actionError &&

{actionError}

} + +
+ {/* Название */} +
+ + setForm((p) => ({ ...p, name: e.target.value }))} + placeholder="Ноутбук Lenovo ThinkPad X1..." + 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" + /> +
+ + {/* Категория */} + ({ id: c.id, name: c.name }))} + selectedId={form.category} + onSelect={(id) => setForm((p) => ({ ...p, category: id }))} + /> + + {/* Отдел */} + ({ id: d.id, name: d.name }))} + selectedId={form.department} + onSelect={(id) => setForm((p) => ({ ...p, department: id }))} + /> + + {/* Кому выдано */} + ({ id: emp.id, name: displayUserName(emp) }))} + selectedId={form.assigned_to} + onSelect={(id) => setForm((p) => ({ ...p, assigned_to: id }))} + /> + + {/* Дата покупки + стоимость */} +
+
+ + setForm((p) => ({ ...p, purchase_date: e.target.value }))} + 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" + /> +
+
+ + setForm((p) => ({ ...p, purchase_cost: e.target.value }))} + placeholder="0.00" + 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" + /> +
+
+ + {/* Серийный номер */} +
+ + setForm((p) => ({ ...p, serial_number: e.target.value }))} + placeholder="SN-XXXXX" + 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" + /> +
+ + {/* Расположение */} +
+ + setForm((p) => ({ ...p, location: e.target.value }))} + placeholder="Офис 305, стол 2..." + 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" + /> +
+ + {/* Описание */} +
+ +