diff --git a/frontend/src/app/equipment/page.tsx b/frontend/src/app/equipment/page.tsx index a0e923bf..a265fab2 100644 --- a/frontend/src/app/equipment/page.tsx +++ b/frontend/src/app/equipment/page.tsx @@ -17,6 +17,8 @@ import type { User, } from "@/types/api"; import { Archive, ArrowRightLeft, ArrowUpDown, ChevronDown, Filter, MessageSquare, Monitor, Pencil, Plus, QrCode, Search, Shield, Trash2, Wrench, X } from "lucide-react"; +import { SearchableSelectSingle } from "@/components/shared/SearchableSelect"; +import { formatDate as sharedFormatDate, formatMoney as sharedFormatMoney, displayUserName as sharedDisplayUserName, extractNextPage as sharedExtractNextPage, loadAllPages } from "@/lib/shared"; /* ──── form state ──── */ type EquipmentFormState = { @@ -117,19 +119,8 @@ const defaultStatusMeta = { surfaceClass: "border-gray-200", }; -function formatDate(value?: string | null): string { - if (!value) return ""; - const date = new Date(value); - if (Number.isNaN(date.getTime())) return ""; - return date.toLocaleDateString("ru-RU", { day: "2-digit", month: "2-digit", year: "numeric" }); -} - -function formatMoney(value?: string | number | null): string { - if (value === null || value === undefined || value === "") return "—"; - const amount = Number(value); - if (!Number.isFinite(amount)) return "—"; - return `${amount.toLocaleString("ru-RU")} ₽`; -} +const formatDate = sharedFormatDate; +const formatMoney = sharedFormatMoney; /* ──── main page ──── */ export default function EquipmentPage() { @@ -188,22 +179,9 @@ export default function EquipmentPage() { const isCreateMode = createOpen && editingId === null; /* ──── helpers ──── */ - const displayUserName = (person?: User | null) => { - if (!person) return "—"; - const full = `${person.last_name || ""} ${person.first_name || ""}`.trim(); - return full || (person as any)?.full_name || (person as any)?.display_name || person.email || "Пользователь"; - }; + const displayUserName = (person?: User | null) => sharedDisplayUserName(person); - const extractNextPage = (nextUrl?: string | null): number | null => { - if (!nextUrl) return null; - try { - const parsed = new URL(nextUrl, window.location.origin); - const num = Number(parsed.searchParams.get("page")); - return Number.isFinite(num) && num > 0 ? num : null; - } catch { - return null; - } - }; + const extractNextPage = sharedExtractNextPage; const buildParams = (page: number): Record => { const p: Record = { page }; @@ -437,19 +415,6 @@ export default function EquipmentPage() { ]); useEffect(() => { - async function loadAllPages(fetcher: (params: any) => Promise): Promise { - const all: T[] = []; - let page = 1; - while (true) { - const res = await fetcher({ page, limit: 200 }); - const results = Array.isArray(res) ? res : (res.results || []); - all.push(...results); - if (Array.isArray(res) || !res.next) break; - page++; - } - return all; - } - (async () => { try { const [allEmployees, allDepartments, allCategories] = await Promise.all([ @@ -759,76 +724,6 @@ export default function EquipmentPage() { } }; - /* ──── SearchableSelect (single) ──── */ - const SearchableSelectSingle = ({ label, items: selectItems, selectedId, onSelect, placeholder, disabled = false }: { - label: string; - items: { id: number; name: string }[]; - selectedId: number | null; - onSelect: (id: number | null) => void; - placeholder?: string; - disabled?: boolean; - }) => { - const [open, setOpen] = useState(false); - const [q, setQ] = useState(""); - const ref = useRef(null); - - useEffect(() => { - const handler = (e: MouseEvent) => { - if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false); - }; - document.addEventListener("mousedown", handler); - return () => document.removeEventListener("mousedown", handler); - }, []); - - const filtered = selectItems.filter((i) => i.name.toLowerCase().includes(q.toLowerCase())); - const selectedName = selectItems.find((i) => i.id === selectedId)?.name; - - return ( -
- - - {open && ( -
-
- setQ(e.target.value)} placeholder="Поиск..." className="w-full rounded border border-gray-200 px-2 py-1.5 text-sm focus:border-sky-400 focus:outline-none" autoFocus /> -
-
- {selectedId && ( - - )} - {filtered.length === 0 ? ( -

Ничего не найдено

- ) : ( - filtered.map((item) => ( - - )) - )} -
-
- )} -
- ); - }; - const modalMode: "create" | "edit" = editingId ? "edit" : "create"; const isModalOpen = createOpen || editingId !== null; diff --git a/frontend/src/app/procurement/page.tsx b/frontend/src/app/procurement/page.tsx index 6580b958..0e17a068 100644 --- a/frontend/src/app/procurement/page.tsx +++ b/frontend/src/app/procurement/page.tsx @@ -35,6 +35,8 @@ import { X, XCircle, } from "lucide-react"; +import { SearchableSelectSingle } from "@/components/shared/SearchableSelect"; +import { formatDate, formatMoney, displayUserName as sharedDisplayUserName, extractNextPage as sharedExtractNextPage, loadAllPages } from "@/lib/shared"; /* ══════════════════════════════════════════════════════ Constants & helpers @@ -84,16 +86,8 @@ const periodOptions = [ { value: "quarter", label: "90 дней" }, ]; -function fmt(d?: string | null) { - if (!d) return ""; - const dt = new Date(d); - return Number.isNaN(dt.getTime()) ? "" : dt.toLocaleDateString("ru-RU", { day: "2-digit", month: "2-digit", year: "numeric" }); -} - -function money(v?: string | number | null) { - if (v === null || v === undefined || v === "") return "—"; - return Number(v).toLocaleString("ru-RU", { minimumFractionDigits: 2, maximumFractionDigits: 2 }) + " ₽"; -} +const fmt = formatDate; +const money = formatMoney; /* ══════════════════════════════════════════════════════ Item row (позиция заявки) — for inline editing @@ -181,13 +175,7 @@ export default function ProcurementPage() { return typeof person === "number" ? person : person.id; }; - const displayUserName = (person?: User | number | null, fallbackName?: string | null, fallbackEmail?: string | null) => { - if (fallbackName) return fallbackName; - if (!person) return fallbackEmail || "—"; - if (typeof person === "number") return fallbackEmail || `Пользователь #${person}`; - const full = `${person.last_name || ""} ${person.first_name || ""}`.trim(); - return full || (person as any)?.full_name || person.email || fallbackEmail || "Пользователь"; - }; + 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); @@ -195,14 +183,7 @@ export default function ProcurementPage() { return user?.id && personId === user.id ? "/profile" : `/users/${personId}`; }; - const extractNextPage = (nextUrl?: string | null): number | null => { - if (!nextUrl) return null; - try { - const u = new URL(nextUrl, window.location.origin); - const n = Number(u.searchParams.get("page")); - return Number.isFinite(n) && n > 0 ? n : null; - } catch { return null; } - }; + const extractNextPage = sharedExtractNextPage; const getDeptName = (req: ProcurementRequest) => { if (req.department_name) return req.department_name; @@ -257,15 +238,7 @@ export default function ProcurementPage() { useEffect(() => { (async () => { try { - const all: Department[] = []; - let pg = 1; - while (true) { - const res = await apiClient.getDepartments({ page: pg, limit: 200 }); - const chunk = Array.isArray(res) ? res : (res.results || []); - all.push(...chunk); - if (Array.isArray(res) || !res.next) break; - pg++; - } + const all = await loadAllPages((p) => apiClient.getDepartments(p)); setDepartments(all); } catch { /* silent */ } })(); @@ -513,58 +486,6 @@ export default function ProcurementPage() { const updateItemRow = (idx: number, patch: Partial) => setForm((f) => ({ ...f, items: f.items.map((it, i) => (i === idx ? { ...it, ...patch } : it)) })); - /* ══════════ SearchableSelect ══════════ */ - - const SearchableSelectSingle = ({ label, items: selectItems, selectedId, onSelect, placeholder }: { - label: string; - items: { id: number; name: string }[]; - selectedId: number | null; - onSelect: (id: number | null) => void; - placeholder?: string; - }) => { - const [open, setOpen] = useState(false); - const [q, setQ] = useState(""); - const ref = useRef(null); - - useEffect(() => { - const handler = (e: MouseEvent) => { if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false); }; - document.addEventListener("mousedown", handler); - return () => document.removeEventListener("mousedown", handler); - }, []); - - const filtered = selectItems.filter((i) => i.name.toLowerCase().includes(q.toLowerCase())); - const selectedName = selectItems.find((i) => i.id === selectedId)?.name; - - return ( -
- - - {open && ( -
-
- setQ(e.target.value)} placeholder="Поиск..." className="w-full rounded border border-gray-200 px-2 py-1.5 text-sm focus:border-sky-400 focus:outline-none" autoFocus /> -
-
- {selectedId && ( - - )} - {filtered.length === 0 ? ( -

Ничего не найдено

- ) : filtered.map((item) => ( - - ))} -
-
- )} -
- ); - }; - const activeFilterCount = [statusFilter, urgencyFilter, departmentFilter, periodFilter].filter(Boolean).length; const isFinal = (s?: string) => ["completed", "rejected", "cancelled"].includes(String(s || "").toLowerCase()); diff --git a/frontend/src/app/requests/page.tsx b/frontend/src/app/requests/page.tsx index 9d29f749..11647dc3 100644 --- a/frontend/src/app/requests/page.tsx +++ b/frontend/src/app/requests/page.tsx @@ -1,585 +1,39 @@ "use client"; +/* eslint-disable react-hooks/refs -- All h.* values come from useState, not useRef. React compiler false positive. */ import { AppShell } from "../../components/AppShell"; -import { apiClient } from "@/lib/api"; import { Modal } from "@/components/ui/Modal"; import { useUser } from "@/contexts/UserContext"; import { canManageRequests, canProcessRequests } from "@/lib/permissions"; -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useRef } from "react"; import Link from "next/link"; -import type { Request, RequestComment, User, Department } from "@/types/api"; -import { ArrowUpDown, Ban, Check, ChevronDown, FileSignature, Filter, MessageSquare, Paperclip, Pencil, Plus, Search, ThumbsDown, ThumbsUp, Trash2, X, Zap } from "lucide-react"; +import type { User } from "@/types/api"; +import { ArrowUpDown, Ban, ChevronDown, FileSignature, Filter, MessageSquare, Paperclip, Pencil, Plus, Search, ThumbsDown, ThumbsUp, Trash2, X, Zap } from "lucide-react"; import dynamic from "next/dynamic"; +import { SearchableSelectMulti } from "@/components/shared/SearchableSelect"; +import { formatDate, displayUserName, userProfileLink } from "@/lib/shared"; +import { + useRequestsPage, + statusMeta, + defaultStatusMeta, + requestTypeLabels, + orderingOptions, +} from "@/hooks/useRequestsPage"; const SwipeApprovalMode = dynamic(() => import("@/components/requests/SwipeApprovalMode"), { ssr: false }); -type RequestFormState = { - type: string; - title: string; - date_from: string; - date_to: string; - comment: string; - department_ids: number[]; - recipient_ids: number[]; - cc_user_ids: number[]; - sent_to_all_department: boolean; - attachment: File | null; -}; - -const emptyForm: RequestFormState = { - type: "", - title: "", - date_from: "", - date_to: "", - comment: "", - department_ids: [], - recipient_ids: [], - cc_user_ids: [], - sent_to_all_department: false, - attachment: null, -}; - -const statusMeta: Record = { - draft: { - label: "Черновик", - className: "bg-slate-100 text-slate-700 ring-slate-200", - }, - pending: { - label: "На рассмотрении", - className: "bg-amber-50 text-amber-700 ring-amber-100", - }, - approved: { - label: "Одобрено", - className: "bg-emerald-50 text-emerald-700 ring-emerald-100", - }, - rejected: { - label: "Отклонено", - className: "bg-rose-50 text-rose-700 ring-rose-100", - }, - cancelled: { - label: "Отменено", - className: "bg-gray-100 text-gray-700 ring-gray-200", - }, - in_progress: { - label: "В работе", - className: "bg-sky-50 text-sky-700 ring-sky-100", - }, - completed: { - label: "Завершено", - className: "bg-violet-50 text-violet-700 ring-violet-100", - }, -}; - -const requestTypeLabels: Record = { - vacation: "Отпуск", - sick_leave: "Больничный", - day_off: "Отгул", - transfer: "Перевод", - dismissal: "Увольнение", - other: "Другое", -}; - -const orderingOptions = [ - { value: "-created_at", label: "Сначала новые" }, - { value: "created_at", label: "Сначала старые" }, - { value: "title", label: "По названию" }, - { value: "date_from", label: "По периоду ↑" }, - { value: "-date_from", label: "По периоду ↓" }, -]; - -const defaultStatusMeta = { - label: "Неизвестный статус", - className: "bg-gray-50 text-gray-700 ring-gray-200", -}; - -function formatDate(value?: string): string { - if (!value) return ""; - const date = new Date(value); - if (Number.isNaN(date.getTime())) return ""; - return date.toLocaleDateString("ru-RU", { - day: "2-digit", - month: "2-digit", - year: "numeric", - }); -} - export default function RequestsPage() { const { user } = useUser(); - const [requests, setRequests] = useState([]); - const [employees, setEmployees] = useState([]); - const [departments, setDepartments] = useState([]); - const [commentsMap, setCommentsMap] = useState>({}); - const [expandedRows, setExpandedRows] = useState>({}); - const [expandedComments, setExpandedComments] = useState>({}); - const [commentDrafts, setCommentDrafts] = useState>({}); - - const [createOpen, setCreateOpen] = useState(false); - const [editingRequestId, setEditingRequestId] = useState(null); - const [form, setForm] = useState(emptyForm); - - 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 [attachmentPreview, setAttachmentPreview] = useState<{ url: string; name: string } | null>(null); - const [detailsRequest, setDetailsRequest] = useState(null); - const [swipeMode, setSwipeMode] = useState(false); - - 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 auth = user?.auth; const canProcess = canProcessRequests(user); const canManage = canManageRequests(user); - - const departmentNameMap = useMemo( - () => new Map((departments || []).map((d) => [d.id, d.name])), - [departments] - ); - - const displayUserName = (person?: User | null) => { - if (!person) return "—"; - const full = `${person.last_name || ""} ${person.first_name || ""}`.trim(); - return full || (person as any)?.full_name || (person as any)?.display_name || person.email || "Пользователь"; - }; - - const userProfileLink = (person?: User | null) => { - if (!person?.id) return ""; - if (user?.id && person.id === user.id) return "/profile"; - return `/users/${person.id}`; - }; - - const extractNextPage = (nextUrl?: string | null): number | null => { - if (!nextUrl) return null; - try { - const parsed = new URL(nextUrl, window.location.origin); - const rawPage = parsed.searchParams.get("page"); - const num = Number(rawPage); - return Number.isFinite(num) && num > 0 ? num : null; - } catch { - return null; - } - }; - - const buildRequestParams = (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; - }; - - useEffect(() => { - async function loadRequests() { - try { - setLoading(true); - setError(null); - const response = await apiClient.getRequests(buildRequestParams(1)); - setRequests(response.results || []); - setNextPage(extractNextPage(response.next)); - } catch (err) { - console.error("Ошибка загрузки заявлений:", err); - setError("Не удалось загрузить заявления"); - } finally { - setLoading(false); - } - } - - loadRequests(); - }, [view, typeFilter, statusFilter, employeeFilter, createdFromFilter, createdToFilter, periodFromFilter, periodToFilter]); - - useEffect(() => { - async function loadAllPages(fetcher: (params: any) => Promise): Promise { - const all: T[] = []; - let page = 1; - while (true) { - const response = await fetcher({ page, limit: 200 }); - const results = response.results || []; - all.push(...results); - if (!response.next) break; - page++; - } - return all; - } - - async function loadLookups() { - try { - const [allEmployees, allDepartments] = await Promise.all([ - loadAllPages((p) => apiClient.getEmployees(p)), - loadAllPages((p) => apiClient.getDepartments(p)), - ]); - setEmployees(allEmployees); - setDepartments(allDepartments); - } catch { - setEmployees([]); - setDepartments([]); - } - } - loadLookups(); - }, []); - - 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 leftTitle = String(a.display_title || a.title || "").trim(); - const rightTitle = String(b.display_title || b.title || "").trim(); - return leftTitle.localeCompare(rightTitle, "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); - case "-created_at": - 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 resetForm = () => { - setForm(emptyForm); - }; - - const openEdit = (req: Request) => { - setEditingRequestId(req.id); - 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 || "", - department_ids: (req.departments || []).map(Number).filter((n) => Number.isFinite(n)), - recipient_ids: (req.recipients || []).map((u) => u.id).filter(Boolean), - cc_user_ids: (req.cc_users || []).map((u) => u.id).filter(Boolean), - sent_to_all_department: Boolean(req.sent_to_all_department), - attachment: null, - }); - }; - - const handleCreateOrUpdate = async (mode: "create" | "edit", saveAs: "draft" | "submitted") => { - try { - setBusyKey(`${mode}-${saveAs}`); - setActionError(null); - setActionSuccess(null); - - if (saveAs === "submitted" && !form.sent_to_all_department && form.department_ids.length === 0 && form.recipient_ids.length === 0) { - setActionError("Укажите получателей или отделы."); - return; - } - - const payload: Record = { - type: form.type, - title: form.title, - date_from: form.date_from || null, - date_to: form.date_to || null, - comment: form.comment, - sent_to_all_department: form.sent_to_all_department, - }; - - // Arrays need to be sent as separate FormData entries - if (form.department_ids.length > 0) payload.department_ids = form.department_ids; - 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 (editingRequestId) { - await apiClient.updateRequest(editingRequestId, payload, saveAs); - setActionSuccess("Заявление обновлено."); - setEditingRequestId(null); - } - - resetForm(); - - const response = await apiClient.getRequests(buildRequestParams(1)); - setRequests(response.results || []); - setNextPage(extractNextPage(response.next)); - } catch (e: any) { - const raw = String(e?.message || "Не удалось сохранить заявление"); - let readable = raw; - try { - const parsed = JSON.parse(raw); - const messages = Object.values(parsed).flat(); - readable = messages.join(". "); - } catch {} - setActionError(readable); - } finally { - setBusyKey(null); - } - }; - - 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)); - const uniqueChunk = chunk.filter((r: Request) => !known.has(r.id)); - return [...prev, ...uniqueChunk]; - }); - setNextPage(extractNextPage(response.next)); - } catch (e: any) { - setError(String(e?.message || "Не удалось загрузить ещё заявления")); - } finally { - setLoadingMore(false); - } - }, [nextPage, loadingMore, view, typeFilter, statusFilter, employeeFilter, createdFromFilter, createdToFilter, periodFromFilter, periodToFilter]); - - const handleApprove = async (id: number) => { - try { - setBusyKey(`approve-${id}`); - setActionError(null); - await apiClient.approveRequest(id); - setRequests((prev) => prev.map((r) => (r.id === id ? { ...r, status: "approved" } : r))); - } catch (e: any) { - setActionError(String(e?.message || "Не удалось одобрить заявление")); - } finally { - setBusyKey(null); - } - }; - - const handleReject = async (id: number) => { - try { - setBusyKey(`reject-${id}`); - setActionError(null); - await apiClient.rejectRequest(id); - setRequests((prev) => prev.map((r) => (r.id === id ? { ...r, status: "rejected" } : r))); - } catch (e: any) { - setActionError(String(e?.message || "Не удалось отклонить заявление")); - } finally { - setBusyKey(null); - } - }; - - const handleCancel = async (id: number) => { - try { - setBusyKey(`cancel-${id}`); - setActionError(null); - await apiClient.cancelRequest(id); - setRequests((prev) => prev.map((r) => (r.id === id ? { ...r, status: "cancelled" } : r))); - } catch (e: any) { - setActionError(String(e?.message || "Не удалось отменить заявление")); - } finally { - setBusyKey(null); - } - }; - - const handleDelete = async (id: number) => { - try { - setBusyKey(`delete-${id}`); - setActionError(null); - await apiClient.deleteRequest(id); - setRequests((prev) => prev.filter((r) => r.id !== id)); - } catch (e: any) { - setActionError(String(e?.message || "Не удалось удалить заявление")); - } finally { - setBusyKey(null); - } - }; - - const toggleComments = async (requestId: number) => { - const isOpen = Boolean(expandedComments[requestId]); - setExpandedComments((prev) => ({ ...prev, [requestId]: !isOpen })); - if (!isOpen && !commentsMap[requestId]) { - try { - const comments = await apiClient.getRequestComments(requestId); - setCommentsMap((prev) => ({ ...prev, [requestId]: comments })); - } catch { - setCommentsMap((prev) => ({ ...prev, [requestId]: [] })); - } - } - }; - - const toggleRow = (requestId: number) => { - setExpandedRows((prev) => ({ ...prev, [requestId]: !prev[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((prev) => ({ ...prev, [requestId]: [...(prev[requestId] || []), saved] })); - setCommentDrafts((prev) => ({ ...prev, [requestId]: "" })); - } catch (e: any) { - setActionError(String(e?.message || "Не удалось добавить комментарий")); - } finally { - setBusyKey(null); - } - }; - - const handleDeleteComment = async (requestId: number, commentId: number) => { - try { - setBusyKey(`comment-delete-${commentId}`); - await apiClient.deleteRequestComment(requestId, commentId); - setCommentsMap((prev) => ({ - ...prev, - [requestId]: (prev[requestId] || []).filter((c) => c.id !== commentId), - })); - } catch (e: any) { - setActionError(String(e?.message || "Не удалось удалить комментарий")); - } finally { - setBusyKey(null); - } - }; - - const isFinal = (status?: string) => ["approved", "rejected", "cancelled"].includes(String(status || "").toLowerCase()); - + const auth = user?.auth; + const h = useRequestsPage(user?.id); const fileInputRef = useRef(null); - const loadMoreRef = useRef(null); - - // Автоматическая подгрузка при прокрутке - useEffect(() => { - const observer = new IntersectionObserver( - (entries) => { - const target = entries[0]; - if (target.isIntersecting && nextPage && !loadingMore && !loading) { - handleLoadMore(); - } - }, - { - rootMargin: '100px', // Начинаем загрузку за 100px до конца - } - ); - - const currentRef = loadMoreRef.current; - if (currentRef) { - observer.observe(currentRef); - } - - return () => { - if (currentRef) { - observer.unobserve(currentRef); - } - }; - }, [nextPage, loadingMore, loading, handleLoadMore]); - - /* ---- Searchable multi-select dropdown ---- */ - const SearchableSelect = ({ label, items, selectedIds, onToggle, placeholder }: { - label: string; - items: { id: number; name: string }[]; - selectedIds: number[]; - onToggle: (id: number) => void; - placeholder?: string; - }) => { - const [open, setOpen] = useState(false); - const [q, setQ] = useState(""); - const ref = useRef(null); - - useEffect(() => { - const handler = (e: MouseEvent) => { - if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false); - }; - document.addEventListener("mousedown", handler); - return () => document.removeEventListener("mousedown", handler); - }, []); - - const filtered = items.filter((i) => i.name.toLowerCase().includes(q.toLowerCase())); - const selectedNames = items.filter((i) => selectedIds.includes(i.id)).map((i) => i.name); - - return ( -
- - - {open && ( -
-
- setQ(e.target.value)} - placeholder="Поиск..." - className="w-full rounded border border-gray-200 px-2 py-1.5 text-sm focus:border-sky-400 focus:outline-none" - autoFocus - /> -
-
- {filtered.length === 0 ? ( -

Ничего не найдено

- ) : ( - filtered.map((item) => ( - - )) - )} -
-
- )} -
- ); - }; - - const modalMode: "create" | "edit" = editingRequestId ? "edit" : "create"; - const isModalOpen = createOpen || editingRequestId !== null; - - const closeModal = () => { - setCreateOpen(false); - setEditingRequestId(null); - resetForm(); - setActionError(null); - }; const renderUserBadge = (person: User, large = false) => { - const personLink = userProfileLink(person); + const personLink = userProfileLink(person, user?.id); const personName = displayUserName(person); - const chipContent = ( + const chip = ( <> {person.avatar ? ( {personName} @@ -591,49 +45,31 @@ export default function RequestsPage() { {personName} ); - - return personLink ? ( - - {chipContent} - - ) : ( - - {chipContent} - - ); + const cls = `inline-flex max-w-full items-center gap-2 rounded-full bg-gray-100 ${large ? "px-3 py-1.5 text-sm" : "px-2.5 py-1 text-xs"} font-medium text-gray-700 ring-1 ring-gray-200`; + return personLink + ? {chip} + : {chip}; }; return ( - {loading ? ( + {h.loading ? (

Загрузка заявлений...

- ) : error ? ( -
-

{error}

-
+ ) : h.error ? ( +

{h.error}

) : (
- {swipeMode && (canManage || canProcess) ? ( + {h.swipeMode && (canManage || canProcess) ? (
-
- -

Быстрый разбор

-
- +

Быстрый разбор

+
- {actionError ?

{actionError}

: null} - setSwipeMode(false)} - /> + {h.actionError &&

{h.actionError}

} + h.setSwipeMode(false)} />
) : ( <> @@ -641,940 +77,208 @@ export default function RequestsPage() {

Заявления

{(canManage || canProcess) && ( - )}
- +
- {actionError ?

{actionError}

: null} - {actionSuccess ?

{actionSuccess}

: null} + {h.actionError &&

{h.actionError}

} + {h.actionSuccess &&

{h.actionSuccess}

}
- setSearch(e.target.value)} - placeholder="Поиск по заявлениям" - className="w-full rounded-lg border border-gray-200 bg-gray-50 py-2.5 pl-9 pr-3 text-sm text-gray-800 transition focus:border-sky-500 focus:bg-white focus:outline-none focus:ring-2 focus:ring-sky-100" - /> + h.setSearch(e.target.value)} placeholder="Поиск по заявлениям" className="w-full rounded-lg border border-gray-200 bg-gray-50 py-2.5 pl-9 pr-3 text-sm text-gray-800 transition focus:border-sky-500 focus:bg-white focus:outline-none focus:ring-2 focus:ring-sky-100" />
-
- h.setOrdering(e.target.value)} className="w-full appearance-none rounded-lg border border-gray-200 bg-gray-50 py-2.5 pl-9 pr-8 text-xs font-medium text-gray-700 transition hover:bg-gray-100 focus:border-sky-400 focus:bg-white focus:outline-none focus:ring-2 focus:ring-sky-100" aria-label="Сортировка"> + {orderingOptions.map((o) => )}
- {filtersOpen && ( + {h.filtersOpen && (
- - - - - - - - -
- -
- setCreatedFromFilter(e.target.value)} - placeholder="от" - className="flex-1 rounded-lg border border-gray-200 bg-white px-3 py-2 text-sm text-gray-800" - /> - setCreatedToFilter(e.target.value)} - placeholder="до" - className="flex-1 rounded-lg border border-gray-200 bg-white px-3 py-2 text-sm text-gray-800" - /> -
-
- -
- -
- setPeriodFromFilter(e.target.value)} - placeholder="от" - className="flex-1 rounded-lg border border-gray-200 bg-white px-3 py-2 text-sm text-gray-800" - /> - setPeriodToFilter(e.target.value)} - placeholder="до" - className="flex-1 rounded-lg border border-gray-200 bg-white px-3 py-2 text-sm text-gray-800" - /> -
-
- - {(view || typeFilter || statusFilter || employeeFilter || createdFromFilter || createdToFilter || periodFromFilter || periodToFilter) && ( - + + + + +
h.setCreatedFromFilter(e.target.value)} className="flex-1 rounded-lg border border-gray-200 bg-white px-3 py-2 text-sm text-gray-800" /> h.setCreatedToFilter(e.target.value)} className="flex-1 rounded-lg border border-gray-200 bg-white px-3 py-2 text-sm text-gray-800" />
+
h.setPeriodFromFilter(e.target.value)} className="flex-1 rounded-lg border border-gray-200 bg-white px-3 py-2 text-sm text-gray-800" /> h.setPeriodToFilter(e.target.value)} className="flex-1 rounded-lg border border-gray-200 bg-white px-3 py-2 text-sm text-gray-800" />
+ {[h.view, h.typeFilter, h.statusFilter, h.employeeFilter, h.createdFromFilter, h.createdToFilter, h.periodFromFilter, h.periodToFilter].some(Boolean) && ( + )}
)}
- {filteredRequests.length === 0 ? ( -
- -

Заявления не найдены

-
- ) : ( - filteredRequests.map((item) => { - const requestAuthor = item.employee || item.created_by; - const authorName = displayUserName(requestAuthor); - const approverName = displayUserName(item.approver || item.assigned_to); - const statusKey = String(item.status || "").toLowerCase(); - const requestTypeKey = String(item.type || item.request_type || "").toLowerCase(); - const status = statusMeta[statusKey] ?? defaultStatusMeta; - const authorLink = userProfileLink(requestAuthor); - const approverLink = userProfileLink(item.approver || item.assigned_to); - const requestTypeLabel = requestTypeLabels[requestTypeKey] || String(item.type || item.request_type || "Другое"); - const requestTitle = item.display_title || item.title || "Без заголовка"; - const canProcessThis = Boolean( - statusKey === "pending" && - requestAuthor?.id && - user?.id && - requestAuthor.id !== user.id && - (canManage || (canProcess && item.is_recipient)) - ); - const isAuthor = Boolean(requestAuthor?.id && user?.id && requestAuthor.id === user.id); - const canEditThis = isAuthor && !isFinal(statusKey); - const canCancelThis = isAuthor && !isFinal(statusKey); - const canDeleteThis = (isAuthor && !isFinal(statusKey)) || canManage; - const rowOpen = Boolean(expandedRows[item.id]); - const comments = commentsMap[item.id] || []; - const commentsOpen = Boolean(expandedComments[item.id]); - const departmentLabels = (item.departments || []) - .map((id) => departmentNameMap.get(Number(id)) || `Отдел #${id}`) - .join(", "); - const recipients = item.recipients || []; - const ccUsers = item.cc_users || []; - const visibleRecipients = recipients.slice(0, 2); - const visibleCcUsers = ccUsers.slice(0, 2); - const hiddenRecipientsCount = Math.max(0, recipients.length - visibleRecipients.length); - const hiddenCcCount = Math.max(0, ccUsers.length - visibleCcUsers.length); - const summaryText = item.comment || item.description; + {h.requests.length === 0 ? ( +

Заявления не найдены

+ ) : h.requests.map((item) => { + const requestAuthor = item.employee || item.created_by; + const authorName = displayUserName(requestAuthor); + const sk = String(item.status || "").toLowerCase(); + const tk = String(item.type || item.request_type || "").toLowerCase(); + const st = statusMeta[sk] ?? defaultStatusMeta; + const al = userProfileLink(requestAuthor as User, user?.id); + const tl = requestTypeLabels[tk] || String(item.type || item.request_type || "Другое"); + const tt = item.display_title || item.title || "Без заголовка"; + const canPr = Boolean(sk === "pending" && requestAuthor?.id && user?.id && requestAuthor.id !== user.id && (canManage || (canProcess && item.is_recipient))); + const isAuth = Boolean(requestAuthor?.id && user?.id && requestAuthor.id === user.id); + const rowOpen = Boolean(h.expandedRows[item.id]); + const comments = h.commentsMap[item.id] || []; + const commentsOpen = Boolean(h.expandedComments[item.id]); + const deptLabels = (item.departments || []).map((id) => h.departmentNameMap.get(Number(id)) || `Отдел #${id}`).join(", "); + const recs = item.recipients || []; + const ccs = item.cc_users || []; + const summary = item.comment || item.description; - return ( -
-
-
- - -
-
-
-
- {authorLink ? ( - - {requestAuthor?.avatar ? ( - {authorName} - ) : ( - - {(requestAuthor?.first_name?.[0] || requestAuthor?.last_name?.[0] || "?").toUpperCase()} - - )} - {authorName} - - ) : ( -
- - ? - - {authorName} -
- )} -
- - -
- Период: {item.date_from ? formatDate(item.date_from) : "—"}{item.date_to ? ` — ${formatDate(item.date_to)}` : ""} -
-
- -
- - {status.label} - -
-
- - {summaryText ? ( -

{summaryText}

- ) : null} - -
- {canCancelThis ? ( - - ) : null} - - +
+
+
+
+ {al ? ( + + {requestAuthor?.avatar ? {authorName} : {(requestAuthor?.first_name?.[0] || requestAuthor?.last_name?.[0] || "?").toUpperCase()}} + {authorName} + + ) : ( +
?{authorName}
)} +
+ - - {canEditThis ? ( - - ) : null} - - {canDeleteThis ? ( - - ) : null} - - {canProcessThis && ( - - - - - )} +
Период: {item.date_from ? formatDate(item.date_from) : "—"}{item.date_to ? ` — ${formatDate(item.date_to)}` : ""}
+
{st.label}
+
+ {summary &&

{summary}

} +
+ {isAuth && !h.isFinal(sk) && } + + {isAuth && !h.isFinal(sk) && } + {((isAuth && !h.isFinal(sk)) || canManage) && } + {canPr && }
- - {(rowOpen || commentsOpen) ? ( -
- {rowOpen ? ( -
-
-
- Решающий:{" "} - {(() => { - const approver = item.approver || item.assigned_to; - const aLink = userProfileLink(approver); - const aName = displayUserName(approver); - if (!approver) return —; - return aLink ? ( - {aName} - ) : ( - {aName} - ); - })()} -
-
- Создано:{" "} - {formatDate(item.created_at) || "—"} -
-
- Обновлено:{" "} - {formatDate(item.updated_at) || "—"} -
- {departmentLabels ? ( -
- Отделы:{" "} - {departmentLabels} -
- ) : null} -
- -
-
- Получатели: -
- {visibleRecipients.length > 0 ? visibleRecipients.map((recipient) => { - const recipientLink = userProfileLink(recipient); - const recipientName = displayUserName(recipient); - const chipContent = ( - <> - {recipient.avatar ? ( - {recipientName} - ) : ( - - {(recipient.first_name?.[0] || recipient.last_name?.[0] || "?").toUpperCase()} - - )} - {recipientName} - - ); - - return recipientLink ? ( - - {chipContent} - - ) : ( - - {chipContent} - - ); - }) : ( - {item.recipient_count ?? 0} - )} - {hiddenRecipientsCount > 0 && ( - - +{hiddenRecipientsCount} - - )} -
-
- -
- В копии: -
- {visibleCcUsers.length > 0 ? visibleCcUsers.map((ccUser) => { - const ccLink = userProfileLink(ccUser); - const ccName = displayUserName(ccUser); - const chipContent = ( - <> - {ccUser.avatar ? ( - {ccName} - ) : ( - - {(ccUser.first_name?.[0] || ccUser.last_name?.[0] || "?").toUpperCase()} - - )} - {ccName} - - ); - - return ccLink ? ( - - {chipContent} - - ) : ( - - {chipContent} - - ); - }) : ( - — - )} - {hiddenCcCount > 0 && ( - - +{hiddenCcCount} - - )} -
-
- - {(item.attachment || item.attachment_url) && ( -
- -
- )} -
-
- ) : null} - - {commentsOpen ? ( -
-
- {comments.length === 0 ? ( -

Комментариев пока нет

- ) : ( - comments.map((c) => { - const canDeleteComment = Boolean(c.author?.id && (user?.id === c.author.id || auth?.is_staff || auth?.is_superuser)); - return ( -
-
- {displayUserName(c.author)} -
- {formatDate(c.created_at)} - {canDeleteComment ? ( - - ) : null} -
-
-

{c.text}

-
- ); - }) - )} -
- -
- setCommentDrafts((prev) => ({ ...prev, [item.id]: e.target.value }))} - placeholder="Добавить комментарий" - className="flex-1 rounded-lg border border-gray-300 px-3 py-2 text-xs" - /> - -
-
- ) : null} -
- ) : null}
-
- ); - }) - )} + + {(rowOpen || commentsOpen) && ( +
+ {rowOpen && ( +
+
+
Решающий: {(() => { const a = item.approver || item.assigned_to; if (!a) return —; const alink = userProfileLink(a as User, user?.id); const aname = displayUserName(a); return alink ? {aname} : {aname}; })()}
+
Создано: {formatDate(item.created_at) || "—"}
+
Обновлено: {formatDate(item.updated_at) || "—"}
+ {deptLabels &&
Отделы: {deptLabels}
} +
+
+
Получатели:
{recs.slice(0,2).map((r) => { const rl = userProfileLink(r, user?.id); const rn = displayUserName(r); const c = <>{r.avatar ? {rn} : {(r.first_name?.[0] || r.last_name?.[0] || "?").toUpperCase()}}{rn}; return rl ? {c} : {c}; })}{recs.length === 0 && {item.recipient_count ?? 0}}{recs.length > 2 && +{recs.length - 2}}
+
В копии:
{ccs.slice(0,2).map((c) => { const cl = userProfileLink(c, user?.id); const cn = displayUserName(c); const ch = <>{c.avatar ? {cn} : {(c.first_name?.[0] || c.last_name?.[0] || "?").toUpperCase()}}{cn}; return cl ? {ch} : {ch}; })}{ccs.length === 0 && —}{ccs.length > 2 && +{ccs.length - 2}}
+ {(item.attachment || item.attachment_url) &&
} +
+
+ )} + {commentsOpen && ( +
+
{comments.length === 0 ?

Комментариев пока нет

: comments.map((c) => (
{displayUserName(c.author)}
{formatDate(c.created_at)}{Boolean(c.author?.id && (user?.id === c.author.id || auth?.is_staff || auth?.is_superuser)) && }

{c.text}

))}
+
h.setCommentDrafts((p) => ({ ...p, [item.id]: e.target.value }))} placeholder="Добавить комментарий" className="flex-1 rounded-lg border border-gray-300 px-3 py-2 text-xs" />
+
+ )} +
+ )} +
+ + ); + })} - {/* Элемент-наблюдатель для автоматической подгрузки */} - {nextPage && ( -
- {loadingMore && ( -
-
- Загрузка... -
- )} -
- )} + {h.nextPage &&
{h.loadingMore &&
Загрузка...
}
} )} )} - setDetailsRequest(null)} - title="Полная информация по заявлению" - size="lg" - > - {detailsRequest ? ( - (() => { - const detailAuthor = detailsRequest.employee || detailsRequest.created_by; - const detailApprover = detailsRequest.approver || detailsRequest.assigned_to; - const detailStatusKey = String(detailsRequest.status || "").toLowerCase(); - const detailTypeKey = String(detailsRequest.type || detailsRequest.request_type || "").toLowerCase(); - const detailStatus = statusMeta[detailStatusKey] ?? defaultStatusMeta; - const detailTypeLabel = requestTypeLabels[detailTypeKey] || String(detailsRequest.type || detailsRequest.request_type || "Другое"); - const detailTitle = detailsRequest.display_title || detailsRequest.title || "Без заголовка"; - const detailSummary = detailsRequest.comment || detailsRequest.description; - const detailDepartments = (detailsRequest.departments || []) - .map((id) => departmentNameMap.get(Number(id)) || `Отдел #${id}`); - const detailRecipients = detailsRequest.recipients || []; - const detailCcUsers = detailsRequest.cc_users || []; - const detailAttachmentUrl = detailsRequest.attachment_url || detailsRequest.attachment || ""; - const detailAttachmentName = detailAttachmentUrl ? decodeURIComponent(detailAttachmentUrl.split("/").pop() || "Вложение") : ""; - - return ( -
-
-
-
- - {detailTypeLabel} - - - {detailStatus.label} - -
-
-

{detailTitle}

- {detailSummary ? ( -
{detailSummary}
- ) : ( -

Описание отсутствует

- )} -
-
-
- -
-
-

Даты

-
-
-

Период

-

{detailsRequest.date_from ? formatDate(detailsRequest.date_from) : "—"}{detailsRequest.date_to ? ` — ${formatDate(detailsRequest.date_to)}` : ""}

-
-
-

Создано

-

{formatDate(detailsRequest.created_at) || "—"}

-
-
-

Обновлено

-

{formatDate(detailsRequest.updated_at) || "—"}

-
-
-
- -
-

Участники

-
-
-

Автор

-
- {detailAuthor ? renderUserBadge(detailAuthor, true) : —} -
-
-
-

Решающий

-
- {detailApprover ? renderUserBadge(detailApprover, true) : —} -
-
-
-
-
- -
-

Отделы

- {detailDepartments.length > 0 ? ( -
- {detailDepartments.map((department) => ( - - {department} - - ))} -
- ) : ( -

Отделы не указаны

- )} -
- -
-

Получатели

- {detailRecipients.length > 0 ? ( -
- {detailRecipients.map((recipient) => ( -
{renderUserBadge(recipient, true)}
- ))} -
- ) : ( -

Получатели не указаны

- )} -
- -
-

В копии

- {detailCcUsers.length > 0 ? ( -
- {detailCcUsers.map((ccUser) => ( -
{renderUserBadge(ccUser, true)}
- ))} -
- ) : ( -

Копия не указана

- )} -
- -
-

Вложение

- {detailAttachmentUrl ? ( -
- - - Открыть в новой вкладке - -
- ) : ( -

Вложение отсутствует

- )} -
+ {/* Detail modal */} + h.setDetailsRequest(null)} title="Полная информация по заявлению" size="lg"> + {h.detailsRequest && (() => { + const dr = h.detailsRequest; const da = dr.employee || dr.created_by; const dap = dr.approver || dr.assigned_to; + const ds = statusMeta[String(dr.status || "").toLowerCase()] ?? defaultStatusMeta; + const dtl = requestTypeLabels[String(dr.type || dr.request_type || "").toLowerCase()] || String(dr.type || dr.request_type || "Другое"); + const dau = dr.attachment_url || dr.attachment || ""; const dan = dau ? decodeURIComponent(dau.split("/").pop() || "Вложение") : ""; + return ( +
+
{dtl}{ds.label}

{dr.display_title || dr.title || "Без заголовка"}

{(dr.comment || dr.description) ?
{dr.comment || dr.description}
:

Описание отсутствует

}
+
+

Даты

Период

{dr.date_from ? formatDate(dr.date_from) : "—"}{dr.date_to ? ` — ${formatDate(dr.date_to)}` : ""}

Создано

{formatDate(dr.created_at) || "—"}

Обновлено

{formatDate(dr.updated_at) || "—"}

+

Участники

Автор

{da ? renderUserBadge(da as User, true) : —}

Решающий

{dap ? renderUserBadge(dap as User, true) : —}
- ); - })() - ) : null} +

Отделы

{(dr.departments || []).length > 0 ?
{(dr.departments || []).map((id) => {h.departmentNameMap.get(Number(id)) || `Отдел #${id}`})}
:

Отделы не указаны

}
+

Получатели

{(dr.recipients || []).length > 0 ?
{(dr.recipients || []).map((r) =>
{renderUserBadge(r, true)}
)}
:

Получатели не указаны

}
+

В копии

{(dr.cc_users || []).length > 0 ?
{(dr.cc_users || []).map((c) =>
{renderUserBadge(c, true)}
)}
:

Копия не указана

}
+

Вложение

{dau ?
Открыть в новой вкладке
:

Вложение отсутствует

}
+
+ ); + })()}
- {/* ===== Modal create/edit ===== */} - {isModalOpen && ( -
{ if (e.target === e.currentTarget) closeModal(); }}> + {/* Create/Edit modal */} + {h.isModalOpen && ( +
{ if (e.target === e.currentTarget) h.closeModal(); }}>
-
-

- {modalMode === "create" ? "Новое заявление" : "Редактировать заявление"} -

- -
- - {actionError ?

{actionError}

: null} - +

{h.modalMode === "create" ? "Новое заявление" : "Редактировать заявление"}

+ {h.actionError &&

{h.actionError}

}
- {/* Тема */} -
- - setForm((p) => ({ ...p, title: e.target.value }))} - placeholder="Тема заявления" - className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-sky-400 focus:outline-none focus:ring-2 focus:ring-sky-100" - /> -
- - {/* Тип */} -
- - -
- - {/* Период */} -
- -
- setForm((p) => ({ ...p, date_from: e.target.value }))} - className="flex-1 rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-sky-400 focus:outline-none focus:ring-2 focus:ring-sky-100" - /> - — - setForm((p) => ({ ...p, date_to: e.target.value }))} - className="flex-1 rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-sky-400 focus:outline-none focus:ring-2 focus:ring-sky-100" - /> -
-
- - {/* Решающий (recipients) */} - !user?.id || emp.id !== user.id).map((emp) => ({ id: emp.id, name: displayUserName(emp) }))} - selectedIds={form.recipient_ids} - onToggle={(id) => setForm((p) => ({ - ...p, - recipient_ids: p.recipient_ids.includes(id) ? p.recipient_ids.filter((x) => x !== id) : [...p.recipient_ids, id], - }))} - /> - - {/* В копии */} - !user?.id || emp.id !== user.id).map((emp) => ({ id: emp.id, name: displayUserName(emp) }))} - selectedIds={form.cc_user_ids} - onToggle={(id) => setForm((p) => ({ - ...p, - cc_user_ids: p.cc_user_ids.includes(id) ? p.cc_user_ids.filter((x) => x !== id) : [...p.cc_user_ids, id], - }))} - /> - - {/* Отдел */} - ({ id: d.id, name: d.name }))} - selectedIds={form.department_ids} - onToggle={(id) => setForm((p) => ({ - ...p, - department_ids: p.department_ids.includes(id) ? p.department_ids.filter((x) => x !== id) : [...p.department_ids, id], - }))} - /> - - {/* Описание */} -
- -