From b324e6128507b90a9d55389de87ef62f4bf1df40 Mon Sep 17 00:00:00 2001 From: Konstantin Makaev Date: Tue, 24 Feb 2026 14:13:26 +0300 Subject: [PATCH] =?UTF-8?q?=D0=91=D0=B5=D1=82=D0=B0=20=D0=B2=D0=B5=D1=80?= =?UTF-8?q?=D1=81=D0=B8=D1=8F=20(80%=20=D1=84=D1=83=D0=BD=D0=BA=D1=86?= =?UTF-8?q?=D0=B8=D0=BE=D0=BD=D0=B0=D0=BB=D0=B0)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/app/departments/[id]/page.tsx | 93 +++ frontend/src/app/departments/page.tsx | 5 +- frontend/src/app/employees/[id]/page.tsx | 102 +++ frontend/src/app/employees/page.tsx | 5 +- frontend/src/app/profile/page.tsx | 30 +- frontend/src/app/requests/page.tsx | 708 ++++++++++++++++++++- frontend/src/app/users/[id]/page.tsx | 139 ++++ frontend/src/app/users/page.tsx | 99 +++ frontend/src/types/api.ts | 36 +- 9 files changed, 1182 insertions(+), 35 deletions(-) create mode 100644 frontend/src/app/departments/[id]/page.tsx create mode 100644 frontend/src/app/employees/[id]/page.tsx create mode 100644 frontend/src/app/users/[id]/page.tsx create mode 100644 frontend/src/app/users/page.tsx diff --git a/frontend/src/app/departments/[id]/page.tsx b/frontend/src/app/departments/[id]/page.tsx new file mode 100644 index 00000000..6ce722d4 --- /dev/null +++ b/frontend/src/app/departments/[id]/page.tsx @@ -0,0 +1,93 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { useParams } from "next/navigation"; +import Link from "next/link"; +import { AppShell } from "../../../components/AppShell"; +import { apiClient } from "@/lib/api"; +import type { Department } from "@/types/api"; +import { ArrowLeft, Building2, Users } from "lucide-react"; + +export default function DepartmentDetailPage() { + const params = useParams<{ id: string }>(); + const departmentId = Number(params?.id); + + const [department, setDepartment] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + async function loadDepartment() { + if (!departmentId || Number.isNaN(departmentId)) { + setError("Некорректный идентификатор отдела"); + setLoading(false); + return; + } + + try { + setLoading(true); + setError(null); + const response = await apiClient.getDepartment(departmentId); + setDepartment(response); + } catch (e) { + console.error("Ошибка загрузки отдела:", e); + setError("Не удалось загрузить отдел"); + } finally { + setLoading(false); + } + } + + loadDepartment(); + }, [departmentId]); + + return ( + +
+ + + К списку отделов + + + {loading ? ( +
+
+

Загрузка отдела...

+
+ ) : error ? ( +
+

{error}

+
+ ) : department ? ( +
+
+
+
+ + Отдел +
+

{department.name}

+

{department.description || "Описание не заполнено"}

+

+ Руководитель:{" "} + + {department.head + ? `${department.head.last_name} ${department.head.first_name}`.trim() + : "Не назначен"} + +

+
+ +
+ + {department.employees_count ?? 0} +
+
+
+ ) : null} +
+ + ); +} diff --git a/frontend/src/app/departments/page.tsx b/frontend/src/app/departments/page.tsx index 0bf12cd3..74a28265 100644 --- a/frontend/src/app/departments/page.tsx +++ b/frontend/src/app/departments/page.tsx @@ -3,6 +3,7 @@ import { AppShell } from "../../components/AppShell"; import { apiClient } from "@/lib/api"; import { useEffect, useMemo, useState } from "react"; +import Link from "next/link"; import type { Department } from "@/types/api"; import { Building2, Search, Users } from "lucide-react"; @@ -79,7 +80,7 @@ export default function DepartmentsPage() { : "Не назначен"; return ( -
+

{department.name}

@@ -91,7 +92,7 @@ export default function DepartmentsPage() { {department.employees_count ?? 0}
-
+ ); }) )} diff --git a/frontend/src/app/employees/[id]/page.tsx b/frontend/src/app/employees/[id]/page.tsx new file mode 100644 index 00000000..08fca45a --- /dev/null +++ b/frontend/src/app/employees/[id]/page.tsx @@ -0,0 +1,102 @@ +"use client"; + +import { useEffect, useMemo, useState } from "react"; +import { useParams } from "next/navigation"; +import Link from "next/link"; +import Image from "next/image"; +import { AppShell } from "../../../components/AppShell"; +import { apiClient } from "@/lib/api"; +import type { User } from "@/types/api"; + +const BACKEND_URL = (process.env.NEXT_PUBLIC_BACKEND_URL || "").trim(); + +function resolveMediaUrl(url?: string | null): string { + const raw = (url || "").trim(); + if (!raw) return ""; + if (raw.startsWith("data:")) return raw; + if (/^https?:\/\//i.test(raw)) return raw; + if (raw.startsWith("/")) return raw; + + if (BACKEND_URL) { + const base = BACKEND_URL.replace(/\/$/, ""); + return `${base}/${raw.replace(/^\/+/, "")}`; + } + + return `/${raw.replace(/^\/+/, "")}`; +} + +export default function EmployeeDetailPage() { + const params = useParams<{ id: string }>(); + const employeeId = Number(params?.id); + + const [employee, setEmployee] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + async function loadEmployee() { + if (!employeeId || Number.isNaN(employeeId)) { + setError("Некорректный идентификатор сотрудника"); + setLoading(false); + return; + } + try { + setLoading(true); + setError(null); + const data = await apiClient.getEmployee(employeeId); + setEmployee(data); + } catch (e: any) { + setError(String(e?.message || "Не удалось загрузить профиль сотрудника")); + } finally { + setLoading(false); + } + } + + loadEmployee(); + }, [employeeId]); + + const fullName = useMemo(() => { + if (!employee) return ""; + return `${employee.last_name || ""} ${employee.first_name || ""} ${employee.patronymic || ""}`.trim() || "Сотрудник"; + }, [employee]); + + return ( + +
+ + ← К списку сотрудников + + + {loading ? ( +
+
+

Загрузка профиля...

+
+ ) : error ? ( +
+

{error}

+
+ ) : employee ? ( +
+
+
+ {employee.avatar ? ( + {fullName} + ) : ( + {`${employee.last_name?.[0] || ""}${employee.first_name?.[0] || ""}` || "С"} + )} +
+ +
+

{fullName}

+

Должность: {employee.position?.name || "—"}

+

Email: {employee.email || "—"}

+

Телефон: {employee.phone_number || "—"}

+
+
+
+ ) : null} +
+ + ); +} diff --git a/frontend/src/app/employees/page.tsx b/frontend/src/app/employees/page.tsx index 847e5174..2876c711 100644 --- a/frontend/src/app/employees/page.tsx +++ b/frontend/src/app/employees/page.tsx @@ -3,6 +3,7 @@ import { AppShell } from "../../components/AppShell"; import { apiClient } from "@/lib/api"; import { useEffect, useRef, useState } from "react"; +import Link from "next/link"; import type { User } from "@/types/api"; export default function EmployeesPage() { @@ -116,7 +117,7 @@ export default function EmployeesPage() { const statusColor = isOnline ? 'text-green-600' : 'text-gray-500'; return ( -
+
{employee.avatar ? ( @@ -143,7 +144,7 @@ export default function EmployeesPage() { )}
-
+ ); })} diff --git a/frontend/src/app/profile/page.tsx b/frontend/src/app/profile/page.tsx index a3ee3c9f..da063124 100644 --- a/frontend/src/app/profile/page.tsx +++ b/frontend/src/app/profile/page.tsx @@ -1,6 +1,7 @@ "use client"; import Image from "next/image"; +import Link from "next/link"; import { useEffect, useMemo, useRef, useState } from "react"; import { AppShell } from "../../components/AppShell"; import { useUser } from "@/contexts/UserContext"; @@ -275,15 +276,28 @@ export default function ProfilePage() { {departments.length === 0 ? (

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

) : ( - departments.map((d) => ( -
-
-

{d.name}

- {d.role_name ?

{d.role_name}

: null} + departments.map((d, index) => { + const deptId = Number((d as any).id ?? (d as any).department_id); + const isValidDeptId = Number.isFinite(deptId) && deptId > 0; + + return isValidDeptId ? ( + +
+

{d.name}

+ {d.role_name ?

{d.role_name}

: null} +
+ {d.is_head ? Руководитель : null} + + ) : ( +
+
+

{d.name}

+ {d.role_name ?

{d.role_name}

: null} +
+ {d.is_head ? Руководитель : null}
- {d.is_head ? Руководитель : null} -
- )) + ); + }) )}
diff --git a/frontend/src/app/requests/page.tsx b/frontend/src/app/requests/page.tsx index d8ae19e2..e76ef668 100644 --- a/frontend/src/app/requests/page.tsx +++ b/frontend/src/app/requests/page.tsx @@ -2,11 +2,44 @@ import { AppShell } from "../../components/AppShell"; import { apiClient } from "@/lib/api"; +import { useUser } from "@/contexts/UserContext"; +import { canManageRequests, canProcessRequests } from "@/lib/permissions"; import { useEffect, useMemo, useState } from "react"; -import type { Request } from "@/types/api"; -import { Search, FileSignature } from "lucide-react"; +import Link from "next/link"; +import type { Request, RequestComment, User, Department } from "@/types/api"; +import { Check, FileSignature, MessageSquare, Plus, Search, X } from "lucide-react"; -const statusMeta: Record = { +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", @@ -19,6 +52,10 @@ const statusMeta: Record = { + vacation: "Отпуск", + sick_leave: "Больничный", + day_off: "Отгул", + transfer: "Перевод", + dismissal: "Увольнение", + other: "Другое", +}; + const defaultStatusMeta = { label: "Неизвестный статус", className: "bg-gray-50 text-gray-700 ring-gray-200", @@ -46,18 +92,87 @@ function formatDate(value?: string): string { } export default function RequestsPage() { + const { user } = useUser(); const [requests, setRequests] = useState([]); + const [employees, setEmployees] = useState([]); + const [departments, setDepartments] = useState([]); + const [commentsMap, setCommentsMap] = 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 [dateFromFilter, setDateFromFilter] = useState(""); + const [dateToFilter, setDateToFilter] = useState(""); + 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 (dateFromFilter) params.date_from = dateFromFilter; + if (dateToFilter) params.date_to = dateToFilter; + return params; + }; useEffect(() => { async function loadRequests() { try { setLoading(true); setError(null); - const response = await apiClient.getRequests(); + const response = await apiClient.getRequests(buildRequestParams(1)); setRequests(response.results || []); + setNextPage(extractNextPage(response.next)); } catch (err) { console.error("Ошибка загрузки заявлений:", err); setError("Не удалось загрузить заявления"); @@ -67,6 +182,23 @@ export default function RequestsPage() { } loadRequests(); + }, [view, typeFilter, statusFilter, employeeFilter, dateFromFilter, dateToFilter]); + + useEffect(() => { + async function loadLookups() { + try { + const [employeesResponse, departmentsResponse] = await Promise.all([ + apiClient.getEmployees({ limit: 200 }), + apiClient.getDepartments({ limit: 200 }), + ]); + setEmployees(employeesResponse.results || []); + setDepartments(departmentsResponse.results || []); + } catch { + setEmployees([]); + setDepartments([]); + } + } + loadLookups(); }, []); const filteredRequests = useMemo(() => { @@ -80,14 +212,350 @@ export default function RequestsPage() { if (!q) return sorted; return sorted.filter((item) => { - const title = item.title.toLowerCase(); - const description = item.description.toLowerCase(); - const type = item.request_type.toLowerCase(); - const author = `${item.created_by?.last_name || ""} ${item.created_by?.first_name || ""}`.toLowerCase(); + 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); }); }, [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" | "submit") => { + try { + setBusyKey(`${mode}-${saveAs}`); + setActionError(null); + setActionSuccess(null); + + if (saveAs === "submit" && !form.sent_to_all_department && form.department_ids.length === 0 && form.recipient_ids.length === 0) { + setActionError("Укажите получателей или отделы."); + return; + } + + const payload = { + type: form.type, + title: form.title, + date_from: form.date_from || null, + date_to: form.date_to || null, + comment: form.comment, + department_ids: form.department_ids, + recipient_ids: form.recipient_ids, + cc_user_ids: form.cc_user_ids, + sent_to_all_department: form.sent_to_all_department, + 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) { + setActionError(String(e?.message || "Не удалось сохранить заявление")); + } finally { + setBusyKey(null); + } + }; + + const handleLoadMore = 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) => r.id)); + const uniqueChunk = chunk.filter((r) => !known.has(r.id)); + return [...prev, ...uniqueChunk]; + }); + setNextPage(extractNextPage(response.next)); + } catch (e: any) { + setError(String(e?.message || "Не удалось загрузить ещё заявления")); + } finally { + setLoadingMore(false); + } + }; + + 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 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 RequestEditor = ({ mode }: { mode: "create" | "edit" }) => ( +
+
+ + + setForm((p) => ({ ...p, title: e.target.value }))} + placeholder="Тема" + className="rounded-lg border border-gray-300 px-3 py-2 text-sm" + /> + + setForm((p) => ({ ...p, date_from: e.target.value }))} + className="rounded-lg border border-gray-300 px-3 py-2 text-sm" + /> + + setForm((p) => ({ ...p, date_to: e.target.value }))} + className="rounded-lg border border-gray-300 px-3 py-2 text-sm" + /> + + + + + + + +