diff --git a/frontend/src/components/requests/RequestListItem.tsx b/frontend/src/components/requests/RequestListItem.tsx
index 07758b73..32ed7056 100644
--- a/frontend/src/components/requests/RequestListItem.tsx
+++ b/frontend/src/components/requests/RequestListItem.tsx
@@ -21,6 +21,7 @@ import {
import type { Ref } from "react";
import { CommentComposer, CommentDeleteButton } from "@/components/shared/CommentControls";
import { RequestAvatar } from "./RequestAvatar";
+import { getRequestActionState } from "./requestActions";
import { RequestUserBadge } from "./RequestUserBadge";
type RequestListItemProps = {
@@ -85,19 +86,13 @@ export function RequestListItem({
const requestAuthor = request.employee || request.created_by;
const authorName = displayUserName(requestAuthor);
const authorFallback = (requestAuthor?.first_name?.[0] || requestAuthor?.last_name?.[0] || "?").toUpperCase();
- const statusKey = String(request.status || "").toLowerCase();
+ const actionState = getRequestActionState(request, currentUserId, isFinal);
+ const { canCancel, canComment, canDelete, canEdit, canProcess, hasSecondaryActions, statusKey } = actionState;
const typeKey = String(request.type || request.request_type || "").toLowerCase();
const status = statusMeta[statusKey] ?? defaultStatusMeta;
const authorLink = requestAuthor ? userProfileLink(requestAuthor, currentUserId) : null;
const typeLabel = requestTypeLabels[typeKey] || String(request.type || request.request_type || "Другое");
const title = request.display_title || request.title || "Без заголовка";
- const canProcess = Boolean(statusKey === "pending" && request.can_decide);
- const isAuthor = Boolean(requestAuthor?.id && currentUserId && requestAuthor.id === currentUserId);
- const canCancel = Boolean(isAuthor && !isFinal(statusKey));
- const canEdit = Boolean(isAuthor && !isFinal(statusKey));
- const canDelete = Boolean(isAuthor && !isFinal(statusKey));
- const hasSecondaryActions = canCancel || canEdit || canDelete;
- const canComment = statusKey !== "draft";
const departmentLabels = (request.departments || [])
.map((id) => departmentNameMap.get(Number(id)) || `Отдел #${id}`)
.join(", ");
diff --git a/frontend/src/components/requests/requestActions.ts b/frontend/src/components/requests/requestActions.ts
new file mode 100644
index 00000000..a5806eb3
--- /dev/null
+++ b/frontend/src/components/requests/requestActions.ts
@@ -0,0 +1,38 @@
+import type { Request } from "@/types/api";
+
+export type RequestActionState = {
+ canProcess: boolean;
+ canCancel: boolean;
+ canEdit: boolean;
+ canDelete: boolean;
+ canComment: boolean;
+ hasSecondaryActions: boolean;
+ isAuthor: boolean;
+ statusKey: string;
+};
+
+export function getRequestActionState(
+ request: Request,
+ currentUserId: number | null | undefined,
+ isFinal: (status?: string) => boolean,
+): RequestActionState {
+ const requestAuthor = request.employee || request.created_by;
+ const statusKey = String(request.status || "").toLowerCase();
+ const canProcess = Boolean(statusKey === "pending" && request.can_decide);
+ const isAuthor = Boolean(requestAuthor?.id && currentUserId && requestAuthor.id === currentUserId);
+ const canCancel = Boolean(isAuthor && !isFinal(statusKey));
+ const canEdit = Boolean(isAuthor && !isFinal(statusKey));
+ const canDelete = Boolean(isAuthor && !isFinal(statusKey));
+ const canComment = statusKey !== "draft";
+
+ return {
+ canProcess,
+ canCancel,
+ canEdit,
+ canDelete,
+ canComment,
+ hasSecondaryActions: canCancel || canEdit || canDelete,
+ isAuthor,
+ statusKey,
+ };
+}
diff --git a/frontend/src/components/users/ChangeUserPositionModal.tsx b/frontend/src/components/users/ChangeUserPositionModal.tsx
new file mode 100644
index 00000000..80e953b9
--- /dev/null
+++ b/frontend/src/components/users/ChangeUserPositionModal.tsx
@@ -0,0 +1,105 @@
+"use client";
+
+import { Modal } from "@/components/ui";
+import type { Position, User } from "@/types/api";
+
+type ChangeUserPositionModalProps = {
+ isOpen: boolean;
+ onClose: () => void;
+ onSave: () => void;
+ onPositionChange: (value: string) => void;
+ positions: Position[];
+ positionsLoading: boolean;
+ positionValue: string;
+ actionLoading: string | null;
+ person: User | null;
+ error?: string | null;
+};
+
+export default function ChangeUserPositionModal({
+ isOpen,
+ onClose,
+ onSave,
+ onPositionChange,
+ positions,
+ positionsLoading,
+ positionValue,
+ actionLoading,
+ person,
+ error,
+}: ChangeUserPositionModalProps) {
+ const isSaving = actionLoading === "position";
+
+ const footer = (
+
+
+
+
+ );
+
+ return (
+
+
+
+
+
+ {person?.last_name} {person?.first_name} {person?.patronymic || ""}
+
+
+ Текущая должность: {person?.position?.name || "Не указана"}
+
+
+
+
+
+ {positionsLoading ? (
+ Загрузка должностей...
+ ) : null}
+
+ {error ? (
+ {error}
+ ) : null}
+
+
+
+ );
+}
diff --git a/frontend/src/hooks/useUserDetailPage.ts b/frontend/src/hooks/useUserDetailPage.ts
index 996e392b..de727e31 100644
--- a/frontend/src/hooks/useUserDetailPage.ts
+++ b/frontend/src/hooks/useUserDetailPage.ts
@@ -17,7 +17,7 @@ import {
type UserProfileTextField,
sortEmployeeActions,
} from "@/lib/users/userDetailUtils";
-import type { Chat, EmployeeAction, User } from "@/types/api";
+import type { Chat, EmployeeAction, Position, User } from "@/types/api";
type ChatWithMemberIds = Chat & { member_ids?: number[] };
@@ -36,9 +36,14 @@ export function useUserDetailPage(userId: number, currentUser: User | null) {
const [copySuccess, setCopySuccess] = useState
(null);
const [isEditModalOpen, setIsEditModalOpen] = useState(false);
const [isActionModalOpen, setIsActionModalOpen] = useState(false);
+ const [isPositionModalOpen, setIsPositionModalOpen] = useState(false);
const [editForm, setEditForm] = useState(createEmptyEditForm);
const [actionForm, setActionForm] = useState(createEmptyActionForm);
const [actionLoading, setActionLoading] = useState(null);
+ const [positions, setPositions] = useState([]);
+ const [positionsLoading, setPositionsLoading] = useState(false);
+ const [positionsError, setPositionsError] = useState(null);
+ const [positionValue, setPositionValue] = useState("");
const refreshPerson = useCallback(async () => {
const data = await apiClient.getEmployee(userId) as User;
@@ -211,6 +216,33 @@ export function useUserDetailPage(userId: number, currentUser: User | null) {
setEditForm((current) => ({ ...current, avatarFile: null, avatarPreview: null }));
}, []);
+ const handleOpenPositionModal = useCallback(async () => {
+ if (!person) return;
+
+ setPositionValue(person.position?.id ? String(person.position.id) : "");
+ setPositionsError(null);
+ setIsPositionModalOpen(true);
+
+ if (positions.length > 0 || positionsLoading) return;
+
+ try {
+ setPositionsLoading(true);
+ const response = await apiClient.getPositions() as Position[];
+ setPositions(response);
+ } catch (loadError) {
+ setPositionsError(
+ getErrorMessage(loadError, "Не удалось загрузить список должностей"),
+ );
+ } finally {
+ setPositionsLoading(false);
+ }
+ }, [person, positions.length, positionsLoading]);
+
+ const handleClosePositionModal = useCallback(() => {
+ setIsPositionModalOpen(false);
+ setPositionsError(null);
+ }, []);
+
const handleAvatarChange = useCallback((event: ChangeEvent) => {
const file = event.target.files?.[0];
if (!file) return;
@@ -276,6 +308,26 @@ export function useUserDetailPage(userId: number, currentUser: User | null) {
}
}, [editForm, handleCloseEditModal, person, refreshPerson, userId]);
+ const handleSavePosition = useCallback(async () => {
+ if (!person || !userId) return;
+
+ setActionLoading("position");
+ setPositionsError(null);
+ try {
+ await apiClient.updateEmployee(userId, {
+ position_id: positionValue ? Number(positionValue) : null,
+ });
+ await refreshPerson();
+ handleClosePositionModal();
+ } catch (saveError) {
+ setPositionsError(
+ getErrorMessage(saveError, "Не удалось изменить должность"),
+ );
+ } finally {
+ setActionLoading(null);
+ }
+ }, [handleClosePositionModal, person, positionValue, refreshPerson, userId]);
+
const canEdit = Boolean(currentUser && (
currentUser.auth?.is_staff ||
currentUser.auth?.is_superuser ||
@@ -310,23 +362,32 @@ export function useUserDetailPage(userId: number, currentUser: User | null) {
handleAvatarChange,
handleCloseActionModal,
handleCloseEditModal,
+ handleClosePositionModal,
handleCopyToClipboard: copyToClipboard,
handleDeleteAction,
handleEditAction,
handleOpenActionModal,
handleOpenEditModal,
+ handleOpenPositionModal,
handleSaveAction,
handleSaveEdit,
+ handleSavePosition,
handleStartChat,
initials,
isActionModalOpen,
isEditModalOpen,
+ isPositionModalOpen,
latestAction,
loading,
person,
+ positionValue,
+ positions,
+ positionsError,
+ positionsLoading,
setActionField,
setAvatarFailed,
setEditField,
+ setPositionValue,
sortedActions,
};
-}
\ No newline at end of file
+}
diff --git a/frontend/src/lib/api/employees.ts b/frontend/src/lib/api/employees.ts
index a34259a9..622d4b02 100644
--- a/frontend/src/lib/api/employees.ts
+++ b/frontend/src/lib/api/employees.ts
@@ -24,6 +24,7 @@ export function createEmployeesApi(request: RequestFn) {
return request(`/api/v1/employees/${qs ? '?' + qs : ''}`);
},
getEmployee: (id: number | string) => request(`/api/v1/employees/${id}/`),
+ getPositions: () => request('/api/v1/positions/'),
addEmployeeSkill: (
id: number | string,
data: { skill_id?: number; name?: string },
diff --git a/frontend/src/types/api.ts b/frontend/src/types/api.ts
index b9b9d0f9..f74d3dc9 100644
--- a/frontend/src/types/api.ts
+++ b/frontend/src/types/api.ts
@@ -646,6 +646,11 @@ export interface Message {
count: number;
users?: number[];
user_names?: string[];
+ user_details?: Array<{
+ id: number;
+ name: string;
+ avatar?: string | null;
+ }>;
}>;
}