diff --git a/HANDOFF.md b/HANDOFF.md index 798c858..2597647 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -4,7 +4,7 @@ - 프로젝트명: 10 Minute Planner 웹 UI - 기술 스택: Vue 3 + Vite + TailwindCSS + JavaScript -- 현재 기준 버전: `v0.1.50` 준비 중 +- 현재 기준 버전: `v0.1.51` 준비 중 - Git 원격 저장소: `https://git.sori.studio/zenn/planner.sori.studio.git` ## 기준 디자인 @@ -201,12 +201,13 @@ - 미니 달력 날짜 버튼은 원형 비율이 흔들리지 않도록 고정 `width/height` 기준으로 다시 맞췄다. - 플래너 본문 D-DAY 텍스트는 3줄까지만 보이고, 넘치면 말줄임 처리되도록 정리했다. - 목표가 없는 빈 날짜에서는 `D-DAY 사용` 토글이 저장 상태와 무관하게 `OFF + 비활성`처럼 보이도록 보정했다. -- 관리자 전용 `ADMIN` 메뉴와 기본 대시보드가 추가되었다. 현재는 사용자 수, 최근 접속, 문서 수, 목표 수, 계정별 최종 접속일을 읽기 전용으로 확인할 수 있다. +- 관리자 전용 `ADMIN` 메뉴와 기본 대시보드가 추가되었다. 현재는 사용자 수, 최근 접속, 문서 수, 목표 수, 계정별 최종 접속일을 확인할 수 있다. - `users` 테이블에 `login_id`, `role`, `last_login_at` 컬럼이 추가되었다. - 관리자 계정은 이제 이메일이 아니라 별도 자동 생성 계정으로 관리한다. - 관리자 계정은 서버 시작 시 `ADMIN_ACCOUNT_ID`, `ADMIN_ACCOUNT_PASSWORD`, `ADMIN_ACCOUNT_EMAIL`, `ADMIN_ACCOUNT_NICKNAME` 환경변수 조합으로 자동 생성된다. - 관리자 아이디와 비밀번호는 저장소 문서에 실제 값을 남기지 않고, Docker 배포 시 루트 `.env` 같은 비공개 환경변수 파일에서만 관리한다. -- 관리자 대시보드는 현재 읽기 전용이며, 계정 정지/삭제/강제 로그아웃 같은 실제 운영 액션은 아직 없다. +- 관리자 대시보드에서는 계정 비활성화, 강제 로그아웃, 삭제를 실행할 수 있다. +- 관리자 대시보드에서 특정 사용자의 최근 플래너 기록 12건과 목표 20건을 상세 조회할 수 있다. - 배포용 `docker-compose.yml`은 현재 PostgreSQL 외부 포트 `45432`, 프론트 외부 포트 `48081` 기준이며, DB 계정/비밀번호는 루트 `.env`에서 주입한다. - 설정 화면의 보조 메모 카드는 주석 처리되어 현재는 보이지 않는다. - 비로그인 랜딩 카드는 상단 고정이 아니라 화면 중앙에 오도록 정렬을 수정했다. diff --git a/TODO.md b/TODO.md index d2894cf..5875d42 100644 --- a/TODO.md +++ b/TODO.md @@ -102,5 +102,5 @@ - [x] 서버 세션을 명시적으로 폐기하는 로그아웃 API를 추가한다. - [x] 메일 발송 인프라와 발신 도메인 정책을 Resend 기준으로 확정한다. - [x] 관리자 페이지에서 계정 비활성화 / 강제 로그아웃 / 삭제 기능을 추가한다. -- [ ] 관리자 페이지에서 사용자별 문서 상세 조회 기능을 추가한다. +- [x] 관리자 페이지에서 사용자별 문서 상세 조회 기능을 추가한다. - [ ] 관리자 페이지에서 검색 / 정렬 / 필터 UX를 추가한다. diff --git a/backend/src/routes/admin.js b/backend/src/routes/admin.js index 7dc003b..e23191c 100644 --- a/backend/src/routes/admin.js +++ b/backend/src/routes/admin.js @@ -112,6 +112,89 @@ export async function registerAdminRoutes(app) { } }) + app.get('/api/admin/users/:userId/detail', async (request, reply) => { + const adminUser = await requireAdminUser(request, reply) + + if (!adminUser) { + return + } + + const userIdResult = adminUserIdSchema.safeParse(request.params.userId) + + if (!userIdResult.success) { + return reply.code(400).send({ + message: '대상 사용자 값이 올바르지 않습니다.', + }) + } + + const userId = userIdResult.data + + const detailResult = await db.execute(sql` + select + u.id, + u.nickname, + u.email, + u.role, + u.disabled_at as "disabledAt", + u.created_at as "createdAt", + u.updated_at as "updatedAt", + u.email_verified_at as "emailVerifiedAt", + u.last_login_at as "lastLoginAt", + count(distinct pe.id)::int as "plannerEntryCount", + count(distinct g.id)::int as "goalCount", + count(distinct s.id)::int as "activeSessionCount" + from users u + left join planner_entries pe on pe.user_id = u.id + left join goals g on g.user_id = u.id + left join auth_sessions s on s.user_id = u.id and s.expires_at > now() + where u.id = ${userId} + group by u.id + limit 1 + `) + + const detailUser = detailResult.rows[0] + + if (!detailUser) { + return reply.code(404).send({ + message: '대상 사용자를 찾을 수 없습니다.', + }) + } + + const plannerEntriesResult = await db.execute(sql` + select + entry_date as "entryDate", + payload, + created_at as "createdAt", + updated_at as "updatedAt" + from planner_entries + where user_id = ${userId} + order by entry_date desc + limit 12 + `) + + const goalsResult = await db.execute(sql` + select + id, + title, + target_date as "targetDate", + active_from as "activeFrom", + active_until as "activeUntil", + color, + created_at as "createdAt", + updated_at as "updatedAt" + from goals + where user_id = ${userId} + order by updated_at desc, id desc + limit 20 + `) + + return { + user: detailUser, + plannerEntries: plannerEntriesResult.rows, + goals: goalsResult.rows, + } + }) + app.put('/api/admin/users/:userId/status', async (request, reply) => { const adminUser = await requireAdminUser(request, reply) diff --git a/package-lock.json b/package-lock.json index 1612462..cfbff36 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "ten-minute-planner", - "version": "0.1.50", + "version": "0.1.51", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "ten-minute-planner", - "version": "0.1.50", + "version": "0.1.51", "dependencies": { "vue": "^3.5.13" }, diff --git a/package.json b/package.json index 97f3d96..b0aaa0d 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "ten-minute-planner", "private": true, - "version": "0.1.50", + "version": "0.1.51", "type": "module", "scripts": { "dev": "vite", diff --git a/src/App.vue b/src/App.vue index f7d020b..23807c3 100644 --- a/src/App.vue +++ b/src/App.vue @@ -1719,6 +1719,8 @@ function clearAuthenticatedState() { adminMessage.value = '' adminUsers.value = [] adminRecentLogins.value = [] + adminSelectedUserId.value = null + adminUserDetail.value = null accountDeleteMessage.value = '' adminOverview.value = { totalUsers: 0, @@ -1761,6 +1763,8 @@ async function loadAdminDashboard() { if (!authToken.value || !isAdmin.value) { adminUsers.value = [] adminRecentLogins.value = [] + adminSelectedUserId.value = null + adminUserDetail.value = null adminMessage.value = '' return } @@ -1780,6 +1784,35 @@ async function loadAdminDashboard() { } } +async function loadAdminUserDetail(userId) { + if (!authToken.value || !isAdmin.value) { + return + } + + adminDetailBusy.value = true + + try { + const result = await fetchAdminUserDetail(authToken.value, userId) + adminSelectedUserId.value = userId + adminUserDetail.value = result + adminMessage.value = '' + } catch (error) { + adminMessage.value = error.message || '사용자 상세 정보를 불러오지 못했습니다.' + } finally { + adminDetailBusy.value = false + } +} + +function selectAdminUser(user) { + if (adminSelectedUserId.value === user.id && adminUserDetail.value) { + adminSelectedUserId.value = null + adminUserDetail.value = null + return + } + + void loadAdminUserDetail(user.id) +} + async function toggleAdminUserStatus(user) { const willDisable = !user.disabledAt const confirmed = window.confirm( @@ -1798,6 +1831,9 @@ async function toggleAdminUserStatus(user) { const result = await updateAdminUserStatus(authToken.value, user.id, willDisable) adminMessage.value = result.message || '계정 상태를 변경했습니다.' await loadAdminDashboard() + if (adminSelectedUserId.value === user.id) { + await loadAdminUserDetail(user.id) + } } catch (error) { adminMessage.value = error.message || '계정 상태를 변경하지 못했습니다.' } finally { @@ -1818,6 +1854,9 @@ async function revokeAdminSessions(user) { const result = await revokeAdminUserSessions(authToken.value, user.id) adminMessage.value = result.message || '사용자 세션을 정리했습니다.' await loadAdminDashboard() + if (adminSelectedUserId.value === user.id) { + await loadAdminUserDetail(user.id) + } } catch (error) { adminMessage.value = error.message || '사용자 세션을 종료하지 못했습니다.' } finally { @@ -1838,6 +1877,10 @@ async function removeAdminUser(user) { const result = await deleteAdminUser(authToken.value, user.id) adminMessage.value = result.message || '사용자 계정을 삭제했습니다.' await loadAdminDashboard() + if (adminSelectedUserId.value === user.id) { + adminSelectedUserId.value = null + adminUserDetail.value = null + } } catch (error) { adminMessage.value = error.message || '사용자 계정을 삭제하지 못했습니다.' } finally { @@ -3227,10 +3270,14 @@ onBeforeUnmount(() => { class="scrollbar-hide print-hidden xl:h-full xl:overflow-y-auto" :summary="adminOverview" :users="adminUsers" + :selected-user-id="adminSelectedUserId" + :user-detail="adminUserDetail" :recent-logins="adminRecentLogins" :busy="adminBusy" :action-busy-user-id="adminActionUserId" + :detail-busy="adminDetailBusy" :message="adminMessage" + @select-user="selectAdminUser" @toggle-user-status="toggleAdminUserStatus" @revoke-user-sessions="revokeAdminSessions" @delete-user="removeAdminUser" diff --git a/src/components/AdminDashboard.vue b/src/components/AdminDashboard.vue index 4724706..6a8805b 100644 --- a/src/components/AdminDashboard.vue +++ b/src/components/AdminDashboard.vue @@ -8,6 +8,14 @@ const props = defineProps({ type: Array, required: true, }, + selectedUserId: { + type: Number, + default: null, + }, + userDetail: { + type: Object, + default: null, + }, recentLogins: { type: Array, required: true, @@ -24,9 +32,14 @@ const props = defineProps({ type: Number, default: null, }, + detailBusy: { + type: Boolean, + default: false, + }, }) const emit = defineEmits([ + 'select-user', 'toggle-user-status', 'revoke-user-sessions', 'delete-user', @@ -51,6 +64,28 @@ function formatDate(value) { minute: '2-digit', }).format(date) } + +function getPlannerSummary(payload) { + if (!payload || typeof payload !== 'object') { + return { + comment: '코멘트 없음', + taskCount: 0, + completedCount: 0, + memoCount: 0, + } + } + + const tasks = Array.isArray(payload.tasks) ? payload.tasks : [] + const memo = Array.isArray(payload.memo) ? payload.memo : [] + const activeTasks = tasks.filter((task) => task?.title?.trim?.()) + + return { + comment: payload.comment?.trim?.() || '코멘트 없음', + taskCount: activeTasks.length, + completedCount: activeTasks.filter((task) => task.checked).length, + memoCount: memo.filter((item) => item?.text?.trim?.() || item?.label?.trim?.()).length, + } +}