"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 = { 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 = { vacation: "Отпуск", sick_leave: "Больничный", day_off: "Отгул", maternity: "Декрет", 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": case "day_off": case "maternity": 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([]); const [employees, setEmployees] = useState([]); const [departments, setDepartments] = useState([]); const [commentsMap, setCommentsMap] = useState>({}); const [commentsLoadingMap, setCommentsLoadingMap] = useState>({}); const [expandedRows, setExpandedRows] = useState>({}); const [expandedComments, setExpandedComments] = useState>({}); const [commentDrafts, setCommentDrafts] = useState>({}); /* form */ const [createOpen, setCreateOpen] = useState(false); const [editingRequest, setEditingRequest] = useState(null); const [form, setForm] = useState(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(null); const [detailsRequest, setDetailsRequest] = useState(null); const [loading, setLoading] = useState(true); const [loadingMore, setLoadingMore] = useState(false); const [error, setError] = useState(null); const [actionError, setActionError] = useState(null); const [actionSuccess, setActionSuccess] = useState(null); const [busyKey, setBusyKey] = useState(null); const [nextPage, setNextPage] = useState(null); const departmentNameMap = useMemo( () => new Map((departments || []).map((d) => [d.id, d.name])), [departments], ); /* ── params ── */ const buildRequestParams = useCallback( (page: number): Record => { const params: Record = { 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((p) => apiClient.getEmployees(p)), loadAllPages((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 = { 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; 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(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;