diff --git a/momogo-api/src/main/java/com/momogo/api/auth/details/MoMoGoUserDetails.java b/momogo-api/src/main/java/com/momogo/api/auth/details/MoMoGoUserDetails.java index 2be908c..6ab5da0 100644 --- a/momogo-api/src/main/java/com/momogo/api/auth/details/MoMoGoUserDetails.java +++ b/momogo-api/src/main/java/com/momogo/api/auth/details/MoMoGoUserDetails.java @@ -51,7 +51,7 @@ public String getName() { @Override public boolean isAccountNonLocked() { // 계정 정지(밴) 상태인 경우 잠금(Locked) 처리 - return !Boolean.TRUE.equals(userResponse.banned()); + return !Boolean.TRUE.equals(userResponse.isBanned()); } @Override diff --git a/momogo-api/src/main/java/com/momogo/api/auth/details/OAuth2UserDetailsService.java b/momogo-api/src/main/java/com/momogo/api/auth/details/OAuth2UserDetailsService.java index ec1c210..576aafc 100644 --- a/momogo-api/src/main/java/com/momogo/api/auth/details/OAuth2UserDetailsService.java +++ b/momogo-api/src/main/java/com/momogo/api/auth/details/OAuth2UserDetailsService.java @@ -64,17 +64,17 @@ public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2Authentic .orElseGet(() -> registerSocialUser(attributes, socialType)); return new MoMoGoUserDetails( - new UserResponse( - user.getId(), - user.getEmail(), - user.getName(), - user.getProfileImageUrl(), - user.getRole(), - user.getSocial(), - user.getIsBanned(), - user.getCreatedAt(), - user.getDeletedAt() - ), + UserResponse.builder() + .id(user.getId()) + .email(user.getEmail()) + .name(user.getName()) + .profileImageUrl(user.getProfileImageUrl()) + .role(user.getRole()) + .social(user.getSocial()) + .isBanned(user.getIsBanned()) + .createdAt(user.getCreatedAt()) + .deletedAt(user.getDeletedAt()) + .build(), user.getPassword(), attributes.attributes() ); diff --git a/momogo-api/src/main/java/com/momogo/api/auth/jwt/JwtTokenProvider.java b/momogo-api/src/main/java/com/momogo/api/auth/jwt/JwtTokenProvider.java index 54ee9b7..4a43c40 100644 --- a/momogo-api/src/main/java/com/momogo/api/auth/jwt/JwtTokenProvider.java +++ b/momogo-api/src/main/java/com/momogo/api/auth/jwt/JwtTokenProvider.java @@ -226,17 +226,13 @@ public MoMoGoUserDetails parseAccessToken(String token) { role = UserRole.valueOf(roleString); } - UserResponse userResponse = new UserResponse( - userId, - userEmail, - name, - null, - role, - null, - false, - null, - null - ); + UserResponse userResponse = UserResponse.builder() + .id(userId) + .email(userEmail) + .name(name) + .role(role) + .isBanned(false) + .build(); log.info("[TokenProvider] parseAccessToken 완료: UserResponse 생성"); return new MoMoGoUserDetails(userResponse, null); diff --git a/momogo-core/src/main/java/com/momogo/core/domain/user/dto/UserSearchCondition.java b/momogo-core/src/main/java/com/momogo/core/domain/user/dto/UserSearchCondition.java index 6fe6874..b0e2bb9 100644 --- a/momogo-core/src/main/java/com/momogo/core/domain/user/dto/UserSearchCondition.java +++ b/momogo-core/src/main/java/com/momogo/core/domain/user/dto/UserSearchCondition.java @@ -1,5 +1,6 @@ package com.momogo.core.domain.user.dto; +import com.momogo.core.domain.user.entity.enums.UserRole; import org.hibernate.query.SortDirection; import java.util.UUID; @@ -7,6 +8,8 @@ public record UserSearchCondition( String nameLike, String emailLike, + String spaceNameLike, + UserRole role, String cursor, UUID idAfter, SortDirection sortDirection, diff --git a/momogo-core/src/main/java/com/momogo/core/domain/user/dto/request/UserPageRequest.java b/momogo-core/src/main/java/com/momogo/core/domain/user/dto/request/UserPageRequest.java index ccbe7c0..8c54432 100644 --- a/momogo-core/src/main/java/com/momogo/core/domain/user/dto/request/UserPageRequest.java +++ b/momogo-core/src/main/java/com/momogo/core/domain/user/dto/request/UserPageRequest.java @@ -1,5 +1,6 @@ package com.momogo.core.domain.user.dto.request; +import com.momogo.core.domain.user.entity.enums.UserRole; import jakarta.validation.constraints.Max; import jakarta.validation.constraints.Min; import jakarta.validation.constraints.Pattern; @@ -11,6 +12,8 @@ public record UserPageRequest( String nameLike, String emailLike, + String spaceNameLike, + UserRole role, String cursor, // 가입일(OffsetDateTime) 문자열 UUID idAfter, // 보조 식별자 유저 ID diff --git a/momogo-core/src/main/java/com/momogo/core/domain/user/dto/response/UserResponse.java b/momogo-core/src/main/java/com/momogo/core/domain/user/dto/response/UserResponse.java index f5b5619..edef2ea 100644 --- a/momogo-core/src/main/java/com/momogo/core/domain/user/dto/response/UserResponse.java +++ b/momogo-core/src/main/java/com/momogo/core/domain/user/dto/response/UserResponse.java @@ -3,9 +3,12 @@ import com.momogo.core.domain.user.entity.enums.SocialType; import com.momogo.core.domain.user.entity.enums.UserRole; +import lombok.Builder; + import java.time.OffsetDateTime; import java.util.UUID; +@Builder public record UserResponse( UUID id, String email, @@ -13,8 +16,11 @@ public record UserResponse( String profileImageUrl, UserRole role, SocialType social, - Boolean banned, + Boolean isBanned, OffsetDateTime createdAt, - OffsetDateTime deletedAt + OffsetDateTime updatedAt, + OffsetDateTime deletedAt, + UUID spaceId, + String spaceName ) { } diff --git a/momogo-core/src/main/java/com/momogo/core/domain/user/mapper/UserMapper.java b/momogo-core/src/main/java/com/momogo/core/domain/user/mapper/UserMapper.java index be63c35..cea291c 100644 --- a/momogo-core/src/main/java/com/momogo/core/domain/user/mapper/UserMapper.java +++ b/momogo-core/src/main/java/com/momogo/core/domain/user/mapper/UserMapper.java @@ -17,8 +17,9 @@ public abstract class UserMapper { @Value("${app.file.base-url}") protected String fileBaseUrl; - @Mapping(target = "banned", source = "isBanned") @Mapping(target = "profileImageUrl", source = "profileImageUrl", qualifiedByName = "resolveImageUrl") + @Mapping(target = "spaceId", source = "space.id") + @Mapping(target = "spaceName", source = "space.name") public abstract UserResponse toResponse(User user); // 이미지 파일명을 클라이언트가 접근할 수 있는 완전한 URL 형태로 변환합니다. diff --git a/momogo-core/src/main/java/com/momogo/core/domain/user/repository/UserRepositoryImpl.java b/momogo-core/src/main/java/com/momogo/core/domain/user/repository/UserRepositoryImpl.java index a0ed987..00e3761 100644 --- a/momogo-core/src/main/java/com/momogo/core/domain/user/repository/UserRepositoryImpl.java +++ b/momogo-core/src/main/java/com/momogo/core/domain/user/repository/UserRepositoryImpl.java @@ -17,9 +17,11 @@ import java.time.OffsetDateTime; import java.time.format.DateTimeParseException; +import java.time.temporal.ChronoUnit; import java.util.List; import java.util.UUID; +import static com.momogo.core.domain.space.entity.QSpace.space; import static com.momogo.core.domain.user.entity.QUser.user; @Slf4j @@ -34,9 +36,12 @@ public class UserRepositoryImpl implements UserRepositoryCustom { public List findAllByCursor(UserSearchCondition condition, Pageable pageable) { return queryFactory .selectFrom(user) + .leftJoin(user.space, space).fetchJoin() .where( nameLike(condition.nameLike()), emailLike(condition.emailLike()), + spaceNameLike(condition.spaceNameLike()), + roleEq(condition.role()), // 정렬 기준에 따라 활성/탈퇴 회원을 동적으로 분기하여 필터링 filterBySortBy(condition.sortBy()), cursorCondition(condition.sortBy(), condition.cursor(), condition.idAfter(), condition.sortDirection()) @@ -51,21 +56,32 @@ public long countByCondition(UserSearchCondition condition) { Long count = queryFactory .select(user.count()) .from(user) + .leftJoin(user.space, space) .where( nameLike(condition.nameLike()), emailLike(condition.emailLike()), + spaceNameLike(condition.spaceNameLike()), + roleEq(condition.role()), filterBySortBy(condition.sortBy()) ) .fetchOne(); return count != null ? count : 0L; } + private BooleanExpression roleEq(UserRole role) { + return role != null ? user.role.eq(role) : null; + } + + private BooleanExpression spaceNameLike(String spaceNameLike) { + return spaceNameLike != null && !spaceNameLike.isBlank() ? space.name.containsIgnoreCase(spaceNameLike) : null; + } + private BooleanExpression nameLike(String nameLike) { - return nameLike != null && !nameLike.isBlank() ? user.name.contains(nameLike) : null; + return nameLike != null && !nameLike.isBlank() ? user.name.containsIgnoreCase(nameLike) : null; } private BooleanExpression emailLike(String emailLike) { - return emailLike != null && !emailLike.isBlank() ? user.email.contains(emailLike) : null; + return emailLike != null && !emailLike.isBlank() ? user.email.containsIgnoreCase(emailLike) : null; } private DateTimePath getSortPath(String sortBy) { @@ -84,7 +100,7 @@ private BooleanExpression cursorCondition(String sortBy, String cursor, UUID idA final OffsetDateTime cursorTime; try { - cursorTime = OffsetDateTime.parse(cursor); + cursorTime = OffsetDateTime.parse(cursor).truncatedTo(ChronoUnit.MICROS); } catch (DateTimeParseException e) { log.warn("유효하지 않은 형식의 커서 전달됨: {}", cursor); return null; @@ -101,13 +117,17 @@ private BooleanExpression cursorCondition(String sortBy, String cursor, UUID idA .or(sortPath.eq(cursorTime).and(user.id.lt(idAfter))); } - // 계정을 삭제하지 않은 유저는 deletedAt = null이므로 - // Super Admin이 deletedAt을 필터 조건으로 사용했을 경우 deletedAt != null인 삭제 요청한 유저만 확인할 수 있다. + // 계정을 삭제하지 않은 유저는 deletedAt = null이므로 + // deletedAt을 필터 조건으로 사용했을 경우 deletedAt != null인 삭제 요청한 유저만 확인한다. + // updatedAt 정렬 시에는 활성 유저만 확인하고, createdAt 정렬 시에는 탈퇴 유저를 포함한 전체 회원을 조회한다. private BooleanExpression filterBySortBy(String sortBy) { if ("deletedAt".equals(sortBy)) { return user.deletedAt.isNotNull(); } - return user.deletedAt.isNull(); + if ("updatedAt".equals(sortBy)) { + return user.deletedAt.isNull(); + } + return null; } // 1차 정렬 조건 필드와 2차 순서 보장용 고유 ID(UUID) 정렬을 결합하여 복합 정렬 순서 정의 diff --git a/momogo-core/src/main/java/com/momogo/core/domain/user/service/UserServiceImpl.java b/momogo-core/src/main/java/com/momogo/core/domain/user/service/UserServiceImpl.java index 64e3ed0..9891d92 100644 --- a/momogo-core/src/main/java/com/momogo/core/domain/user/service/UserServiceImpl.java +++ b/momogo-core/src/main/java/com/momogo/core/domain/user/service/UserServiceImpl.java @@ -37,8 +37,8 @@ import java.io.IOException; import java.io.InputStream; import java.time.OffsetDateTime; +import java.time.temporal.ChronoUnit; import java.util.ArrayList; -import java.time.OffsetDateTime; import java.util.List; import java.util.UUID; @@ -277,6 +277,8 @@ public CursorResponse findAllUsers(UserPageRequest request) { UserSearchCondition condition = new UserSearchCondition( request.nameLike(), request.emailLike(), + request.spaceNameLike(), + request.role(), request.cursor(), request.idAfter(), request.sortDirection(), @@ -303,7 +305,9 @@ public CursorResponse findAllUsers(UserPageRequest request) { default -> lastUser.getCreatedAt(); }; - nextCursor = sortTime != null ? sortTime.toString() : null; + if (sortTime != null) { + nextCursor = sortTime.truncatedTo(ChronoUnit.MICROS).toString(); + } nextIdAfter = lastUser.getId(); } diff --git a/momogo-frontend/index.html b/momogo-frontend/index.html index cc68a8c..32bec9a 100644 --- a/momogo-frontend/index.html +++ b/momogo-frontend/index.html @@ -4,7 +4,8 @@ - momogo-frontend + + MoMoGo - 에듀테크 인터랙티브 플랫폼
diff --git a/momogo-frontend/src/pages/DashboardPage.tsx b/momogo-frontend/src/pages/DashboardPage.tsx index d0d1191..04aa5b2 100644 --- a/momogo-frontend/src/pages/DashboardPage.tsx +++ b/momogo-frontend/src/pages/DashboardPage.tsx @@ -1,5 +1,5 @@ -import React, { useEffect, useState } from 'react'; -import type { UserResponse } from '../types/user'; +import React, { useEffect, useState, useRef, useCallback } from 'react'; +import type { UserResponse, UserCursorResponse, UserSortByType, SortDirectionType } from '../types/user'; import { PASSWORD_REGEX, PASSWORD_INVALID_MESSAGE } from '../types/user'; import type { SpaceResponse, SpaceCreateRequest } from '../types/space'; import { request, getAccessToken, connectNotificationSse } from '../services/api'; @@ -71,7 +71,41 @@ export const DashboardPage: React.FC = ({ user, initialTab, // 슈퍼관리자용 상태 목록 const [allUsers, setAllUsers] = useState([]); const [userCursor, setUserCursor] = useState(null); + const [userNextIdAfter, setUserNextIdAfter] = useState(null); const [userHasNext, setUserHasNext] = useState(false); + const [userTotalCount, setUserTotalCount] = useState(null); + const [overallUserTotalCount, setOverallUserTotalCount] = useState(null); + const [userLoadingMore, setUserLoadingMore] = useState(false); + const userSentinelRef = useRef(null); + + const userCursorRef = useRef(null); + const userNextIdAfterRef = useRef(null); + const userHasNextRef = useRef(false); + const userLoadingMoreRef = useRef(false); + + // 슈퍼관리자 유저 필터링 상태 + const [userSearchType, setUserSearchType] = useState<'name' | 'email' | 'role' | 'space'>('name'); + const [userSearchKeyword, setUserSearchKeyword] = useState(''); + const [userRoleFilter, setUserRoleFilter] = useState<'ALL' | 'USER' | 'ADMIN' | 'SUPER_ADMIN'>('ALL'); + const [userSortBy, setUserSortBy] = useState('createdAt'); + const [userSortDirection, setUserSortDirection] = useState('DESCENDING'); + + const userSortByRef = useRef('createdAt'); + const userSortDirectionRef = useRef('DESCENDING'); + const userSearchTypeRef = useRef<'name' | 'email' | 'role' | 'space'>('name'); + const userSearchKeywordRef = useRef(''); + const userRoleFilterRef = useRef<'ALL' | 'USER' | 'ADMIN' | 'SUPER_ADMIN'>('ALL'); + + userCursorRef.current = userCursor; + userNextIdAfterRef.current = userNextIdAfter; + userHasNextRef.current = userHasNext; + userLoadingMoreRef.current = userLoadingMore; + userSortByRef.current = userSortBy; + userSortDirectionRef.current = userSortDirection; + userSearchTypeRef.current = userSearchType; + userSearchKeywordRef.current = userSearchKeyword; + userRoleFilterRef.current = userRoleFilter; + const [categories, setCategories] = useState([]); // 신규 카테고리 상태 @@ -441,7 +475,7 @@ export const DashboardPage: React.FC = ({ user, initialTab, // 4. 슈퍼관리자 권한인 경우 관련 데이터 미리 조회 if (user.role === 'SUPER_ADMIN') { - await loadSuperAdminUsers(null); + await loadSuperAdminUsers(null, null); await loadSuperAdminCategories(); await loadSuperAdminSpaces(); await loadSuperAdminProblems(); @@ -453,29 +487,184 @@ export const DashboardPage: React.FC = ({ user, initialTab, } }; - // 슈퍼관리자 유저 목록 페이징 조회 (요구사항 7) - const loadSuperAdminUsers = async (cursor: string | null) => { + // 슈퍼관리자 유저 목록 페이징 및 필터 조회 + const loadSuperAdminUsers = async ( + cursorVal: string | null = null, + idAfterVal: string | null = null, + overrideFilters?: { + searchType?: 'name' | 'email' | 'role' | 'space'; + searchKeyword?: string; + role?: 'ALL' | 'USER' | 'ADMIN' | 'SUPER_ADMIN'; + sortBy?: UserSortByType; + sortDirection?: SortDirectionType; + } + ) => { + if (cursorVal && userLoadingMore) return; try { - const params: Record = { size: '20' }; - if (cursor) { - params.cursor = cursor; + if (!cursorVal) { + userCursorRef.current = null; + userNextIdAfterRef.current = null; + userHasNextRef.current = false; + setUserCursor(null); + setUserNextIdAfter(null); + setUserHasNext(false); + setAllUsers([]); + } else { + setUserLoadingMore(true); + } + + const filters = { + searchType: overrideFilters?.searchType || userSearchTypeRef.current, + searchKeyword: overrideFilters?.searchKeyword !== undefined ? overrideFilters.searchKeyword : userSearchKeywordRef.current, + role: overrideFilters?.role || userRoleFilterRef.current, + sortBy: overrideFilters?.sortBy || userSortByRef.current, + sortDirection: overrideFilters?.sortDirection || userSortDirectionRef.current, + }; + + const params: Record = { + limit: '20', + sortBy: filters.sortBy, + sortDirection: filters.sortDirection, + }; + + if (filters.searchType === 'role') { + if (filters.role && filters.role !== 'ALL') { + params.role = filters.role; + } + } else { + const trimmedKeyword = filters.searchKeyword.trim(); + if (trimmedKeyword) { + if (filters.searchType === 'name') { + params.nameLike = trimmedKeyword; + } else if (filters.searchType === 'email') { + params.emailLike = trimmedKeyword; + } else if (filters.searchType === 'space') { + params.spaceNameLike = trimmedKeyword; + } + } + } + + if (cursorVal && idAfterVal) { + params.cursor = cursorVal; + params.idAfter = idAfterVal; } - const data = await request('/api/super-admin/users', { + + const data = await request>('/api/super-admin/users', { method: 'GET', params, }); - const list: UserResponse[] = Array.isArray(data) ? data : (data?.values || data?.content || data?.data || []); - if (cursor) { - setAllUsers(prev => [...prev, ...list]); + const list: UserResponse[] = data?.data || (Array.isArray(data) ? data : []); + + if (cursorVal) { + setAllUsers(prev => { + const existingIds = new Set(prev.map(u => u.id)); + const newItems = list.filter(u => !existingIds.has(u.id)); + return [...prev, ...newItems]; + }); } else { setAllUsers(list); + if (typeof data?.totalCount === 'number' && data.totalCount >= 0) { + setUserTotalCount(data.totalCount); + const isUnfiltered = !filters.searchKeyword.trim() && filters.role === 'ALL'; + if (isUnfiltered || overallUserTotalCount === null) { + setOverallUserTotalCount(data.totalCount); + } + } } - if (data?.nextCursor) setUserCursor(data.nextCursor); - if (typeof data?.hasNext === 'boolean') setUserHasNext(data.hasNext); + + setUserCursor(data?.nextCursor || null); + setUserNextIdAfter(data?.nextIdAfter || null); + setUserHasNext(!!data?.hasNext); } catch (err: any) { console.error('유저 목록 조회 실패:', err.message); + } finally { + setUserLoadingMore(false); + } + }; + + // 슈퍼관리자 다음 유저 목록 로드 함수 + const loadNextUserPage = useCallback(() => { + if ( + userHasNextRef.current && + !userLoadingMoreRef.current && + userCursorRef.current && + userNextIdAfterRef.current + ) { + loadSuperAdminUsers(userCursorRef.current, userNextIdAfterRef.current); + } + }, []); + + // 슈퍼관리자 유저 자동 무한 스크롤 (IntersectionObserver + Capture Scroll + Viewport Check) + useEffect(() => { + const sentinel = userSentinelRef.current; + + const checkSentinelInView = () => { + if (!userSentinelRef.current || !userHasNextRef.current || userLoadingMoreRef.current) return; + const rect = userSentinelRef.current.getBoundingClientRect(); + if (rect.top <= window.innerHeight + 400 && rect.bottom >= 0) { + loadNextUserPage(); + } + }; + + let observer: IntersectionObserver | null = null; + if (sentinel) { + observer = new IntersectionObserver( + (entries) => { + if (entries[0].isIntersecting) { + checkSentinelInView(); + } + }, + { rootMargin: '400px' } + ); + observer.observe(sentinel); } + + window.addEventListener('scroll', checkSentinelInView, true); + const timer = setTimeout(checkSentinelInView, 150); + + return () => { + if (observer && sentinel) { + observer.unobserve(sentinel); + } + window.removeEventListener('scroll', checkSentinelInView, true); + clearTimeout(timer); + }; + }, [loadNextUserPage, allUsers.length]); + + // 유저 검색 실행 핸들러 + const handleUserSearch = (e?: React.FormEvent) => { + if (e) e.preventDefault(); + userSearchTypeRef.current = userSearchType; + userSearchKeywordRef.current = userSearchKeyword; + userRoleFilterRef.current = userRoleFilter; + loadSuperAdminUsers(null, null, { + searchType: userSearchType, + searchKeyword: userSearchKeyword, + role: userRoleFilter, + }); + }; + + // 유저 필터링 조건 초기화 핸들러 + const handleUserFilterReset = () => { + setUserSearchType('name'); + setUserSearchKeyword(''); + setUserRoleFilter('ALL'); + setUserSortBy('createdAt'); + setUserSortDirection('DESCENDING'); + userSearchTypeRef.current = 'name'; + userSearchKeywordRef.current = ''; + userRoleFilterRef.current = 'ALL'; + userSortByRef.current = 'createdAt'; + userSortDirectionRef.current = 'DESCENDING'; + setUserTotalCount(null); + loadSuperAdminUsers(null, null, { + searchType: 'name', + searchKeyword: '', + role: 'ALL', + sortBy: 'createdAt', + sortDirection: 'DESCENDING', + }); }; // 슈퍼관리자 카테고리 전체 조회 @@ -492,7 +681,7 @@ export const DashboardPage: React.FC = ({ user, initialTab, // 유저 밴 처리 연동 (요구사항 7) const handleToggleUserBan = (targetUser: UserResponse) => { - const nextBannedState = !targetUser.banned; + const nextBannedState = !targetUser.isBanned; openConfirm( '회원 상태 변경', `[${targetUser.name}] 님을 ${nextBannedState ? '영구 정지(Ban)' : '정지 해제'} 처리하시겠습니까?`, @@ -505,7 +694,7 @@ export const DashboardPage: React.FC = ({ user, initialTab, showToast('회원 정지 상태가 변경되었습니다.', 'success'); setAllUsers(prev => - prev.map(u => (u.id === targetUser.id ? { ...u, banned: nextBannedState } : u)) + prev.map(u => (u.id === targetUser.id ? { ...u, isBanned: nextBannedState } : u)) ); } catch (err: any) { showToast(err.message || '상태 변경 처리에 실패했습니다.', 'error'); @@ -965,6 +1154,12 @@ export const DashboardPage: React.FC = ({ user, initialTab,
{/* 슈퍼관리자 전용 대시보드 통계 카드 (요구사항 15) */}
+
+
전체 가입 회원
+
+ {overallUserTotalCount !== null ? `${overallUserTotalCount}명` : userTotalCount !== null ? `${userTotalCount}명` : `${allUsers.length}명`} +
+
전체 생성 공간
{superSpaces.length}개
@@ -973,13 +1168,9 @@ export const DashboardPage: React.FC = ({ user, initialTab,
전체 출제 문제
{superProblems.length}개
-
-
전체 가입 회원
-
{allUsers.length}명
-
-
영구 정지(Ban) 유저
-
{allUsers.filter(u => u.banned).length}명
+
영구 정지 유저
+
{allUsers.filter(u => u.isBanned).length}명
@@ -993,7 +1184,7 @@ export const DashboardPage: React.FC = ({ user, initialTab, }} onClick={() => setSuperadminSubTab('users')} > - 가입 회원 관리 + 전체 사용자 관리 + + + +
- - - - - + + + + + + + - {allUsers.map(u => ( - - - - - - + + + ) : ( + allUsers.map(u => { + const targetDateStr = userSortBy === 'deletedAt' ? u.deletedAt : userSortBy === 'updatedAt' ? u.updatedAt : u.createdAt; + let formattedDate = '-'; + if (targetDateStr) { + try { + formattedDate = new Date(targetDateStr).toLocaleString('ko-KR', { + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + }); + } catch { + formattedDate = targetDateStr; + } + } + + return ( + + + + + + + + - - ))} + role="switch" + aria-checked={u.isBanned} + style={styles.banSwitch} + onClick={() => handleToggleUserBan(u)} + > +
+
+
+ )} + + + ); + }) + )}
이름이메일역할 권한정지 상태유저 정지이름이메일소속 공간 + {userSortBy === 'deletedAt' ? '탈퇴일시' : userSortBy === 'updatedAt' ? '수정일시' : '가입일시'} + 역할 권한정지 상태유저 정지
{u.name}{u.email} - {u.role} - - {u.banned ? ( - 정지 상태 - ) : ( - 정상 이용 - )} - - {u.role === 'SUPER_ADMIN' ? ( - 변경 불가 - ) : ( -
handleToggleUserBan(u)} - > -
+ {allUsers.length === 0 ? ( +
+ 검색 조건에 해당하는 회원 데이터가 없습니다. +
{u.name}{u.email} + {u.spaceName ? ( + + 🏠 {u.spaceName} + + ) : ( + + 미소속 + + )} + {formattedDate} + + {u.role === 'SUPER_ADMIN' ? '최고 관리자' : u.role === 'ADMIN' ? '공간 관리자' : '일반 유저'} + + + {u.deletedAt ? ( + 탈퇴 완료 + ) : u.isBanned ? ( + 정지 상태 + ) : ( + 정상 이용 + )} + + {u.role === 'SUPER_ADMIN' || u.deletedAt ? ( + 변경 불가 + ) : (
-
- )} -
{userHasNext && ( - + {userLoadingMore && ( +
+ + 사용자 목록을 불러오는 중... +
+ )} + )} )} @@ -2067,14 +2579,19 @@ const styles: Record = { backgroundColor: 'transparent', border: 'none', color: '#475467', - padding: '0.5rem 1rem', - fontSize: '0.9rem', + padding: '0.55rem 1.1rem', + fontSize: '0.925rem', fontWeight: 600, + fontFamily: "'Pretendard', -apple-system, BlinkMacSystemFont, system-ui, Roboto, sans-serif", + letterSpacing: '-0.015em', + cursor: 'pointer', + transition: 'all 0.15s ease', }, adminSubTabActive: { - color: '#6366f1', - borderBottom: '2px solid #6366f1', + color: '#4f46e5', + borderBottom: '2.5px solid #4f46e5', borderRadius: '0', + fontWeight: 700, }, adminContentCard: { display: 'flex', @@ -2083,9 +2600,11 @@ const styles: Record = { padding: '2rem', }, adminContentTitle: { - fontSize: '1.125rem', + fontSize: '1.2rem', fontWeight: 700, - color: '#1d2939', + color: '#0f172a', + fontFamily: "'Pretendard', -apple-system, BlinkMacSystemFont, system-ui, Roboto, sans-serif", + letterSpacing: '-0.025em', }, demoBadge: { marginLeft: '0.5rem', @@ -2101,28 +2620,37 @@ const styles: Record = { tableWrapper: { width: '100%', overflowX: 'auto', - border: '1px solid #eaecf0', - borderRadius: '8px', + border: '1px solid #e2e8f0', + borderRadius: '10px', + boxShadow: '0 1px 3px rgba(0, 0, 0, 0.02)', }, adminTable: { width: '100%', + tableLayout: 'fixed', borderCollapse: 'collapse', textAlign: 'left', fontSize: '0.875rem', }, th: { - backgroundColor: '#f9fafb', - padding: '0.75rem 1rem', + backgroundColor: '#f8fafc', + padding: '0.85rem 1.1rem', fontWeight: 600, - color: '#475467', - borderBottom: '1px solid #eaecf0', + color: '#475569', + borderBottom: '1px solid #e2e8f0', + fontFamily: "'Pretendard', -apple-system, BlinkMacSystemFont, system-ui, Roboto, sans-serif", + letterSpacing: '-0.015em', + fontSize: '0.85rem', }, tr: { - borderBottom: '1px solid #eaecf0', + borderBottom: '1px solid #f1f5f9', + transition: 'background-color 0.15s ease', }, td: { - padding: '1rem', - color: '#1d2939', + padding: '0.95rem 1.1rem', + color: '#1e293b', + fontFamily: "'Pretendard', -apple-system, BlinkMacSystemFont, system-ui, Roboto, sans-serif", + letterSpacing: '-0.015em', + fontSize: '0.875rem', }, disabledText: { color: '#98a2b3', diff --git a/momogo-frontend/src/services/api.ts b/momogo-frontend/src/services/api.ts index e8f260c..dd570f7 100644 --- a/momogo-frontend/src/services/api.ts +++ b/momogo-frontend/src/services/api.ts @@ -141,11 +141,12 @@ export async function request(path: string, options: RequestOptions = {}): Pr } } - // URL 파라미터 조립 + // URL 파라미터 조립 (ISO-8601 커서 등 + 기호가 포함된 파라미터의 %2B 인코딩 보장) let url = resolvedPath; if (params) { const searchParams = new URLSearchParams(params); - url += `?${searchParams.toString()}`; + const queryString = searchParams.toString().replace(/\+/g, '%2B'); + url += `?${queryString}`; } const defaultHeaders: Record = { diff --git a/momogo-frontend/src/styles/theme.css b/momogo-frontend/src/styles/theme.css index df34017..113de32 100644 --- a/momogo-frontend/src/styles/theme.css +++ b/momogo-frontend/src/styles/theme.css @@ -42,7 +42,8 @@ } body { - font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; + font-family: 'Pretendard', 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; + letter-spacing: -0.015em; background-color: var(--bg-color); color: var(--text-main); min-height: 100vh; diff --git a/momogo-frontend/src/types/user.ts b/momogo-frontend/src/types/user.ts index 8fa2495..edca29f 100644 --- a/momogo-frontend/src/types/user.ts +++ b/momogo-frontend/src/types/user.ts @@ -2,16 +2,32 @@ export const PASSWORD_REGEX = /^(?=.*[A-Za-z])(?=.*\d)(?=.*[@$!%*#?&])[A-Za-z\d@ export const PASSWORD_INVALID_MESSAGE = '새 비밀번호는 8~20자이며, 영문, 숫자, 특수문자(@$!%*#?&)를 적어도 하나씩 포함해야 합니다.'; export type UserRole = 'USER' | 'ADMIN' | 'SUPER_ADMIN'; +export type SortDirectionType = 'ASCENDING' | 'DESCENDING'; +export type UserSortByType = 'createdAt' | 'updatedAt' | 'deletedAt'; export interface UserResponse { id: string; name: string; email: string; role: UserRole; - banned: boolean; + isBanned: boolean; profileImageUrl: string | null; social?: 'NONE' | 'GOOGLE' | 'KAKAO'; createdAt: string; + updatedAt?: string; + deletedAt?: string | null; + spaceId?: string | null; + spaceName?: string | null; +} + +export interface UserCursorResponse { + data: T[]; + nextCursor: string | null; + nextIdAfter: string | null; + hasNext: boolean; + totalCount: number; + sortBy?: UserSortByType; + sortDirection?: SortDirectionType; } export interface UserCreateRequest { @@ -30,3 +46,4 @@ export interface UserUpdateRequest { export interface UserBannedRequest { banned: boolean; } +