From 19f6f660b0b2ded70827c91d9bddd7a8d27d9acb Mon Sep 17 00:00:00 2001 From: lizerk Date: Wed, 1 Apr 2026 16:34:04 +0300 Subject: [PATCH] refactor: extract procurement page orchestration into hook - add useProcurementPage hook for loading, filters, modal state and workflow actions - keep procurement page focused on rendering and section layout - preserve existing UI behavior while removing API/state orchestration from page component - frontend checks: eslint clean, TypeScript typecheck clean - backend checks: realtime websocket smoke, procurement and equipment suites green --- frontend/src/app/procurement/page.tsx | 467 +++--------------- frontend/src/hooks/useProcurementPage.ts | 575 +++++++++++++++++++++++ 2 files changed, 639 insertions(+), 403 deletions(-) create mode 100644 frontend/src/hooks/useProcurementPage.ts diff --git a/frontend/src/app/procurement/page.tsx b/frontend/src/app/procurement/page.tsx index 0e17a068..a405f68e 100644 --- a/frontend/src/app/procurement/page.tsx +++ b/frontend/src/app/procurement/page.tsx @@ -1,19 +1,11 @@ "use client"; import { AppShell } from "../../components/AppShell"; -import { apiClient } from "@/lib/api"; import { useUser } from "@/contexts/UserContext"; -import { canManageRequests, canManageSupplier } from "@/lib/permissions"; -import { useEffect, useMemo, useRef, useState, useCallback } from "react"; import Link from "next/link"; import ProcurementStatsPanel from "@/components/procurement/ProcurementStatsPanel"; import ProcurementSuppliersPanel from "@/components/procurement/ProcurementSuppliersPanel"; -import type { - ProcurementRequest, - User, - Department, - UrgencyLevel, -} from "@/types/api"; +import type { UrgencyLevel } from "@/types/api"; import { ArrowUpDown, Check, @@ -36,7 +28,8 @@ import { XCircle, } from "lucide-react"; import { SearchableSelectSingle } from "@/components/shared/SearchableSelect"; -import { formatDate, formatMoney, displayUserName as sharedDisplayUserName, extractNextPage as sharedExtractNextPage, loadAllPages } from "@/lib/shared"; +import { formatDate, formatMoney } from "@/lib/shared"; +import { useProcurementPage } from "@/hooks/useProcurementPage"; /* ══════════════════════════════════════════════════════ Constants & helpers @@ -89,405 +82,73 @@ const periodOptions = [ const fmt = formatDate; const money = formatMoney; -/* ══════════════════════════════════════════════════════ - Item row (позиция заявки) — for inline editing - ══════════════════════════════════════════════════════ */ - -type ItemDraft = { - name: string; - description: string; - quantity: string; - unit: string; - estimated_unit_price: string; - supplier_info: string; -}; - -const emptyItem: ItemDraft = { name: "", description: "", quantity: "1", unit: "шт", estimated_unit_price: "", supplier_info: "" }; - -/* ══════════════════════════════════════════════════════ - Form state - ══════════════════════════════════════════════════════ */ - -type FormState = { - title: string; - description: string; - department: number | null; - urgency: UrgencyLevel; - items: ItemDraft[]; -}; - -const emptyForm: FormState = { - title: "", - description: "", - department: null, - urgency: "medium", - items: [{ ...emptyItem }], -}; - -type ProcurementSection = "requests" | "stats" | "suppliers"; - /* ══════════════════════════════════════════════════════ Main page component ══════════════════════════════════════════════════════ */ export default function ProcurementPage() { const { user } = useUser(); - const canManage = canManageRequests(user); - const canSupplierManage = canManageSupplier(user); - - /* ── data ── */ - const [requests, setRequests] = useState([]); - const [departments, setDepartments] = useState([]); - - /* ── UI flags ── */ - 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); - - /* ── scope / filters ── */ - const [scope, setScope] = useState("all"); - const [searchQuery, setSearchQuery] = useState(""); - const [statusFilter, setStatusFilter] = useState(""); - const [urgencyFilter, setUrgencyFilter] = useState(""); - const [departmentFilter, setDepartmentFilter] = useState(""); - const [periodFilter, setPeriodFilter] = useState(""); - const [filtersOpen, setFiltersOpen] = useState(false); - const [ordering, setOrdering] = useState("-created_at"); - const [activeSection, setActiveSection] = useState("requests"); - - /* ── form modal ── */ - const [createOpen, setCreateOpen] = useState(false); - const [editingId, setEditingId] = useState(null); - const [form, setForm] = useState(emptyForm); - - /* ── expanded details ── */ - const [expandedIds, setExpandedIds] = useState>(new Set()); - const [detailsCache, setDetailsCache] = useState>({}); - - /* ══════════ helpers ══════════ */ - - const resolveUserId = (person?: User | number | null) => { - if (!person) return null; - return typeof person === "number" ? person : person.id; - }; - - const displayUserName = (person?: User | number | null, fallbackName?: string | null, fallbackEmail?: string | null) => sharedDisplayUserName(person, fallbackName, fallbackEmail); - - const userLink = (person?: User | number | null) => { - const personId = resolveUserId(person); - if (!personId) return ""; - return user?.id && personId === user.id ? "/profile" : `/users/${personId}`; - }; - - const extractNextPage = sharedExtractNextPage; - - const getDeptName = (req: ProcurementRequest) => { - if (req.department_name) return req.department_name; - if (req.department_details?.name) return req.department_details.name; - const d = departments.find((dep) => dep.id === Number(req.department)); - return d?.name || "—"; - }; - - const getRequestAmount = (req: ProcurementRequest) => req.total_cost ?? req.total_estimated_cost; - - /* ══════════ data loading ══════════ */ - - const buildParams = useCallback((page: number): Record => { - const p: Record = { page }; - if (scope === "mine") p.scope = "mine"; - else if (scope === "department") p.scope = "department"; - else if (scope === "my_work") p.scope = "my_work"; - else if (scope === "available") p.scope = "available"; - if (statusFilter) p.status = statusFilter; - if (urgencyFilter) p.urgency = urgencyFilter; - if (departmentFilter) p.department = departmentFilter; - if (periodFilter) p.period = periodFilter; - if (searchQuery.trim()) p.search = searchQuery.trim(); - return p; - }, [scope, statusFilter, urgencyFilter, departmentFilter, periodFilter, searchQuery]); - - const loadPage1 = useCallback(async () => { - try { - setLoading(true); - setError(null); - - let res: any; - if (scope === "pending_approvals") { - res = await apiClient.getPendingApprovals(buildParams(1)); - } else { - res = await apiClient.getProcurementRequests(buildParams(1)); - } - const results = Array.isArray(res) ? res : (res.results || []); - setRequests(results); - setNextPage(extractNextPage(res.next)); - } catch (e: any) { - console.error("Load procurement error:", e); - setError("Не удалось загрузить заявки на закупку"); - } finally { - setLoading(false); - } - }, [buildParams, scope]); - - useEffect(() => { loadPage1(); }, [loadPage1]); - - // Load departments once - useEffect(() => { - (async () => { - try { - const all = await loadAllPages((p) => apiClient.getDepartments(p)); - setDepartments(all); - } catch { /* silent */ } - })(); - }, []); - - /* ══════════ load more ══════════ */ - - const handleLoadMore = async () => { - if (!nextPage || loadingMore) return; - try { - setLoadingMore(true); - let res: any; - if (scope === "pending_approvals") { - res = await apiClient.getPendingApprovals(buildParams(nextPage)); - } else { - res = await apiClient.getProcurementRequests(buildParams(nextPage)); - } - const chunk = Array.isArray(res) ? res : (res.results || []); - setRequests((prev) => { - const known = new Set(prev.map((r) => r.id)); - return [...prev, ...chunk.filter((r: ProcurementRequest) => !known.has(r.id))]; - }); - setNextPage(extractNextPage(res.next)); - } catch { - setError("Не удалось загрузить ещё"); - } finally { - setLoadingMore(false); - } - }; - - /* ══════════ expand detail ══════════ */ - - const toggleExpand = async (id: number) => { - setExpandedIds((prev) => { - const next = new Set(prev); - if (next.has(id)) { next.delete(id); } else { next.add(id); } - return next; - }); - if (!detailsCache[id]) { - try { - const detail = await apiClient.getProcurementRequest(id); - setDetailsCache((p) => ({ ...p, [id]: detail })); - } catch { /* silent */ } - } - }; - - /* ══════════ client-side filter ══════════ */ - - const filteredRequests = useMemo(() => { - const urgencyRank: Record = { - low: 1, - medium: 2, - high: 3, - critical: 4, - }; - - return [...requests].sort((a, b) => { - switch (ordering) { - case "created_at": - return (new Date(a.created_at).getTime() || 0) - (new Date(b.created_at).getTime() || 0); - case "title": - return String(a.title || "").localeCompare(String(b.title || ""), "ru", { sensitivity: "base" }); - case "urgency": - return (urgencyRank[a.urgency || "medium"] || 0) - (urgencyRank[b.urgency || "medium"] || 0); - case "-urgency": - return (urgencyRank[b.urgency || "medium"] || 0) - (urgencyRank[a.urgency || "medium"] || 0); - case "-created_at": - default: - return (new Date(b.created_at).getTime() || 0) - (new Date(a.created_at).getTime() || 0); - } - }); - }, [ordering, requests]); - - /* ══════════ form ══════════ */ - - const resetForm = () => setForm({ ...emptyForm, items: [{ ...emptyItem }] }); - - const openCreate = () => { - setEditingId(null); - resetForm(); - setActionError(null); - setActionSuccess(null); - setCreateOpen(true); - }; - - const openEdit = (req: ProcurementRequest) => { - setCreateOpen(false); - setEditingId(req.id); - setActionError(null); - setActionSuccess(null); - const detail = detailsCache[req.id] || req; - setForm({ - title: detail.title || "", - description: detail.description || "", - department: detail.department ?? null, - urgency: detail.urgency || "medium", - items: (detail.items && detail.items.length > 0) - ? detail.items.map((it) => ({ - name: it.name || "", - description: it.description || "", - quantity: String(it.quantity || "1"), - unit: it.unit || "шт", - estimated_unit_price: String(it.estimated_unit_price || ""), - supplier_info: it.supplier_info || "", - })) - : [{ ...emptyItem }], - }); - }; - - const closeModal = () => { - setCreateOpen(false); - setEditingId(null); - resetForm(); - setActionError(null); - }; - - const modalMode: "create" | "edit" = editingId ? "edit" : "create"; - const isModalOpen = createOpen || editingId !== null; - - /* ── save ── */ - const handleSave = async () => { - try { - setBusyKey("save"); - setActionError(null); - - if (!form.title.trim()) { setActionError("Укажите название заявки."); return; } - if (!form.description.trim()) { setActionError("Укажите описание и обоснование."); return; } - if (!form.department) { setActionError("Выберите отдел."); return; } - - // Filter out empty item rows - const validItems = form.items.filter((it) => it.name.trim()); - if (validItems.length === 0) { setActionError("Добавьте хотя бы одну позицию."); return; } - for (const it of validItems) { - if (!it.quantity || Number(it.quantity) <= 0) { setActionError(`Позиция «${it.name}»: укажите количество.`); return; } - if (!it.estimated_unit_price || Number(it.estimated_unit_price) <= 0) { setActionError(`Позиция «${it.name}»: укажите цену за единицу.`); return; } - } - - const payload: any = { - title: form.title, - description: form.description, - department: form.department, - urgency: form.urgency, - items: validItems.map((it) => ({ - name: it.name, - description: it.description || undefined, - quantity: it.quantity, - unit: it.unit || "шт", - estimated_unit_price: it.estimated_unit_price, - supplier_info: it.supplier_info || undefined, - })), - }; - - if (modalMode === "create") { - await apiClient.createProcurementRequest(payload); - setActionSuccess("Заявка создана (черновик)."); - setCreateOpen(false); - } else if (editingId) { - await apiClient.updateProcurementRequest(editingId, { - title: payload.title, - description: payload.description, - urgency: payload.urgency, - }); - setActionSuccess("Заявка обновлена."); - setEditingId(null); - } - - resetForm(); - await loadPage1(); - } catch (e: any) { - const raw = String(e?.message || "Ошибка сохранения"); - let readable = raw; - try { - const parsed = JSON.parse(raw); - readable = Object.entries(parsed).map(([k, v]) => `${k}: ${Array.isArray(v) ? v.join(", ") : v}`).join(". "); - } catch { /* keep raw */ } - setActionError(readable); - } finally { - setBusyKey(null); - } - }; - - /* ══════════ workflow actions ══════════ */ - - const refreshOne = async (id: number) => { - try { - const updated = await apiClient.getProcurementRequest(id); - setRequests((prev) => prev.map((r) => (r.id === id ? updated : r))); - setDetailsCache((p) => ({ ...p, [id]: updated })); - } catch { /* reload all */ await loadPage1(); } - }; - - const doAction = async (key: string, fn: () => Promise, id: number, successMsg: string) => { - try { - setBusyKey(key); - setActionError(null); - await fn(); - setActionSuccess(successMsg); - await refreshOne(id); - } catch (e: any) { - const raw = String(e?.message || "Ошибка"); - let readable = raw; - try { const p = JSON.parse(raw); readable = typeof p === "object" ? Object.entries(p).map(([k, v]) => `${k}: ${Array.isArray(v) ? v.join(", ") : v}`).join(". ") : raw; } catch {} - setActionError(readable); - } finally { - setBusyKey(null); - } - }; - - const handleSubmit = (id: number) => doAction(`submit-${id}`, () => apiClient.submitProcurementRequest(id), id, "Заявка отправлена на согласование."); - const handleApprove = (id: number) => { - const comment = window.prompt("Комментарий к одобрению (необязательно)", ""); - if (comment === null) return; - return doAction(`approve-${id}`, () => apiClient.approveProcurementRequest(id, comment), id, "Заявка одобрена."); - }; - const handleReject = (id: number) => { - const comment = window.prompt("Комментарий к отклонению", ""); - if (comment === null) return; - return doAction(`reject-${id}`, () => apiClient.rejectProcurementRequest(id, comment), id, "Заявка отклонена."); - }; - const handleStart = (id: number) => doAction(`start-${id}`, () => apiClient.startWorkProcurementRequest(id), id, "Вы взяли заявку в работу."); - const handleComplete= (id: number) => doAction(`complete-${id}`,() => apiClient.completeProcurementRequest(id), id, "Заявка завершена."); - const handleCancel = (id: number) => { - const reason = window.prompt("Причина отмены", ""); - if (reason === null) return; - return doAction(`cancel-${id}`, () => apiClient.cancelProcurementRequest(id, reason), id, "Заявка отменена."); - }; - - const handleDelete = async (id: number) => { - if (!confirm("Удалить эту заявку? Доступно только для черновиков.")) return; - try { - setBusyKey(`delete-${id}`); - await apiClient.deleteProcurementRequest(id); - setRequests((p) => p.filter((r) => r.id !== id)); - } catch (e: any) { - setActionError(String(e?.message || "Не удалось удалить")); - } finally { - setBusyKey(null); - } - }; - - /* ══════════ form helpers ══════════ */ - - const addItemRow = () => setForm((f) => ({ ...f, items: [...f.items, { ...emptyItem }] })); - const removeItemRow = (idx: number) => setForm((f) => ({ ...f, items: f.items.filter((_, i) => i !== idx) })); - const updateItemRow = (idx: number, patch: Partial) => - setForm((f) => ({ ...f, items: f.items.map((it, i) => (i === idx ? { ...it, ...patch } : it)) })); - - const activeFilterCount = [statusFilter, urgencyFilter, departmentFilter, periodFilter].filter(Boolean).length; - const isFinal = (s?: string) => ["completed", "rejected", "cancelled"].includes(String(s || "").toLowerCase()); + const { + activeFilterCount, + activeSection, + actionError, + actionSuccess, + addItemRow, + busyKey, + canManage, + canSupplierManage, + closeModal, + departmentFilter, + departments, + detailsCache, + displayUserName, + error, + expandedIds, + filteredRequests, + filtersOpen, + form, + getDeptName, + getRequestAmount, + handleApprove, + handleCancel, + handleComplete, + handleDelete, + handleLoadMore, + handleReject, + handleSave, + handleStart, + handleSubmit, + isFinal, + isModalOpen, + loadPage1, + loading, + loadingMore, + modalMode, + nextPage, + openCreate, + openEdit, + ordering, + periodFilter, + removeItemRow, + resolveUserId, + searchQuery, + setActiveSection, + setDepartmentFilter, + setFiltersOpen, + setForm, + setOrdering, + setPeriodFilter, + setScope, + setSearchQuery, + setStatusFilter, + setUrgencyFilter, + statusFilter, + toggleExpand, + updateItemRow, + urgencyFilter, + userLink, + scope, + } = useProcurementPage(user); /* ══════════════════════════════════════════════════════ RENDER diff --git a/frontend/src/hooks/useProcurementPage.ts b/frontend/src/hooks/useProcurementPage.ts new file mode 100644 index 00000000..054ae28e --- /dev/null +++ b/frontend/src/hooks/useProcurementPage.ts @@ -0,0 +1,575 @@ +"use client"; + +import { useCallback, useEffect, useMemo, useState } from "react"; +import { apiClient } from "@/lib/api"; +import { canManageRequests, canManageSupplier } from "@/lib/permissions"; +import { displayUserName, extractNextPage, loadAllPages } from "@/lib/shared"; +import type { + Department, + ProcurementRequest, + UrgencyLevel, + User, +} from "@/types/api"; + +type ItemDraft = { + name: string; + description: string; + quantity: string; + unit: string; + estimated_unit_price: string; + supplier_info: string; +}; + +const emptyItem: ItemDraft = { + name: "", + description: "", + quantity: "1", + unit: "шт", + estimated_unit_price: "", + supplier_info: "", +}; + +type FormState = { + title: string; + description: string; + department: number | null; + urgency: UrgencyLevel; + items: ItemDraft[]; +}; + +const emptyForm: FormState = { + title: "", + description: "", + department: null, + urgency: "medium", + items: [{ ...emptyItem }], +}; + +type ScopeTab = "all" | "mine" | "department" | "pending_approvals" | "my_work" | "available"; +type ProcurementSection = "requests" | "stats" | "suppliers"; + +type PaginatedLike = { + results?: T[]; + next?: string | null; +}; + +const getPaginatedResults = (response: unknown): T[] => { + if (Array.isArray(response)) { + return response as T[]; + } + + if (response && typeof response === "object") { + const results = (response as PaginatedLike).results; + if (Array.isArray(results)) { + return results; + } + } + + return []; +}; + +const getPaginatedNext = (response: unknown): number | null => { + if (!response || typeof response !== "object" || Array.isArray(response)) { + return null; + } + + return extractNextPage((response as PaginatedLike).next ?? null); +}; + +const getReadableError = (error: unknown, fallback: string): string => { + const raw = String((error as Error)?.message || fallback); + try { + const parsed = JSON.parse(raw) as Record; + if (typeof parsed === "object" && parsed !== null) { + return Object.entries(parsed) + .map(([key, value]) => { + if (Array.isArray(value)) { + return `${key}: ${value.join(", ")}`; + } + return `${key}: ${String(value)}`; + }) + .join(". "); + } + } catch { + return raw; + } + + return raw; +}; + +export function useProcurementPage(user: User | null) { + const canManage = canManageRequests(user); + const canSupplierManage = canManageSupplier(user); + + const [requests, setRequests] = useState([]); + const [departments, setDepartments] = useState([]); + 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 [scope, setScope] = useState("all"); + const [searchQuery, setSearchQuery] = useState(""); + const [statusFilter, setStatusFilter] = useState(""); + const [urgencyFilter, setUrgencyFilter] = useState(""); + const [departmentFilter, setDepartmentFilter] = useState(""); + const [periodFilter, setPeriodFilter] = useState(""); + const [filtersOpen, setFiltersOpen] = useState(false); + const [ordering, setOrdering] = useState("-created_at"); + const [activeSection, setActiveSection] = useState("requests"); + + const [createOpen, setCreateOpen] = useState(false); + const [editingId, setEditingId] = useState(null); + const [form, setForm] = useState(emptyForm); + + const [expandedIds, setExpandedIds] = useState>(new Set()); + const [detailsCache, setDetailsCache] = useState>({}); + + const resolveUserId = useCallback((person?: User | number | null) => { + if (!person) return null; + return typeof person === "number" ? person : person.id; + }, []); + + const displayProcurementUserName = useCallback( + (person?: User | number | null, fallbackName?: string | null, fallbackEmail?: string | null) => ( + displayUserName(person, fallbackName, fallbackEmail) + ), + [], + ); + + const userLink = useCallback( + (person?: User | number | null) => { + const personId = resolveUserId(person); + if (!personId) return ""; + return user?.id && personId === user.id ? "/profile" : `/users/${personId}`; + }, + [resolveUserId, user?.id], + ); + + const getDeptName = useCallback( + (request: ProcurementRequest) => { + if (request.department_name) return request.department_name; + if (request.department_details?.name) return request.department_details.name; + const department = departments.find((item) => item.id === Number(request.department)); + return department?.name || "—"; + }, + [departments], + ); + + const getRequestAmount = useCallback( + (request: ProcurementRequest) => request.total_cost ?? request.total_estimated_cost, + [], + ); + + const buildParams = useCallback( + (page: number): Record => { + const params: Record = { page }; + if (scope === "mine") params.scope = "mine"; + else if (scope === "department") params.scope = "department"; + else if (scope === "my_work") params.scope = "my_work"; + else if (scope === "available") params.scope = "available"; + if (statusFilter) params.status = statusFilter; + if (urgencyFilter) params.urgency = urgencyFilter; + if (departmentFilter) params.department = departmentFilter; + if (periodFilter) params.period = periodFilter; + if (searchQuery.trim()) params.search = searchQuery.trim(); + return params; + }, + [departmentFilter, periodFilter, scope, searchQuery, statusFilter, urgencyFilter], + ); + + const loadPage1 = useCallback(async () => { + try { + setLoading(true); + setError(null); + + const response: unknown = scope === "pending_approvals" + ? await apiClient.getPendingApprovals(buildParams(1)) + : await apiClient.getProcurementRequests(buildParams(1)); + + setRequests(getPaginatedResults(response)); + setNextPage(getPaginatedNext(response)); + } catch (loadError) { + console.error("Load procurement error:", loadError); + setError("Не удалось загрузить заявки на закупку"); + } finally { + setLoading(false); + } + }, [buildParams, scope]); + + useEffect(() => { + void loadPage1(); + }, [loadPage1]); + + useEffect(() => { + let cancelled = false; + + void (async () => { + try { + const allDepartments = await loadAllPages((page) => apiClient.getDepartments(page)); + if (!cancelled) { + setDepartments(allDepartments); + } + } catch { + if (!cancelled) { + setDepartments([]); + } + } + })(); + + return () => { + cancelled = true; + }; + }, []); + + const filteredRequests = useMemo(() => { + const urgencyRank: Record = { + low: 1, + medium: 2, + high: 3, + critical: 4, + }; + + return [...requests].sort((left, right) => { + switch (ordering) { + case "created_at": + return (new Date(left.created_at).getTime() || 0) - (new Date(right.created_at).getTime() || 0); + case "title": + return String(left.title || "").localeCompare(String(right.title || ""), "ru", { sensitivity: "base" }); + case "urgency": + return (urgencyRank[left.urgency || "medium"] || 0) - (urgencyRank[right.urgency || "medium"] || 0); + case "-urgency": + return (urgencyRank[right.urgency || "medium"] || 0) - (urgencyRank[left.urgency || "medium"] || 0); + case "-created_at": + default: + return (new Date(right.created_at).getTime() || 0) - (new Date(left.created_at).getTime() || 0); + } + }); + }, [ordering, requests]); + + const resetForm = useCallback(() => { + setForm({ ...emptyForm, items: [{ ...emptyItem }] }); + }, []); + + const openCreate = useCallback(() => { + setEditingId(null); + resetForm(); + setActionError(null); + setActionSuccess(null); + setCreateOpen(true); + }, [resetForm]); + + const openEdit = useCallback((request: ProcurementRequest) => { + setCreateOpen(false); + setEditingId(request.id); + setActionError(null); + setActionSuccess(null); + + const detail = detailsCache[request.id] || request; + setForm({ + title: detail.title || "", + description: detail.description || "", + department: detail.department ?? null, + urgency: detail.urgency || "medium", + items: detail.items && detail.items.length > 0 + ? detail.items.map((item) => ({ + name: item.name || "", + description: item.description || "", + quantity: String(item.quantity || "1"), + unit: item.unit || "шт", + estimated_unit_price: String(item.estimated_unit_price || ""), + supplier_info: item.supplier_info || "", + })) + : [{ ...emptyItem }], + }); + }, [detailsCache]); + + const closeModal = useCallback(() => { + setCreateOpen(false); + setEditingId(null); + resetForm(); + setActionError(null); + }, [resetForm]); + + const modalMode: "create" | "edit" = editingId ? "edit" : "create"; + const isModalOpen = createOpen || editingId !== null; + + const refreshOne = useCallback(async (id: number) => { + try { + const updated = await apiClient.getProcurementRequest(id); + setRequests((previous) => previous.map((request) => (request.id === id ? updated : request))); + setDetailsCache((previous) => ({ ...previous, [id]: updated })); + } catch { + await loadPage1(); + } + }, [loadPage1]); + + const handleSave = useCallback(async () => { + try { + setBusyKey("save"); + setActionError(null); + + if (!form.title.trim()) { + setActionError("Укажите название заявки."); + return; + } + if (!form.description.trim()) { + setActionError("Укажите описание и обоснование."); + return; + } + if (!form.department) { + setActionError("Выберите отдел."); + return; + } + + const validItems = form.items.filter((item) => item.name.trim()); + if (validItems.length === 0) { + setActionError("Добавьте хотя бы одну позицию."); + return; + } + + for (const item of validItems) { + if (!item.quantity || Number(item.quantity) <= 0) { + setActionError(`Позиция «${item.name}»: укажите количество.`); + return; + } + if (!item.estimated_unit_price || Number(item.estimated_unit_price) <= 0) { + setActionError(`Позиция «${item.name}»: укажите цену за единицу.`); + return; + } + } + + const payload: Record = { + title: form.title, + description: form.description, + department: form.department, + urgency: form.urgency, + items: validItems.map((item) => ({ + name: item.name, + description: item.description || undefined, + quantity: item.quantity, + unit: item.unit || "шт", + estimated_unit_price: item.estimated_unit_price, + supplier_info: item.supplier_info || undefined, + })), + }; + + if (modalMode === "create") { + await apiClient.createProcurementRequest(payload); + setActionSuccess("Заявка создана (черновик)."); + setCreateOpen(false); + } else if (editingId) { + await apiClient.updateProcurementRequest(editingId, { + title: payload.title, + description: payload.description, + urgency: payload.urgency, + }); + setActionSuccess("Заявка обновлена."); + setEditingId(null); + } + + resetForm(); + await loadPage1(); + } catch (saveError) { + setActionError(getReadableError(saveError, "Ошибка сохранения")); + } finally { + setBusyKey(null); + } + }, [editingId, form, loadPage1, modalMode, resetForm]); + + const doAction = useCallback(async (key: string, action: () => Promise, id: number, successMessage: string) => { + try { + setBusyKey(key); + setActionError(null); + await action(); + setActionSuccess(successMessage); + await refreshOne(id); + } catch (actionErrorValue) { + setActionError(getReadableError(actionErrorValue, "Ошибка")); + } finally { + setBusyKey(null); + } + }, [refreshOne]); + + const handleLoadMore = useCallback(async () => { + if (!nextPage || loadingMore) return; + + try { + setLoadingMore(true); + const response: unknown = scope === "pending_approvals" + ? await apiClient.getPendingApprovals(buildParams(nextPage)) + : await apiClient.getProcurementRequests(buildParams(nextPage)); + + const chunk = getPaginatedResults(response); + setRequests((previous) => { + const known = new Set(previous.map((request) => request.id)); + return [...previous, ...chunk.filter((request) => !known.has(request.id))]; + }); + setNextPage(getPaginatedNext(response)); + } catch { + setError("Не удалось загрузить ещё"); + } finally { + setLoadingMore(false); + } + }, [buildParams, loadingMore, nextPage, scope]); + + const toggleExpand = useCallback(async (id: number) => { + setExpandedIds((previous) => { + const next = new Set(previous); + if (next.has(id)) { + next.delete(id); + } else { + next.add(id); + } + return next; + }); + + if (!detailsCache[id]) { + try { + const detail = await apiClient.getProcurementRequest(id); + setDetailsCache((previous) => ({ ...previous, [id]: detail })); + } catch { + // ignore detail loading failures for collapsed rows + } + } + }, [detailsCache]); + + const handleSubmit = useCallback((id: number) => doAction( + `submit-${id}`, + () => apiClient.submitProcurementRequest(id), + id, + "Заявка отправлена на согласование.", + ), [doAction]); + + const handleApprove = useCallback((id: number) => { + const comment = window.prompt("Комментарий к одобрению (необязательно)", ""); + if (comment === null) return; + return doAction(`approve-${id}`, () => apiClient.approveProcurementRequest(id, comment), id, "Заявка одобрена."); + }, [doAction]); + + const handleReject = useCallback((id: number) => { + const comment = window.prompt("Комментарий к отклонению", ""); + if (comment === null) return; + return doAction(`reject-${id}`, () => apiClient.rejectProcurementRequest(id, comment), id, "Заявка отклонена."); + }, [doAction]); + + const handleStart = useCallback((id: number) => doAction( + `start-${id}`, + () => apiClient.startWorkProcurementRequest(id), + id, + "Вы взяли заявку в работу.", + ), [doAction]); + + const handleComplete = useCallback((id: number) => doAction( + `complete-${id}`, + () => apiClient.completeProcurementRequest(id), + id, + "Заявка завершена.", + ), [doAction]); + + const handleCancel = useCallback((id: number) => { + const reason = window.prompt("Причина отмены", ""); + if (reason === null) return; + return doAction(`cancel-${id}`, () => apiClient.cancelProcurementRequest(id, reason), id, "Заявка отменена."); + }, [doAction]); + + const handleDelete = useCallback(async (id: number) => { + if (!window.confirm("Удалить эту заявку? Доступно только для черновиков.")) return; + + try { + setBusyKey(`delete-${id}`); + await apiClient.deleteProcurementRequest(id); + setRequests((previous) => previous.filter((request) => request.id !== id)); + } catch (deleteError) { + setActionError(getReadableError(deleteError, "Не удалось удалить")); + } finally { + setBusyKey(null); + } + }, []); + + const addItemRow = useCallback(() => { + setForm((previous) => ({ ...previous, items: [...previous.items, { ...emptyItem }] })); + }, []); + + const removeItemRow = useCallback((index: number) => { + setForm((previous) => ({ ...previous, items: previous.items.filter((_, itemIndex) => itemIndex !== index) })); + }, []); + + const updateItemRow = useCallback((index: number, patch: Partial) => { + setForm((previous) => ({ + ...previous, + items: previous.items.map((item, itemIndex) => (itemIndex === index ? { ...item, ...patch } : item)), + })); + }, []); + + const activeFilterCount = [statusFilter, urgencyFilter, departmentFilter, periodFilter].filter(Boolean).length; + const isFinal = useCallback((status?: string) => ["completed", "rejected", "cancelled"].includes(String(status || "").toLowerCase()), []); + + return { + canManage, + canSupplierManage, + requests, + departments, + loading, + loadingMore, + error, + actionError, + actionSuccess, + busyKey, + nextPage, + scope, + setScope, + searchQuery, + setSearchQuery, + statusFilter, + setStatusFilter, + urgencyFilter, + setUrgencyFilter, + departmentFilter, + setDepartmentFilter, + periodFilter, + setPeriodFilter, + filtersOpen, + setFiltersOpen, + ordering, + setOrdering, + activeSection, + setActiveSection, + createOpen, + editingId, + form, + setForm, + expandedIds, + detailsCache, + filteredRequests, + openCreate, + openEdit, + closeModal, + modalMode, + isModalOpen, + handleSave, + handleLoadMore, + toggleExpand, + handleSubmit, + handleApprove, + handleReject, + handleStart, + handleComplete, + handleCancel, + handleDelete, + addItemRow, + removeItemRow, + updateItemRow, + activeFilterCount, + isFinal, + resolveUserId, + displayUserName: displayProcurementUserName, + userLink, + getDeptName, + getRequestAmount, + loadPage1, + }; +} \ No newline at end of file