v0.1.51 - 관리자 사용자 상세 조회 추가

This commit is contained in:
2026-04-24 12:35:25 +09:00
parent ec9a334035
commit 2ae172f0ce
8 changed files with 300 additions and 7 deletions

View File

@@ -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"

View File

@@ -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,
}
}
</script>
<template>
@@ -166,6 +201,13 @@ function formatDate(value) {
<div>
<p class="text-sm font-semibold text-stone-900">{{ user.nickname }}</p>
<p class="mt-1 text-xs font-semibold text-stone-500">{{ user.email }}</p>
<button
type="button"
class="mt-2 text-[11px] font-bold tracking-[0.12em] text-stone-500 underline underline-offset-4 transition hover:text-stone-900"
@click="emit('select-user', user)"
>
{{ selectedUserId === user.id ? '상세 닫기' : '상세 보기' }}
</button>
</div>
<p class="text-sm font-semibold text-stone-700">{{ user.role }}</p>
<p class="text-sm font-semibold text-stone-700">{{ formatDate(user.lastLoginAt) }}</p>
@@ -238,6 +280,122 @@ function formatDate(value) {
</div>
</div>
</div>
<section class="border-t border-stone-200 bg-[#fcfaf6] px-5 py-5">
<div class="flex items-center justify-between gap-4">
<div>
<p class="text-[11px] font-bold uppercase tracking-[0.24em] text-stone-500">사용자 상세</p>
<p class="mt-2 text-sm font-semibold text-stone-500">선택한 사용자의 최근 플래너 기록과 목표를 확인합니다.</p>
</div>
<div
v-if="detailBusy"
class="rounded-full bg-stone-900 px-4 py-2 text-[11px] font-bold tracking-[0.16em] text-white"
>
불러오는 ...
</div>
</div>
<div
v-if="userDetail?.user"
class="mt-5 grid gap-5 xl:grid-cols-[minmax(0,1.15fr)_minmax(320px,0.85fr)]"
>
<div class="rounded-[24px] border border-stone-200 bg-white p-5">
<div class="flex items-center justify-between gap-4">
<div>
<p class="text-lg font-semibold text-stone-900">{{ userDetail.user.nickname }}</p>
<p class="mt-1 text-sm font-semibold text-stone-500">{{ userDetail.user.email }}</p>
</div>
<div class="flex flex-wrap justify-end gap-2">
<span class="rounded-full bg-stone-100 px-3 py-1 text-[10px] font-bold tracking-[0.14em] text-stone-600">{{ userDetail.user.role }}</span>
<span
class="rounded-full px-3 py-1 text-[10px] font-bold tracking-[0.14em]"
:class="userDetail.user.disabledAt ? 'bg-rose-100 text-rose-700' : 'bg-emerald-100 text-emerald-700'"
>
{{ userDetail.user.disabledAt ? '비활성화' : '사용 가능' }}
</span>
</div>
</div>
<div class="mt-5 grid gap-3 md:grid-cols-3">
<div class="rounded-2xl border border-stone-200 bg-[#fbf7f0] px-4 py-4">
<p class="text-[10px] font-bold tracking-[0.16em] text-stone-500">최근 로그인</p>
<p class="mt-2 text-sm font-semibold text-stone-800">{{ formatDate(userDetail.user.lastLoginAt) }}</p>
</div>
<div class="rounded-2xl border border-stone-200 bg-[#fbf7f0] px-4 py-4">
<p class="text-[10px] font-bold tracking-[0.16em] text-stone-500">문서 / 목표</p>
<p class="mt-2 text-sm font-semibold text-stone-800">{{ userDetail.user.plannerEntryCount }} / {{ userDetail.user.goalCount }}</p>
</div>
<div class="rounded-2xl border border-stone-200 bg-[#fbf7f0] px-4 py-4">
<p class="text-[10px] font-bold tracking-[0.16em] text-stone-500">활성 세션</p>
<p class="mt-2 text-sm font-semibold text-stone-800">{{ userDetail.user.activeSessionCount }}</p>
</div>
</div>
<div class="mt-6">
<p class="text-[11px] font-bold uppercase tracking-[0.2em] text-stone-500">최근 플래너 기록</p>
<div class="mt-3 space-y-3">
<article
v-for="entry in userDetail.plannerEntries"
:key="entry.entryDate"
class="rounded-2xl border border-stone-200 bg-[#fffdfa] px-4 py-4"
>
<div class="flex items-center justify-between gap-3">
<p class="text-sm font-semibold text-stone-900">{{ entry.entryDate }}</p>
<p class="text-[11px] font-semibold text-stone-500">{{ formatDate(entry.updatedAt) }}</p>
</div>
<p class="mt-3 text-sm font-semibold leading-6 text-stone-700">{{ getPlannerSummary(entry.payload).comment }}</p>
<div class="mt-3 flex flex-wrap gap-2 text-[11px] font-bold tracking-[0.12em] text-stone-500">
<span>작업 {{ getPlannerSummary(entry.payload).taskCount }}</span>
<span>완료 {{ getPlannerSummary(entry.payload).completedCount }}</span>
<span>메모 {{ getPlannerSummary(entry.payload).memoCount }}</span>
</div>
</article>
<p
v-if="userDetail.plannerEntries.length === 0"
class="rounded-2xl border border-dashed border-stone-300 bg-white px-4 py-4 text-sm font-semibold text-stone-500"
>
아직 작성한 플래너 기록이 없습니다.
</p>
</div>
</div>
</div>
<div class="rounded-[24px] border border-stone-200 bg-white p-5">
<p class="text-[11px] font-bold uppercase tracking-[0.2em] text-stone-500">목표 목록</p>
<div class="mt-3 space-y-3">
<article
v-for="goal in userDetail.goals"
:key="goal.id"
class="rounded-2xl border border-stone-200 bg-[#fffdfa] px-4 py-4"
>
<div class="flex items-center justify-between gap-3">
<p class="text-sm font-semibold text-stone-900">{{ goal.title }}</p>
<span class="rounded-full border border-stone-200 px-3 py-1 text-[10px] font-bold tracking-[0.12em] text-stone-500">
{{ goal.targetDate }}
</span>
</div>
<div class="mt-3 flex flex-wrap gap-2 text-[11px] font-bold tracking-[0.12em] text-stone-500">
<span>표시 시작 {{ goal.activeFrom || '미설정' }}</span>
<span>표시 종료 {{ goal.activeUntil || '미설정' }}</span>
</div>
</article>
<p
v-if="userDetail.goals.length === 0"
class="rounded-2xl border border-dashed border-stone-300 bg-white px-4 py-4 text-sm font-semibold text-stone-500"
>
등록된 목표가 없습니다.
</p>
</div>
</div>
</div>
<div
v-else
class="mt-5 rounded-[24px] border border-dashed border-stone-300 bg-white px-5 py-10 text-center text-sm font-semibold text-stone-500"
>
사용자 목록에서 `상세 보기` 눌러 기록과 목표를 확인해 주세요.
</div>
</section>
</section>
</div>
</section>

View File

@@ -24,6 +24,10 @@ export async function fetchAdminOverview(token) {
return request('/api/admin/overview', token)
}
export async function fetchAdminUserDetail(token, userId) {
return request(`/api/admin/users/${userId}/detail`, token)
}
export async function updateAdminUserStatus(token, userId, disabled) {
return request(`/api/admin/users/${userId}/status`, token, {
method: 'PUT',