Corp/frontend/src/hooks/useProcurementPage.ts

651 lines
21 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"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;
count?: number;
};
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 getPaginatedCount = (response: unknown): number => {
if (!response || typeof response !== "object" || Array.isArray(response)) {
return 0;
}
const count = (response as PaginatedLike<unknown>).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<string, unknown>;
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<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 [scopeCounts, setScopeCounts] = useState<Record<ScopeTab, number>>({
all: 0,
mine: 0,
department: 0,
pending_approvals: 0,
my_work: 0,
available: 0,
});
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 buildScopeCountParams = useCallback(
(targetScope: ScopeTab): Record<string, string | number> => {
const params: Record<string, string | number> = { 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<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 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<ScopeTab, number>);
}
} 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<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,
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,
};
}