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:
parent
0a80eb86cf
commit
19f6f660b0
@ -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
|
||||
|
||||
575
frontend/src/hooks/useProcurementPage.ts
Normal file
575
frontend/src/hooks/useProcurementPage.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user