Corp/frontend/src/hooks/useRequestsPage.ts
2026-04-20 08:08:57 +03:00

564 lines
20 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, useRef, useState } from "react";
import { apiClient } from "@/lib/api";
import type { Request, RequestComment, User, Department } from "@/types/api";
import { extractNextPage, loadAllPages, displayUserName } from "@/lib/shared";
/* ── constants ── */
export type RequestFormState = {
type: string;
title: string;
date_from: string;
date_to: string;
comment: string;
recipient_ids: number[];
cc_user_ids: number[];
attachment: File | null;
};
export type RequestAttachmentPreview = {
url: string;
name: string;
};
export const createEmptyForm = (): RequestFormState => ({
type: "",
title: "",
date_from: "",
date_to: "",
comment: "",
recipient_ids: [],
cc_user_ids: [],
attachment: null,
});
export const statusMeta: Record<string, { label: string; className: string }> = {
draft: { label: "Черновик", className: "app-badge" },
pending: { label: "На рассмотрении", className: "app-feedback-warning" },
approved: { label: "Одобрено", className: "app-feedback-success" },
rejected: { label: "Отклонено", className: "app-feedback-danger" },
cancelled: { label: "Отменено", className: "app-badge" },
in_progress: { label: "В работе", className: "app-selected" },
completed: { label: "Завершено", className: "app-badge-accent" },
};
export const defaultStatusMeta = { label: "Неизвестный статус", className: "app-badge" };
export const requestTypeLabels: Record<string, string> = {
vacation: "Отпуск",
sick_leave: "Больничный",
day_off: "Отгул",
transfer: "Перевод",
dismissal: "Увольнение",
other: "Другое",
};
export const orderingOptions = [
{ value: "-created_at", label: "Сначала новые" },
{ value: "created_at", label: "Сначала старые" },
{ value: "title", label: "По названию" },
{ value: "date_from", label: "По периоду ↑" },
{ value: "-date_from", label: "По периоду ↓" },
];
export function getRequestDateMode(type: string): "range" | "single" | "optional" {
switch (String(type || "").toLowerCase()) {
case "vacation":
case "sick_leave":
return "range";
case "transfer":
case "dismissal":
return "single";
default:
return "optional";
}
}
function getRequestSubmitError(form: RequestFormState): string | null {
if (form.recipient_ids.length === 0) {
return "Укажите хотя бы одного получателя.";
}
if (!form.type) {
return "Выберите тип заявления.";
}
const dateMode = getRequestDateMode(form.type);
if (dateMode === "range") {
if (!form.date_from || !form.date_to) {
return "Укажите обе даты периода.";
}
if (form.date_to < form.date_from) {
return "Дата окончания не может быть раньше даты начала.";
}
}
if (dateMode === "single" && !form.date_from) {
return "Укажите дату начала.";
}
return null;
}
/* ── hook ── */
// eslint-disable-next-line @typescript-eslint/no-unused-vars
export function useRequestsPage(_userId: number | null | undefined) {
/* data */
const [requests, setRequests] = useState<Request[]>([]);
const [employees, setEmployees] = useState<User[]>([]);
const [departments, setDepartments] = useState<Department[]>([]);
const [commentsMap, setCommentsMap] = useState<Record<number, RequestComment[]>>({});
const [commentsLoadingMap, setCommentsLoadingMap] = useState<Record<number, boolean>>({});
const [expandedRows, setExpandedRows] = useState<Record<number, boolean>>({});
const [expandedComments, setExpandedComments] = useState<Record<number, boolean>>({});
const [commentDrafts, setCommentDrafts] = useState<Record<number, string>>({});
/* form */
const [createOpen, setCreateOpen] = useState(false);
const [editingRequest, setEditingRequest] = useState<Request | null>(null);
const [form, setForm] = useState<RequestFormState>(createEmptyForm);
/* filters */
const [search, setSearch] = useState("");
const [view, setView] = useState<"" | "mine" | "addressed">("");
const [typeFilter, setTypeFilter] = useState("");
const [statusFilter, setStatusFilter] = useState("");
const [employeeFilter, setEmployeeFilter] = useState("");
const [createdFromFilter, setCreatedFromFilter] = useState("");
const [createdToFilter, setCreatedToFilter] = useState("");
const [periodFromFilter, setPeriodFromFilter] = useState("");
const [periodToFilter, setPeriodToFilter] = useState("");
const [filtersOpen, setFiltersOpen] = useState(false);
const [ordering, setOrdering] = useState("-created_at");
const [swipeMode, setSwipeMode] = useState(false);
/* UI */
const [attachmentPreview, setAttachmentPreview] = useState<RequestAttachmentPreview | null>(null);
const [detailsRequest, setDetailsRequest] = useState<Request | null>(null);
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 departmentNameMap = useMemo(
() => new Map((departments || []).map((d) => [d.id, d.name])),
[departments],
);
/* ── params ── */
const buildRequestParams = useCallback(
(page: number): Record<string, string | number> => {
const params: Record<string, string | number> = { page, limit: 20 };
if (view === "mine") params.view = "mine";
if (view === "addressed") params.addressed_to_me = "true";
if (typeFilter) params.type = typeFilter;
if (statusFilter) params.status = statusFilter;
if (employeeFilter) params.employee_id = employeeFilter;
if (createdFromFilter) params.created_from = createdFromFilter;
if (createdToFilter) params.created_to = createdToFilter;
if (periodFromFilter) params.date_from = periodFromFilter;
if (periodToFilter) params.date_to = periodToFilter;
return params;
},
[view, typeFilter, statusFilter, employeeFilter, createdFromFilter, createdToFilter, periodFromFilter, periodToFilter],
);
/* ── load data ── */
useEffect(() => {
let cancelled = false;
(async () => {
try {
setLoading(true);
setError(null);
const response = await apiClient.getRequests(buildRequestParams(1));
if (cancelled) return;
setRequests(response.results || []);
setNextPage(extractNextPage(response.next));
} catch (err) {
if (!cancelled) {
console.error("Ошибка загрузки заявлений:", err);
setError("Не удалось загрузить заявления");
}
} finally {
if (!cancelled) setLoading(false);
}
})();
return () => { cancelled = true; };
}, [buildRequestParams]);
useEffect(() => {
(async () => {
try {
const [allEmployees, allDepartments] = await Promise.all([
loadAllPages<User>((p) => apiClient.getEmployees(p)),
loadAllPages<Department>((p) => apiClient.getDepartments(p)),
]);
setEmployees(allEmployees);
setDepartments(allDepartments);
} catch {
setEmployees([]);
setDepartments([]);
}
})();
}, []);
/* ── filtered + sorted ── */
const filteredRequests = useMemo(() => {
const q = search.trim().toLowerCase();
const sorted = [...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": {
const l = String(a.display_title || a.title || "").trim();
const r = String(b.display_title || b.title || "").trim();
return l.localeCompare(r, "ru", { sensitivity: "base" });
}
case "date_from":
return (new Date(a.date_from || 0).getTime() || 0) - (new Date(b.date_from || 0).getTime() || 0);
case "-date_from":
return (new Date(b.date_from || 0).getTime() || 0) - (new Date(a.date_from || 0).getTime() || 0);
default:
return (new Date(b.created_at).getTime() || 0) - (new Date(a.created_at).getTime() || 0);
}
});
if (!q) return sorted;
return sorted.filter((item) => {
const title = (item.display_title || item.title || "").toLowerCase();
const description = (item.comment || item.description || "").toLowerCase();
const type = String(item.type || item.request_type || "").toLowerCase();
const author = displayUserName(item.employee || item.created_by).toLowerCase();
return title.includes(q) || description.includes(q) || type.includes(q) || author.includes(q);
});
}, [ordering, requests, search]);
const pendingDecisionRequests = useMemo(
() => filteredRequests.filter((request) => String(request.status || "").toLowerCase() === "pending" && request.can_decide),
[filteredRequests],
);
const pendingDecisionCount = pendingDecisionRequests.length;
const activeFiltersCount = useMemo(
() => [
view,
typeFilter,
statusFilter,
employeeFilter,
createdFromFilter,
createdToFilter,
periodFromFilter,
periodToFilter,
].filter(Boolean).length,
[
view,
typeFilter,
statusFilter,
employeeFilter,
createdFromFilter,
createdToFilter,
periodFromFilter,
periodToFilter,
],
);
const hasActiveFilters = activeFiltersCount > 0;
/* ── form ── */
const resetForm = () => setForm(createEmptyForm());
const openEdit = (req: Request) => {
setEditingRequest(req);
setCreateOpen(false);
setActionError(null);
setActionSuccess(null);
setForm({
type: req.type || req.request_type || "",
title: req.title || "",
date_from: req.date_from || "",
date_to: req.date_to || "",
comment: req.comment || req.description || "",
recipient_ids: (req.recipients || []).map((u: User) => u.id).filter(Boolean),
cc_user_ids: (req.cc_users || []).map((u: User) => u.id).filter(Boolean),
attachment: null,
});
};
const openCreate = () => {
setEditingRequest(null);
resetForm();
setActionError(null);
setActionSuccess(null);
setCreateOpen(true);
};
const openSwipeMode = () => setSwipeMode(true);
const closeModal = () => {
setCreateOpen(false);
setEditingRequest(null);
resetForm();
setActionError(null);
};
const clearFilters = () => {
setView("");
setTypeFilter("");
setStatusFilter("");
setEmployeeFilter("");
setCreatedFromFilter("");
setCreatedToFilter("");
setPeriodFromFilter("");
setPeriodToFilter("");
};
const toggleFilters = () => setFiltersOpen((value) => !value);
/* ── CRUD ── */
const handleCreateOrUpdate = async (mode: "create" | "edit", saveAs: "draft" | "submit") => {
try {
setBusyKey(`${mode}-${saveAs}`);
setActionError(null);
setActionSuccess(null);
if (saveAs === "submit") {
const submitError = getRequestSubmitError(form);
if (submitError) {
setActionError(submitError);
return;
}
}
const payload: Record<string, string | File | number[] | null> = {
title: form.title,
date_from: form.date_from || null,
date_to: form.date_to || null,
comment: form.comment,
};
if (form.type) payload.type = form.type;
if (form.recipient_ids.length > 0) payload.recipient_ids = form.recipient_ids;
if (form.cc_user_ids.length > 0) payload.cc_user_ids = form.cc_user_ids;
if (form.attachment) payload.attachment = form.attachment;
if (mode === "create") {
await apiClient.createRequest(payload, saveAs);
setActionSuccess(saveAs === "draft" ? "Черновик сохранён." : "Заявление отправлено.");
setCreateOpen(false);
} else if (editingRequest) {
await apiClient.updateRequest(editingRequest.id, payload, saveAs);
setActionSuccess(saveAs === "draft" ? "Черновик обновлён." : "Заявление отправлено.");
setEditingRequest(null);
}
resetForm();
const response = await apiClient.getRequests(buildRequestParams(1));
setRequests(response.results || []);
setNextPage(extractNextPage(response.next));
} catch (e: unknown) {
const raw = String((e as Error)?.message || "Не удалось сохранить заявление");
let readable = raw;
try {
const parsed = JSON.parse(raw) as Record<string, string[] | string>;
readable = Object.values(parsed).flat().join(". ");
} catch {
/* keep raw */
}
setActionError(readable);
} finally {
setBusyKey(null);
}
};
const applyUpdatedRequest = useCallback((updated: Request) => {
setRequests((prev) => prev.map((request) => (request.id === updated.id ? { ...request, ...updated } : request)));
setDetailsRequest((prev) => (prev?.id === updated.id ? { ...prev, ...updated } : prev));
}, []);
const handleLoadMore = useCallback(async () => {
if (!nextPage || loadingMore) return;
try {
setLoadingMore(true);
setError(null);
const response = await apiClient.getRequests(buildRequestParams(nextPage));
const chunk = response.results || [];
setRequests((prev) => {
const known = new Set(prev.map((r: Request) => r.id));
return [...prev, ...chunk.filter((r: Request) => !known.has(r.id))];
});
setNextPage(extractNextPage(response.next));
} catch (e: unknown) {
setError(String((e as Error)?.message || "Не удалось загрузить ещё заявления"));
} finally {
setLoadingMore(false);
}
}, [nextPage, loadingMore, buildRequestParams]);
const handleApprove = async (id: number) => {
try { setBusyKey(`approve-${id}`); setActionError(null); const updated = await apiClient.approveRequest(id); applyUpdatedRequest(updated); }
catch (e: unknown) { setActionError(String((e as Error)?.message || "Не удалось одобрить")); }
finally { setBusyKey(null); }
};
const handleReject = async (id: number) => {
try { setBusyKey(`reject-${id}`); setActionError(null); const updated = await apiClient.rejectRequest(id); applyUpdatedRequest(updated); }
catch (e: unknown) { setActionError(String((e as Error)?.message || "Не удалось отклонить")); }
finally { setBusyKey(null); }
};
const handleCancel = async (id: number) => {
try { setBusyKey(`cancel-${id}`); setActionError(null); const updated = await apiClient.cancelRequest(id); applyUpdatedRequest(updated); }
catch (e: unknown) { setActionError(String((e as Error)?.message || "Не удалось отменить")); }
finally { setBusyKey(null); }
};
const handleDelete = async (id: number) => {
if (!window.confirm("Удалить заявление? Это действие нельзя отменить.")) {
return;
}
try {
setBusyKey(`delete-${id}`);
setActionError(null);
await apiClient.deleteRequest(id);
setRequests((p) => p.filter((r) => r.id !== id));
setDetailsRequest((prev) => (prev?.id === id ? null : prev));
}
catch (e: unknown) { setActionError(String((e as Error)?.message || "Не удалось удалить")); }
finally { setBusyKey(null); }
};
/* ── comments ── */
const ensureCommentsLoaded = useCallback(async (requestId: number) => {
if (commentsMap[requestId] || commentsLoadingMap[requestId]) return;
setCommentsLoadingMap((prev) => ({ ...prev, [requestId]: true }));
try {
const comments = await apiClient.getRequestComments(requestId);
setCommentsMap((prev) => ({ ...prev, [requestId]: comments }));
} catch {
setCommentsMap((prev) => ({ ...prev, [requestId]: [] }));
} finally {
setCommentsLoadingMap((prev) => ({ ...prev, [requestId]: false }));
}
}, [commentsLoadingMap, commentsMap]);
const toggleComments = async (requestId: number) => {
const isOpen = Boolean(expandedComments[requestId]);
setExpandedComments((p) => ({ ...p, [requestId]: !isOpen }));
if (!isOpen && !commentsMap[requestId]) {
await ensureCommentsLoaded(requestId);
}
};
const toggleRow = (requestId: number) => setExpandedRows((p) => ({ ...p, [requestId]: !p[requestId] }));
const handleAddComment = async (requestId: number) => {
const text = (commentDrafts[requestId] || "").trim();
if (!text) return;
try {
setBusyKey(`comment-${requestId}`);
const saved = await apiClient.addRequestComment(requestId, text);
setCommentsMap((p) => ({ ...p, [requestId]: [...(p[requestId] || []), saved] }));
setCommentDrafts((p) => ({ ...p, [requestId]: "" }));
} catch (e: unknown) { setActionError(String((e as Error)?.message || "Не удалось добавить комментарий")); }
finally { setBusyKey(null); }
};
const handleDeleteComment = async (requestId: number, commentId: number) => {
try {
setBusyKey(`comment-delete-${commentId}`);
await apiClient.deleteRequestComment(requestId, commentId);
setCommentsMap((p) => ({ ...p, [requestId]: (p[requestId] || []).filter((c) => c.id !== commentId) }));
} catch (e: unknown) { setActionError(String((e as Error)?.message || "Не удалось удалить комментарий")); }
finally { setBusyKey(null); }
};
const setCommentDraft = useCallback((requestId: number, value: string) => {
setCommentDrafts((prev) => ({ ...prev, [requestId]: value }));
}, []);
const isFinal = (status?: string) => ["approved", "rejected", "cancelled"].includes(String(status || "").toLowerCase());
const loadMoreRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const observer = new IntersectionObserver(
(entries) => { if (entries[0].isIntersecting && nextPage && !loadingMore && !loading) handleLoadMore(); },
{ rootMargin: "100px" },
);
const el = loadMoreRef.current;
if (el) observer.observe(el);
return () => { if (el) observer.unobserve(el); };
}, [nextPage, loadingMore, loading, handleLoadMore]);
useEffect(() => {
const requestId = detailsRequest?.id;
if (!requestId) return;
if (String(detailsRequest.status || "").toLowerCase() === "draft") return;
void ensureCommentsLoaded(requestId);
}, [detailsRequest, ensureCommentsLoaded]);
return {
/* data */
requests: filteredRequests,
pendingDecisionRequests,
employees,
departments,
departmentNameMap,
commentsMap,
commentsLoadingMap,
expandedRows,
expandedComments,
commentDrafts,
setCommentDraft,
/* form */
form,
setForm,
createOpen,
editingRequest,
editingRequestId: editingRequest?.id ?? null,
modalMode: (editingRequest ? "edit" : "create") as "create" | "edit",
isModalOpen: createOpen || editingRequest !== null,
openCreate,
openEdit,
closeModal,
handleCreateOrUpdate,
/* filters */
search, setSearch,
view, setView,
typeFilter, setTypeFilter,
statusFilter, setStatusFilter,
employeeFilter, setEmployeeFilter,
createdFromFilter, setCreatedFromFilter,
createdToFilter, setCreatedToFilter,
periodFromFilter, setPeriodFromFilter,
periodToFilter, setPeriodToFilter,
filtersOpen, setFiltersOpen,
ordering, setOrdering,
swipeMode, setSwipeMode,
activeFiltersCount,
hasActiveFilters,
pendingDecisionCount,
clearFilters,
toggleFilters,
openSwipeMode,
/* UI */
attachmentPreview, setAttachmentPreview,
detailsRequest, setDetailsRequest,
loading, loadingMore, error,
actionError, actionSuccess,
busyKey, nextPage,
loadMoreRef,
/* actions */
handleApprove, handleReject, handleCancel, handleDelete,
toggleRow, toggleComments, handleAddComment, handleDeleteComment,
isFinal,
};
}
export type RequestsPageController = ReturnType<typeof useRequestsPage>;