"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; count?: number; }; 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 getPaginatedCount = (response: unknown): number => { if (!response || typeof response !== "object" || Array.isArray(response)) { return 0; } const count = (response as PaginatedLike).count; return typeof count === "number" && Number.isFinite(count) ? count : getPaginatedResults(response).length; }; const getReadableError = (error: unknown, fallback: string): string => { const raw = String((error as Error)?.message || fallback); const jsonStart = raw.indexOf("{"); const payload = jsonStart >= 0 ? raw.slice(jsonStart) : raw; try { const parsed = JSON.parse(payload) as Record; if (typeof parsed === "object" && parsed !== null) { if (typeof parsed.error === "string" && parsed.error.trim()) { return parsed.error; } if (typeof parsed.detail === "string" && parsed.detail.trim()) { return parsed.detail; } 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 [scopeCounts, setScopeCounts] = useState>({ all: 0, mine: 0, department: 0, pending_approvals: 0, my_work: 0, available: 0, }); 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 buildScopeCountParams = useCallback( (targetScope: ScopeTab): Record => { const params: Record = { page: 1 }; if (targetScope === "mine") params.scope = "mine"; else if (targetScope === "department") params.scope = "department"; else if (targetScope === "my_work") params.scope = "my_work"; else if (targetScope === "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, 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 scopes: ScopeTab[] = ["all", "mine", "department", "pending_approvals", "my_work", "available"]; const results = await Promise.all( scopes.map(async (scopeKey) => { const response: unknown = scopeKey === "pending_approvals" ? await apiClient.getPendingApprovals(buildScopeCountParams(scopeKey)) : await apiClient.getProcurementRequests(buildScopeCountParams(scopeKey)); return [scopeKey, getPaginatedCount(response)] as const; }), ); if (!cancelled) { setScopeCounts(Object.fromEntries(results) as Record); } } catch (countsError) { console.error("Load procurement scope counts error:", countsError); } })(); return () => { cancelled = true; }; }, [buildScopeCountParams]); 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, scopeCounts, 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, }; }