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
This commit is contained in:
lizerk 2026-04-01 16:34:04 +03:00
parent 0a80eb86cf
commit 19f6f660b0
2 changed files with 639 additions and 403 deletions

View File

@ -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<ProcurementRequest[]>([]);
const [departments, setDepartments] = useState<Department[]>([]);
/* ── UI flags ── */
const [loading, setLoading] = useState(true);
const [loadingMore, setLoadingMore] = useState(false);
const [error, setError] = useState<string | null>(null);
const [actionError, setActionError] = useState<string | null>(null);
const [actionSuccess, setActionSuccess] = useState<string | null>(null);
const [busyKey, setBusyKey] = useState<string | null>(null);
const [nextPage, setNextPage] = useState<number | null>(null);
/* ── scope / filters ── */
const [scope, setScope] = useState<ScopeTab>("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<ProcurementSection>("requests");
/* ── form modal ── */
const [createOpen, setCreateOpen] = useState(false);
const [editingId, setEditingId] = useState<number | null>(null);
const [form, setForm] = useState<FormState>(emptyForm);
/* ── expanded details ── */
const [expandedIds, setExpandedIds] = useState<Set<number>>(new Set());
const [detailsCache, setDetailsCache] = useState<Record<number, ProcurementRequest>>({});
/* ══════════ 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<string, string | number> => {
const p: Record<string, string | number> = { 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<Department>((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<string, number> = {
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<any>, 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<ItemDraft>) =>
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

View File

@ -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<T> = {
results?: T[];
next?: string | null;
};
const getPaginatedResults = <T,>(response: unknown): T[] => {
if (Array.isArray(response)) {
return response as T[];
}
if (response && typeof response === "object") {
const results = (response as PaginatedLike<T>).results;
if (Array.isArray(results)) {
return results;
}
}
return [];
};
const getPaginatedNext = (response: unknown): number | null => {
if (!response || typeof response !== "object" || Array.isArray(response)) {
return null;
}
return extractNextPage((response as PaginatedLike<unknown>).next ?? null);
};
const getReadableError = (error: unknown, fallback: string): string => {
const raw = String((error as Error)?.message || fallback);
try {
const parsed = JSON.parse(raw) as Record<string, unknown>;
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<ProcurementRequest[]>([]);
const [departments, setDepartments] = useState<Department[]>([]);
const [loading, setLoading] = useState(true);
const [loadingMore, setLoadingMore] = useState(false);
const [error, setError] = useState<string | null>(null);
const [actionError, setActionError] = useState<string | null>(null);
const [actionSuccess, setActionSuccess] = useState<string | null>(null);
const [busyKey, setBusyKey] = useState<string | null>(null);
const [nextPage, setNextPage] = useState<number | null>(null);
const [scope, setScope] = useState<ScopeTab>("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<ProcurementSection>("requests");
const [createOpen, setCreateOpen] = useState(false);
const [editingId, setEditingId] = useState<number | null>(null);
const [form, setForm] = useState<FormState>(emptyForm);
const [expandedIds, setExpandedIds] = useState<Set<number>>(new Set());
const [detailsCache, setDetailsCache] = useState<Record<number, ProcurementRequest>>({});
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<string, string | number> => {
const params: Record<string, string | number> = { 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<ProcurementRequest>(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<Department>((page) => apiClient.getDepartments(page));
if (!cancelled) {
setDepartments(allDepartments);
}
} catch {
if (!cancelled) {
setDepartments([]);
}
}
})();
return () => {
cancelled = true;
};
}, []);
const filteredRequests = useMemo(() => {
const urgencyRank: Record<string, number> = {
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<string, unknown> = {
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<unknown>, 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<ProcurementRequest>(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<ItemDraft>) => {
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,
};
}