diff --git a/src/components/admin/board/BoardAdminSkeleton.tsx b/src/components/admin/board/BoardAdminSkeleton.tsx new file mode 100644 index 00000000..4f695389 --- /dev/null +++ b/src/components/admin/board/BoardAdminSkeleton.tsx @@ -0,0 +1,33 @@ +import { Skeleton } from '@/components/ui'; +import { BoardCardSkeleton } from './BoardCardSkeleton'; + +export function BoardAdminSkeleton() { + return ( +
+ {/* Toolbar skeleton */} +
+ + +
+ + {/* Board card skeletons */} +
+
+ +
+ +
+ +
+ +
+ + + +
+ + +
+
+ ); +} diff --git a/src/components/admin/board/BoardCard.tsx b/src/components/admin/board/BoardCard.tsx index b0136160..e58942e0 100644 --- a/src/components/admin/board/BoardCard.tsx +++ b/src/components/admin/board/BoardCard.tsx @@ -13,6 +13,7 @@ import { MegaphoneIcon } from '@/components/board'; interface BoardCardProps extends React.HTMLAttributes { board: Board; onToggleComments?: (next: boolean) => void; + commentTogglePending?: boolean; onEdit?: () => void; onDelete?: () => void; draggable?: boolean; @@ -24,6 +25,7 @@ function BoardCard({ className, board, onToggleComments, + commentTogglePending = false, onEdit, onDelete, draggable = true, @@ -31,7 +33,8 @@ function BoardCard({ ref, ...props }: BoardCardProps) { - const { name, description, visibility, postCount, commentEnabled, editable } = board; + const { name, description, visibility, postCount, commentEnabled, editable, kind } = board; + const showCommentToggle = kind !== 'ALL'; const [deleteOpen, setDeleteOpen] = useState(false); const handleDeleteConfirm = () => { @@ -90,13 +93,13 @@ function BoardCard({ {/* Comment toggle */}
- {commentEnabled !== null && ( + {showCommentToggle && ( <>

댓글 허용

diff --git a/src/components/admin/board/BoardPageContent.tsx b/src/components/admin/board/BoardPageContent.tsx index a66129fe..90dc9be8 100644 --- a/src/components/admin/board/BoardPageContent.tsx +++ b/src/components/admin/board/BoardPageContent.tsx @@ -16,10 +16,9 @@ import { } from '@dnd-kit/sortable'; import { useQueryClient } from '@tanstack/react-query'; -import { Icon, Skeleton } from '@/components/ui'; +import { Icon } from '@/components/ui'; import { InfoCircleIcon } from '@/assets/icons'; import { BoardCard } from '@/components/admin/board/BoardCard'; -import { BoardCardSkeleton } from '@/components/admin/board/BoardCardSkeleton'; import { BoardToolbar } from '@/components/admin/board/BoardToolbar'; import { CreateBoardModal } from '@/components/admin/board/modal/CreateBoardModal'; import { EditBoardModal } from '@/components/admin/board/modal/EditBoardModal'; @@ -28,22 +27,43 @@ import { useBoardDragReorder } from '@/hooks/admin'; import { useAdminBoardsQuery } from '@/hooks/queries/admin/useAdminBoardsQuery'; import { useCreateBoardMutation } from '@/hooks/queries/admin/useCreateBoardMutation'; import { useUpdateBoardMutation } from '@/hooks/queries/admin/useUpdateBoardMutation'; +import { useToggleBoardCommentMutation } from '@/hooks/queries/admin/useToggleBoardCommentMutation'; import { useDeleteBoardMutation } from '@/hooks/queries/admin/useDeleteBoardMutation'; import { useUpdateBoardOrderMutation } from '@/hooks/queries/admin/useUpdateBoardOrderMutation'; import { adminBoardQueryKeys } from '@/hooks/queries/admin/boardQueryKeys'; import { ADMIN_BOARD_ERROR, getApiErrorCode, getApiErrorMessage } from '@/lib/apis/adminBoard'; import { useClubId } from '@/stores'; -import { toastError } from '@/stores/useToastStore'; +import { toastError, toastSuccess } from '@/stores/useToastStore'; import { toApiPermission } from '@/utils/admin/boardMapper'; import { MAX_CUSTOM_BOARDS } from '@/constants/admin/board.constants'; -import type { Board, BoardListCache } from '@/types/admin/board'; +import type { Board, BoardKind, BoardListCache } from '@/types/admin/board'; import type { BoardFormData } from '@/components/admin/board/modal/constants'; import { SortableBoardCard } from './SortableBoardCard'; +import { BoardAdminSkeleton } from './BoardAdminSkeleton'; function subscribeMounted() { return () => {}; } +const FIXED_BOARD_ORDER: BoardKind[] = ['ALL', 'NOTICE']; + +function compareFixedBoards(a: Board, b: Board) { + const ai = FIXED_BOARD_ORDER.indexOf(a.kind); + const bi = FIXED_BOARD_ORDER.indexOf(b.kind); + return (ai === -1 ? FIXED_BOARD_ORDER.length : ai) - (bi === -1 ? FIXED_BOARD_ORDER.length : bi); +} + +function handleNameMutationError(setNameError: (msg: string) => void) { + return (err: unknown) => { + const code = getApiErrorCode(err); + if (code === ADMIN_BOARD_ERROR.DUPLICATE_NAME) { + setNameError('같은 이름의 게시판이 이미 있어요.'); + } else { + toastError(getApiErrorMessage(err)); + } + }; +} + function BoardPageContent() { const clubId = useClubId(); const queryClient = useQueryClient(); @@ -53,6 +73,21 @@ function BoardPageContent() { const [createNameError, setCreateNameError] = useState(null); const [editingBoardId, setEditingBoardId] = useState(null); const [editNameError, setEditNameError] = useState(null); + const [pendingToggleIds, setPendingToggleIds] = useState>(() => new Set()); + + const addPendingToggle = (boardId: number) => + setPendingToggleIds((prev) => { + const next = new Set(prev); + next.add(boardId); + return next; + }); + + const removePendingToggle = (boardId: number) => + setPendingToggleIds((prev) => { + const next = new Set(prev); + next.delete(boardId); + return next; + }); const mounted = useSyncExternalStore( subscribeMounted, () => true, @@ -70,31 +105,26 @@ function BoardPageContent() { const { handleDragStart, handleDragEnd } = useBoardDragReorder({ onReorder: updateBoardOrder }); const { mutate: createBoard } = useCreateBoardMutation({ - onSuccess: () => setCreateModalOpen(false), - onError: (err) => { - const code = getApiErrorCode(err); - if (code === ADMIN_BOARD_ERROR.DUPLICATE_NAME) { - setCreateNameError('같은 이름의 게시판이 이미 있어요.'); - } else { - toastError(getApiErrorMessage(err)); - } + onSuccess: () => { + setCreateModalOpen(false); + toastSuccess('게시판이 생성되었어요.'); }, + onError: handleNameMutationError(setCreateNameError), }); const { mutate: updateBoard } = useUpdateBoardMutation({ - onSuccess: () => setEditingBoardId(null), - onError: (err) => { - const code = getApiErrorCode(err); - if (code === ADMIN_BOARD_ERROR.DUPLICATE_NAME) { - setEditNameError('같은 이름의 게시판이 이미 있어요.'); - } else { - toastError(getApiErrorMessage(err)); - } + onSuccess: () => { + setEditingBoardId(null); + toastSuccess('게시판이 수정되었어요.'); }, + onError: handleNameMutationError(setEditNameError), }); const { mutate: deleteBoard } = useDeleteBoardMutation({ - onSuccess: () => setEditingBoardId(null), + onSuccess: () => { + setEditingBoardId(null); + toastSuccess('게시판이 삭제되었어요.'); + }, onError: (err) => { const code = getApiErrorCode(err); if (code === ADMIN_BOARD_ERROR.BOARD_NOT_FOUND) { @@ -105,7 +135,8 @@ function BoardPageContent() { }, }); - const { mutate: toggleComment } = useUpdateBoardMutation({ + const { mutate: toggleComment } = useToggleBoardCommentMutation({ + onSuccess: () => toastSuccess('댓글 허용 설정을 변경했어요.'), onError: (err) => toastError(getApiErrorMessage(err)), }); @@ -114,36 +145,7 @@ function BoardPageContent() { useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }), ); - if (isLoading || !data) { - return ( -
- {/* Toolbar skeleton */} -
- - -
- - {/* Board card skeletons */} -
-
- -
- -
- -
- -
- - - -
- - -
-
- ); - } + if (isLoading || !data) return ; const { boards } = data; @@ -159,6 +161,8 @@ function BoardPageContent() { }; const handleToggleComments = (boardId: number, next: boolean) => { + if (pendingToggleIds.has(boardId)) return; + const target = boards.find((b) => b.boardId === boardId); if (!target) return; @@ -169,11 +173,12 @@ function BoardPageContent() { boards: prev.boards.map((b) => (b.boardId === boardId ? { ...b, commentEnabled: next } : b)), })); + addPendingToggle(boardId); + toggleComment( { boardId, body: { - name: target.name, description: target.description, commentEnabled: next, ...toApiPermission(target.visibility), @@ -183,6 +188,7 @@ function BoardPageContent() { onError: () => { queryClient.setQueryData(cacheKey, snapshot); }, + onSettled: () => removePendingToggle(boardId), }, ); }; @@ -225,7 +231,7 @@ function BoardPageContent() { ? boards.filter((b) => b.name.toLowerCase().includes(query)) : boards; - const fixedBoards = filteredBoards.filter((b) => !b.editable); + const fixedBoards = filteredBoards.filter((b) => !b.editable).sort(compareFixedBoards); const customBoards = filteredBoards.filter((b) => b.editable); const totalCustomCount = boards.filter((b) => b.editable).length; const reachedLimit = totalCustomCount >= MAX_CUSTOM_BOARDS; @@ -233,6 +239,16 @@ function BoardPageContent() { const editingBoard = editingBoardId !== null ? (boards.find((b) => b.boardId === editingBoardId) ?? null) : null; + const getToggleProps = (board: Board) => ({ + onToggleComments: (next: boolean) => handleToggleComments(board.boardId, next), + commentTogglePending: pendingToggleIds.has(board.boardId), + }); + + const getEditableProps = (board: Board) => ({ + onEdit: () => setEditingBoardId(board.boardId), + onDelete: () => handleMoveToTrash(board), + }); + return (
setTrashModalOpen(true)} onCreateClick={ reachedLimit - ? () => toastError(`추가 게시판은 최대 ${MAX_CUSTOM_BOARDS}개까지 만들 수 있어요.`) + ? () => toastError(`게시판은 최대 ${MAX_CUSTOM_BOARDS}개까지 만들 수 있어요.`) : () => setCreateModalOpen(true) } /> @@ -254,11 +270,7 @@ function BoardPageContent() { {fixedBoards.map((board, index) => ( {index > 0 &&
} - handleToggleComments(board.boardId, next)} - /> + ))}
@@ -276,9 +288,8 @@ function BoardPageContent() { key={board.boardId} board={board} draggable={false} - onToggleComments={(next) => handleToggleComments(board.boardId, next)} - onEdit={() => setEditingBoardId(board.boardId)} - onDelete={() => handleMoveToTrash(board)} + {...getToggleProps(board)} + {...getEditableProps(board)} /> ))}
@@ -298,9 +309,8 @@ function BoardPageContent() { handleToggleComments(board.boardId, next)} - onEdit={() => setEditingBoardId(board.boardId)} - onDelete={() => handleMoveToTrash(board)} + {...getToggleProps(board)} + {...getEditableProps(board)} /> ))}
@@ -311,7 +321,7 @@ function BoardPageContent() {

- 추가 게시판은 최대 {MAX_CUSTOM_BOARDS}개입니다. + 게시판 추가는 최대 {MAX_CUSTOM_BOARDS}개까지 가능 합니다.

diff --git a/src/components/admin/board/SortableBoardCard.tsx b/src/components/admin/board/SortableBoardCard.tsx index faa2ebc2..bf22dcd0 100644 --- a/src/components/admin/board/SortableBoardCard.tsx +++ b/src/components/admin/board/SortableBoardCard.tsx @@ -6,6 +6,7 @@ import { BoardCard } from './BoardCard'; interface SortableBoardCardProps { board: Board; onToggleComments: (next: boolean) => void; + commentTogglePending?: boolean; onEdit: () => void; onDelete: () => void; } @@ -13,6 +14,7 @@ interface SortableBoardCardProps { export function SortableBoardCard({ board, onToggleComments, + commentTogglePending, onEdit, onDelete, }: SortableBoardCardProps) { @@ -25,6 +27,7 @@ export function SortableBoardCard({ ref={setNodeRef} board={board} onToggleComments={onToggleComments} + commentTogglePending={commentTogglePending} onEdit={onEdit} onDelete={onDelete} dragHandleProps={{ ...attributes, ...listeners }} diff --git a/src/components/admin/member/CardinalPillList.tsx b/src/components/admin/member/CardinalPillList.tsx index 0d0ef9f6..bd0ea215 100644 --- a/src/components/admin/member/CardinalPillList.tsx +++ b/src/components/admin/member/CardinalPillList.tsx @@ -1,7 +1,9 @@ 'use client'; import { MoreVerticalIcon } from '@/assets/icons'; -import { AddCardinalButton, AddCardinalModal, CardinalCard } from '@/components/admin'; +import { AddCardinalButton } from './AddCardinalButton'; +import { AddCardinalModal } from './modal/AddCardinalModal'; +import { CardinalCard } from './CardinalCard'; import { DropdownMenu, DropdownMenuContent, diff --git a/src/components/admin/member/modal/MemberDetailModal.tsx b/src/components/admin/member/modal/MemberDetailModal.tsx index 5f2d4792..b3dc6ab7 100644 --- a/src/components/admin/member/modal/MemberDetailModal.tsx +++ b/src/components/admin/member/modal/MemberDetailModal.tsx @@ -40,6 +40,8 @@ function MemberDetailModal({ const personalInfo = getPersonalInfo(member); const activityStats = getActivityStats(member); + const cardinals = parseCardinals(member.cardinal); + const latestCardinal = cardinals.at(-1); const footerActions = getFooterActions({ memberRole: member.memberRole, status: member.status, @@ -76,10 +78,7 @@ function MemberDetailModal({
{member.name} - {/* TODO: 응답 기수 정렬 확인 후 수정 (다중 기수일 때 첫 숫자만 노출됨) */} - - {parseInt(member.cardinal, 10) || member.cardinal || '-'}기 - + {latestCardinal ?? '-'}기
@@ -100,7 +99,7 @@ function MemberDetailModal({
활동 기수
- {parseCardinals(member.cardinal).map((c) => ( + {cardinals.map((c) => ( { reorderTimerRef.current = null; onReorder(reorderedIds, { - onError: () => queryClient.setQueryData(cacheKey, snapshot), + onError: () => queryClient.invalidateQueries({ queryKey: cacheKey }), }); }, debounceMs); }; diff --git a/src/hooks/queries/admin/index.ts b/src/hooks/queries/admin/index.ts index 8c1edb14..054bc9a1 100644 --- a/src/hooks/queries/admin/index.ts +++ b/src/hooks/queries/admin/index.ts @@ -8,5 +8,6 @@ export { useAdminSessionList } from './useAdminScheduleQueries'; export { useAdminBoardsQuery } from './useAdminBoardsQuery'; export { useCreateBoardMutation } from './useCreateBoardMutation'; export { useUpdateBoardMutation } from './useUpdateBoardMutation'; +export { useToggleBoardCommentMutation } from './useToggleBoardCommentMutation'; export { useDeleteBoardMutation } from './useDeleteBoardMutation'; export { useUpdateBoardOrderMutation } from './useUpdateBoardOrderMutation'; diff --git a/src/hooks/queries/admin/useCreateBoardMutation.ts b/src/hooks/queries/admin/useCreateBoardMutation.ts index ee820b03..64296bf8 100644 --- a/src/hooks/queries/admin/useCreateBoardMutation.ts +++ b/src/hooks/queries/admin/useCreateBoardMutation.ts @@ -1,16 +1,16 @@ import { useMutation, useQueryClient } from '@tanstack/react-query'; -import type { AxiosError } from 'axios'; import { adminBoardApi, type CreateBoardBody } from '@/lib/apis/adminBoard'; import { useClubId } from '@/stores'; import type { MutationCallbacks } from '@/types/common'; import { adminBoardQueryKeys } from './boardQueryKeys'; -export function useCreateBoardMutation(callbacks?: MutationCallbacks) { +export function useCreateBoardMutation(callbacks?: MutationCallbacks) { const clubId = useClubId(); const queryClient = useQueryClient(); return useMutation({ + mutationKey: ['admin', 'board', 'create'], mutationFn: (body: CreateBoardBody) => { if (!clubId) throw new Error('clubId is required'); return adminBoardApi.createBoard(clubId, body); diff --git a/src/hooks/queries/admin/useDeleteBoardMutation.ts b/src/hooks/queries/admin/useDeleteBoardMutation.ts index 6d547d9a..808d5281 100644 --- a/src/hooks/queries/admin/useDeleteBoardMutation.ts +++ b/src/hooks/queries/admin/useDeleteBoardMutation.ts @@ -1,16 +1,16 @@ import { useMutation, useQueryClient } from '@tanstack/react-query'; -import type { AxiosError } from 'axios'; import { adminBoardApi } from '@/lib/apis/adminBoard'; import { useClubId } from '@/stores'; import type { MutationCallbacks } from '@/types/common'; import { adminBoardQueryKeys } from './boardQueryKeys'; -export function useDeleteBoardMutation(callbacks?: MutationCallbacks) { +export function useDeleteBoardMutation(callbacks?: MutationCallbacks) { const clubId = useClubId(); const queryClient = useQueryClient(); return useMutation({ + mutationKey: ['admin', 'board', 'delete'], mutationFn: (boardId: number) => { if (!clubId) throw new Error('clubId is required'); return adminBoardApi.deleteBoard(clubId, boardId); diff --git a/src/hooks/queries/admin/useToggleBoardCommentMutation.ts b/src/hooks/queries/admin/useToggleBoardCommentMutation.ts new file mode 100644 index 00000000..a6602841 --- /dev/null +++ b/src/hooks/queries/admin/useToggleBoardCommentMutation.ts @@ -0,0 +1,26 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query'; + +import { adminBoardApi, type UpdateBoardCommentBody } from '@/lib/apis/adminBoard'; +import { useClubId } from '@/stores'; +import type { MutationCallbacks } from '@/types/common'; +import { adminBoardQueryKeys } from './boardQueryKeys'; + +export function useToggleBoardCommentMutation(callbacks?: MutationCallbacks) { + const clubId = useClubId(); + const queryClient = useQueryClient(); + + return useMutation({ + mutationKey: ['admin', 'board', 'comment-toggle'], + mutationFn: ({ boardId, body }: { boardId: number; body: UpdateBoardCommentBody }) => { + if (!clubId) throw new Error('clubId is required'); + return adminBoardApi.updateBoardComment(clubId, boardId, body); + }, + onSuccess: async () => { + await queryClient.invalidateQueries({ queryKey: adminBoardQueryKeys.list(clubId) }); + callbacks?.onSuccess?.(); + }, + onError: callbacks?.onError, + onMutate: callbacks?.onMutate, + onSettled: callbacks?.onSettled, + }); +} diff --git a/src/hooks/queries/admin/useUpdateBoardMutation.ts b/src/hooks/queries/admin/useUpdateBoardMutation.ts index 8dc68b66..aef21bc9 100644 --- a/src/hooks/queries/admin/useUpdateBoardMutation.ts +++ b/src/hooks/queries/admin/useUpdateBoardMutation.ts @@ -1,16 +1,16 @@ import { useMutation, useQueryClient } from '@tanstack/react-query'; -import type { AxiosError } from 'axios'; import { adminBoardApi, type UpdateBoardBody } from '@/lib/apis/adminBoard'; import { useClubId } from '@/stores'; import type { MutationCallbacks } from '@/types/common'; import { adminBoardQueryKeys } from './boardQueryKeys'; -export function useUpdateBoardMutation(callbacks?: MutationCallbacks) { +export function useUpdateBoardMutation(callbacks?: MutationCallbacks) { const clubId = useClubId(); const queryClient = useQueryClient(); return useMutation({ + mutationKey: ['admin', 'board', 'update'], mutationFn: ({ boardId, body }: { boardId: number; body: UpdateBoardBody }) => { if (!clubId) throw new Error('clubId is required'); return adminBoardApi.updateBoard(clubId, boardId, body); diff --git a/src/hooks/queries/admin/useUpdateBoardOrderMutation.ts b/src/hooks/queries/admin/useUpdateBoardOrderMutation.ts index 5ba57e55..f1559d2e 100644 --- a/src/hooks/queries/admin/useUpdateBoardOrderMutation.ts +++ b/src/hooks/queries/admin/useUpdateBoardOrderMutation.ts @@ -1,5 +1,4 @@ import { useMutation, useQueryClient } from '@tanstack/react-query'; -import type { AxiosError } from 'axios'; import { adminBoardApi, getApiErrorMessage } from '@/lib/apis/adminBoard'; import { useClubId } from '@/stores'; @@ -7,11 +6,12 @@ import { toastError, toastSuccess } from '@/stores/useToastStore'; import type { MutationCallbacks } from '@/types/common'; import { adminBoardQueryKeys } from './boardQueryKeys'; -export function useUpdateBoardOrderMutation(callbacks?: MutationCallbacks) { +export function useUpdateBoardOrderMutation(callbacks?: MutationCallbacks) { const clubId = useClubId(); const queryClient = useQueryClient(); return useMutation({ + mutationKey: ['admin', 'boards', 'reorder', clubId], mutationFn: (boardIds: number[]) => { if (!clubId) throw new Error('clubId is required'); return adminBoardApi.updateBoardOrder(clubId, boardIds); @@ -20,7 +20,7 @@ export function useUpdateBoardOrderMutation(callbacks?: MutationCallbacks { + onError: (err) => { toastError(getApiErrorMessage(err)); queryClient.invalidateQueries({ queryKey: adminBoardQueryKeys.list(clubId) }); callbacks?.onError?.(err); diff --git a/src/lib/apis/adminBoard.ts b/src/lib/apis/adminBoard.ts index 0f4ae258..1d37cf92 100644 --- a/src/lib/apis/adminBoard.ts +++ b/src/lib/apis/adminBoard.ts @@ -53,6 +53,14 @@ export interface UpdateBoardBody { isPrivate: boolean; } +/** 댓글 허용 토글 전용 body. 공지 게시판도 안전하게 PATCH 가능하도록 name 미포함 */ +export interface UpdateBoardCommentBody { + description: string; + commentEnabled: boolean; + writePermission: AdminBoardWritePermission; + isPrivate: boolean; +} + export const adminBoardApi = { getBoards: (clubId: string) => apiClient.get>(`/admin/clubs/${clubId}/boards`), @@ -63,6 +71,9 @@ export const adminBoardApi = { updateBoard: (clubId: string, boardId: number, body: UpdateBoardBody) => apiClient.patch>(`/admin/clubs/${clubId}/boards/${boardId}`, body), + updateBoardComment: (clubId: string, boardId: number, body: UpdateBoardCommentBody) => + apiClient.patch>(`/admin/clubs/${clubId}/boards/${boardId}`, body), + deleteBoard: (clubId: string, boardId: number) => apiClient.delete>(`/admin/clubs/${clubId}/boards/${boardId}`), diff --git a/src/types/admin/board.d.ts b/src/types/admin/board.d.ts index d381797c..9ebc3660 100644 --- a/src/types/admin/board.d.ts +++ b/src/types/admin/board.d.ts @@ -8,7 +8,7 @@ export interface Board { kind: BoardKind; visibility: BoardVisibility; postCount: number; - /** 댓글 허용 여부. ALL/NOTICE 등 일부 게시판은 토글이 노출되지 않음 */ + /** 댓글 허용 여부. 전체(ALL) 게시판은 토글이 노출되지 않아 null 일 수 있음 */ commentEnabled: boolean | null; /** 사용자가 수정/삭제할 수 있는 게시판인지 (커스텀 게시판) */ editable: boolean; diff --git a/src/utils/admin/boardMapper.ts b/src/utils/admin/boardMapper.ts index fbf9b82f..7116d69f 100644 --- a/src/utils/admin/boardMapper.ts +++ b/src/utils/admin/boardMapper.ts @@ -28,7 +28,7 @@ export function toBoard(dto: AdminBoardDto): Board { kind: dto.type, visibility: mapVisibility(dto.writePermission, dto.isPrivate), postCount: dto.postCount, - commentEnabled: dto.type === 'ALL' || dto.type === 'NOTICE' ? null : dto.commentEnabled, + commentEnabled: dto.type === 'ALL' ? null : dto.commentEnabled, editable: dto.type === 'GENERAL', }; }