v0.1.51 - 관리자 사용자 상세 조회 추가
This commit is contained in:
47
src/App.vue
47
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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user