Files
sori.studio/components/admin/AdminMemberForm.vue

767 lines
34 KiB
Vue

<script setup>
const props = defineProps({
member: {
type: Object,
default: () => null
},
mode: {
type: String,
default: 'edit'
}
})
const emit = defineEmits(['saved', 'deleted'])
const { data: adminSession } = await useFetch('/admin/api/auth/me', {
default: () => ({
userId: '',
roleCode: ''
})
})
const isNewMember = computed(() => props.mode === 'new')
const saveMessage = ref('')
const saveError = ref('')
const isSaving = ref(false)
const savedMemberSnapshot = ref('')
const avatarInputRef = ref(null)
const isUploadingAvatar = ref(false)
const actionMenuOpen = ref(false)
const passwordModalOpen = ref(false)
const deleteModalOpen = ref(false)
const isUpdatingPassword = ref(false)
const isDeletingMember = ref(false)
const actionMessage = ref('')
const actionError = ref('')
const roleOptions = [
{ value: 'owner', label: '소유자' },
{ value: 'admin', label: '관리자' },
{ value: 'vip', label: 'VIP' },
{ value: 'member', label: '멤버' }
]
const form = reactive({
username: '',
email: '',
avatarUrl: '',
roleCode: 'member',
labelsText: '',
note: ''
})
const passwordForm = reactive({
password: '',
passwordConfirm: ''
})
const deleteForm = reactive({
confirmText: ''
})
/**
* 회원 폼 값을 현재 회원 정보로 동기화한다.
* @returns {void}
*/
const syncMemberForm = () => {
const member = props.member || {}
form.username = member.username || ''
form.email = member.email || ''
form.avatarUrl = member.avatarUrl || ''
form.roleCode = member.roleCode || 'member'
form.labelsText = Array.isArray(member.labels) ? member.labels.join(', ') : ''
form.note = member.note || ''
}
watch(() => props.member, syncMemberForm, { immediate: true })
const pageTitle = computed(() => {
if (isNewMember.value) {
return '새 멤버'
}
return form.username || props.member?.email || '멤버'
})
const memberInitial = computed(() => String(form.username || form.email || '?').slice(0, 1).toUpperCase())
const noteLength = computed(() => form.note.length)
const normalizedLabels = computed(() => [...new Set(
form.labelsText
.split(',')
.map((label) => label.trim())
.filter(Boolean)
)])
const currentRoleLabel = computed(() => roleOptions.find((option) => option.value === form.roleCode)?.label || '멤버')
const currentAdminRoleCode = computed(() => adminSession.value?.roleCode || '')
const isCurrentAdminPrivileged = computed(() => ['owner', 'admin'].includes(currentAdminRoleCode.value))
const isEditingSelf = computed(() => Boolean(props.member?.id && adminSession.value?.userId)
&& String(props.member.id) === String(adminSession.value.userId))
const isTargetPrivilegedRole = computed(() => ['owner', 'admin'].includes(props.member?.roleCode || form.roleCode))
const shouldRenderRoleAsText = computed(() => isNewMember.value || !isCurrentAdminPrivileged.value)
const canEditRoleSelect = computed(() => {
if (shouldRenderRoleAsText.value || isSaving.value) {
return false
}
if (currentAdminRoleCode.value === 'owner') {
return !isEditingSelf.value
}
if (currentAdminRoleCode.value === 'admin') {
return !isTargetPrivilegedRole.value
}
return false
})
const availableRoleOptions = computed(() => {
if (!canEditRoleSelect.value) {
return roleOptions
}
if (currentAdminRoleCode.value === 'admin') {
return roleOptions.filter((option) => ['vip', 'member'].includes(option.value))
}
return roleOptions
})
const roleHelpText = computed(() => {
if (shouldRenderRoleAsText.value) {
return '멤버와 VIP는 관리자 권한이 없어 등급을 변경할 수 없습니다.'
}
if (isEditingSelf.value && currentAdminRoleCode.value === 'owner') {
return '소유자는 본인 권한을 직접 낮출 수 없습니다.'
}
if (currentAdminRoleCode.value === 'admin' && isTargetPrivilegedRole.value) {
return '관리자는 소유자 또는 다른 관리자의 등급을 변경할 수 없습니다.'
}
if (currentAdminRoleCode.value === 'admin') {
return '관리자는 멤버와 VIP 등급만 변경할 수 있습니다.'
}
return 'VIP 이상 등급은 멤버십 게시물을 볼 수 있습니다.'
})
/**
* 회원 저장 요청 본문을 문자열로 직렬화한다.
* @returns {string} 직렬화된 회원 입력값
*/
const serializeMemberPayload = () => JSON.stringify({
...getMemberPayload(),
roleCode: form.roleCode
})
/**
* 날짜 표시 형식 변환
* @param {string | null} value - ISO 날짜 문자열
* @returns {string} 화면 표시 날짜
*/
const formatDate = (value) => {
if (!value) {
return '-'
}
const date = new Date(value)
if (Number.isNaN(date.getTime())) {
return '-'
}
const year = date.getFullYear()
const month = String(date.getMonth() + 1).padStart(2, '0')
const day = String(date.getDate()).padStart(2, '0')
return `${year}.${month}.${day}`
}
/**
* 최근 활동 시각을 상대 시간으로 표시한다.
* @param {string | null} value - ISO 시각
* @returns {string} 상대 시간
*/
const formatRelativeTime = (value) => {
if (!value) {
return '최근 활동 없음'
}
const date = new Date(value)
if (Number.isNaN(date.getTime())) {
return '최근 활동 없음'
}
const diffMs = Date.now() - date.getTime()
const minute = 1000 * 60
const hour = minute * 60
const day = hour * 24
if (diffMs < minute) {
return '방금 전'
}
if (diffMs < hour) {
return `${Math.floor(diffMs / minute)}분 전`
}
if (diffMs < day) {
return `${Math.floor(diffMs / hour)}시간 전`
}
if (diffMs < day * 30) {
return `${Math.floor(diffMs / day)}일 전`
}
return formatDate(value)
}
/**
* 회원 저장 요청 본문을 만든다.
* @returns {{ username: string, email: string, avatarUrl: string, labels: string[], note: string }} 저장 본문
*/
const getMemberPayload = () => ({
username: form.username.trim(),
email: form.email.trim(),
avatarUrl: form.avatarUrl.trim(),
labels: normalizedLabels.value,
note: form.note
})
const hasUnsavedMemberChanges = computed(() => serializeMemberPayload() !== savedMemberSnapshot.value)
const {
isUnsavedModalOpen,
stayOnUnsavedPage,
leaveUnsavedPage
} = useAdminUnsavedChangesGuard(hasUnsavedMemberChanges)
/**
* 썸네일 파일 선택창을 연다.
* @returns {void}
*/
const openAvatarFilePicker = () => {
avatarInputRef.value?.click()
}
/**
* 회원 썸네일 파일을 업로드하고 폼에 반영한다.
* @param {Event} event - 파일 선택 이벤트
* @returns {Promise<void>}
*/
const uploadAvatar = async (event) => {
const target = event.target instanceof HTMLInputElement ? event.target : null
const file = target?.files?.[0]
if (!file || isUploadingAvatar.value) {
return
}
isUploadingAvatar.value = true
saveError.value = ''
saveMessage.value = ''
try {
const formData = new FormData()
formData.append('file', file)
const result = isNewMember.value
? await $fetch('/admin/api/member-avatar', {
method: 'POST',
body: formData
})
: await $fetch(`/admin/api/members/${props.member.id}/avatar`, {
method: 'POST',
body: formData
})
form.avatarUrl = result.avatarUrl || ''
if (!isNewMember.value) {
emit('saved', result)
savedMemberSnapshot.value = serializeMemberPayload()
saveMessage.value = '썸네일이 변경되었습니다.'
}
} catch (error) {
saveError.value = error?.data?.message || '썸네일 업로드에 실패했습니다.'
} finally {
isUploadingAvatar.value = false
if (target) {
target.value = ''
}
}
}
/**
* 회원 썸네일 연결을 제거한다.
* @returns {void}
*/
const removeAvatar = () => {
form.avatarUrl = ''
}
/**
* 회원 작업 메뉴를 토글한다.
* @returns {void}
*/
const toggleActionMenu = () => {
actionMenuOpen.value = !actionMenuOpen.value
}
/**
* 회원 작업 메뉴를 닫는다.
* @returns {void}
*/
const closeActionMenu = () => {
actionMenuOpen.value = false
}
/**
* 비밀번호 변경 모달을 연다.
* @returns {void}
*/
const openPasswordModal = () => {
passwordForm.password = ''
passwordForm.passwordConfirm = ''
actionMessage.value = ''
actionError.value = ''
passwordModalOpen.value = true
closeActionMenu()
}
/**
* 회원 삭제 모달을 연다.
* @returns {void}
*/
const openDeleteModal = () => {
deleteForm.confirmText = ''
actionMessage.value = ''
actionError.value = ''
deleteModalOpen.value = true
closeActionMenu()
}
/**
* 비밀번호 변경 모달을 닫는다.
* @returns {void}
*/
const closePasswordModal = () => {
if (isUpdatingPassword.value) {
return
}
passwordModalOpen.value = false
}
/**
* 회원 삭제 모달을 닫는다.
* @returns {void}
*/
const closeDeleteModal = () => {
if (isDeletingMember.value) {
return
}
deleteModalOpen.value = false
}
/**
* 관리자 권한으로 회원 비밀번호를 변경한다.
* @returns {Promise<void>}
*/
const updateMemberPassword = async () => {
actionMessage.value = ''
actionError.value = ''
if (!passwordForm.password || passwordForm.password.length < 8 || passwordForm.password.length > 32) {
actionError.value = '새 비밀번호는 8~32자로 입력해 주세요.'
return
}
if (passwordForm.password !== passwordForm.passwordConfirm) {
actionError.value = '새 비밀번호와 확인 값이 일치하지 않습니다.'
return
}
isUpdatingPassword.value = true
try {
await $fetch(`/admin/api/members/${props.member.id}/password`, {
method: 'PUT',
body: {
password: passwordForm.password
}
})
passwordModalOpen.value = false
passwordForm.password = ''
passwordForm.passwordConfirm = ''
actionMessage.value = '비밀번호가 변경되었습니다.'
} catch (error) {
actionError.value = error?.data?.message || '비밀번호 변경에 실패했습니다.'
} finally {
isUpdatingPassword.value = false
}
}
/**
* 관리자 권한으로 회원을 삭제한다.
* @returns {Promise<void>}
*/
const deleteMember = async () => {
actionMessage.value = ''
actionError.value = ''
if (deleteForm.confirmText !== form.email) {
actionError.value = '삭제하려면 회원 이메일을 정확히 입력해 주세요.'
return
}
isDeletingMember.value = true
try {
await $fetch(`/admin/api/members/${props.member.id}`, {
method: 'DELETE'
})
emit('deleted')
} catch (error) {
actionError.value = error?.data?.message || '회원 삭제에 실패했습니다.'
} finally {
isDeletingMember.value = false
}
}
/**
* 회원 기본 정보를 저장한다.
* @returns {Promise<void>}
*/
const saveMember = async () => {
if (isSaving.value) {
return
}
saveMessage.value = ''
saveError.value = ''
isSaving.value = true
try {
const payload = getMemberPayload()
if (!isNewMember.value && canEditRoleSelect.value && form.roleCode !== props.member?.roleCode) {
await $fetch(`/admin/api/members/${props.member.id}/role`, {
method: 'PUT',
body: {
role: form.roleCode
}
})
}
const saved = isNewMember.value
? await $fetch('/admin/api/members', {
method: 'POST',
body: payload
})
: await $fetch(`/admin/api/members/${props.member.id}`, {
method: 'PUT',
body: payload
})
savedMemberSnapshot.value = serializeMemberPayload()
emit('saved', saved)
saveMessage.value = '저장되었습니다.'
} catch (error) {
saveError.value = error?.data?.message || '저장에 실패했습니다.'
} finally {
isSaving.value = false
}
}
watch(() => props.member, () => {
savedMemberSnapshot.value = serializeMemberPayload()
}, { immediate: true, flush: 'post' })
</script>
<template>
<section class="admin-member-form bg-paper p-6">
<div class="admin-member-form__header sticky top-0 z-10 -mx-6 -mt-6 border-b border-line bg-paper/95 px-6 py-5 backdrop-blur">
<div class="admin-member-form__header-inner flex flex-col gap-4 xl:flex-row xl:items-center xl:justify-between">
<div class="admin-member-form__title-block">
<div class="admin-member-form__breadcrumb flex items-center gap-2 text-sm text-[#8a95a5]">
<NuxtLink class="admin-member-form__breadcrumb-link text-[#3f4650] hover:text-[#15171a]" to="/admin/members">
멤버
</NuxtLink>
<svg class="h-3 w-3" viewBox="0 0 18 27" aria-hidden="true">
<path d="M2.397 25.426l13.143-11.5-13.143-11.5" stroke-width="3" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round" />
</svg>
<span>{{ isNewMember ? '새 멤버' : '멤버 편집' }}</span>
</div>
<h1 class="admin-member-form__title mt-4 text-3xl font-semibold tracking-[-0.01em] text-[#15171a]">
{{ pageTitle }}
</h1>
</div>
<div class="admin-member-form__actions flex items-center gap-3">
<div v-if="!isNewMember" class="admin-member-form__action-menu relative">
<button class="admin-member-form__action-icon flex h-11 w-11 items-center justify-center rounded-md border border-line bg-white text-[#3f4650] transition hover:border-[#c5ccd5] hover:bg-[#f4f6f8]" type="button" aria-label="멤버 작업" :aria-expanded="actionMenuOpen" @click="toggleActionMenu">
<svg class="h-5 w-5" viewBox="0 0 24 24" aria-hidden="true">
<path d="M10.546 2.438a1.957 1.957 0 002.908 0L14.4 1.4a1.959 1.959 0 013.41 1.413l-.071 1.4a1.958 1.958 0 002.051 2.054l1.4-.071a1.959 1.959 0 011.41 3.41l-1.042.94a1.96 1.96 0 000 2.909l1.042.94a1.959 1.959 0 01-1.413 3.41l-1.4-.071a1.958 1.958 0 00-2.056 2.056l.071 1.4A1.959 1.959 0 0114.4 22.6l-.941-1.041a1.959 1.959 0 00-2.908 0L9.606 22.6A1.959 1.959 0 016.2 21.192l.072-1.4a1.958 1.958 0 00-2.056-2.056l-1.4.071A1.958 1.958 0 011.4 14.4l1.041-.94a1.96 1.96 0 000-2.909L1.4 9.606A1.958 1.958 0 012.809 6.2l1.4.071a1.958 1.958 0 002.058-2.06L6.2 2.81A1.959 1.959 0 019.606 1.4z" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" />
<circle cx="12" cy="12.001" r="4.5" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" />
</svg>
</button>
<div v-if="actionMenuOpen" class="admin-member-form__action-popover absolute right-0 top-12 z-20 grid w-52 overflow-hidden rounded-xl border border-line bg-white py-2 text-sm text-[#3f4650] shadow-[0_16px_44px_rgba(15,23,42,0.16)]">
<button class="admin-member-form__action-item px-4 py-2.5 text-left hover:bg-[#f3f5f7]" type="button" @click="openPasswordModal">
비밀번호 변경
</button>
<button class="admin-member-form__action-item px-4 py-2.5 text-left text-[#d21a26] hover:bg-[#fff1f2]" type="button" @click="openDeleteModal">
멤버 삭제
</button>
</div>
</div>
<button v-else class="admin-member-form__action-icon flex h-11 w-11 items-center justify-center rounded-md border border-line bg-white text-[#3f4650]" type="button" aria-label="멤버 작업" disabled>
<svg class="h-5 w-5" viewBox="0 0 24 24" aria-hidden="true">
<path d="M10.546 2.438a1.957 1.957 0 002.908 0L14.4 1.4a1.959 1.959 0 013.41 1.413l-.071 1.4a1.958 1.958 0 002.051 2.054l1.4-.071a1.959 1.959 0 011.41 3.41l-1.042.94a1.96 1.96 0 000 2.909l1.042.94a1.959 1.959 0 01-1.413 3.41l-1.4-.071a1.958 1.958 0 00-2.056 2.056l.071 1.4A1.959 1.959 0 0114.4 22.6l-.941-1.041a1.959 1.959 0 00-2.908 0L9.606 22.6A1.959 1.959 0 016.2 21.192l.072-1.4a1.958 1.958 0 00-2.056-2.056l-1.4.071A1.958 1.958 0 011.4 14.4l1.041-.94a1.96 1.96 0 000-2.909L1.4 9.606A1.958 1.958 0 012.809 6.2l1.4.071a1.958 1.958 0 002.058-2.06L6.2 2.81A1.959 1.959 0 019.606 1.4z" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" />
<circle cx="12" cy="12.001" r="4.5" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" />
</svg>
</button>
<button class="admin-member-form__save h-11 rounded-md bg-[#15171a] px-5 text-sm font-semibold text-white transition hover:bg-[#2b2f35] disabled:opacity-50" type="button" :disabled="isSaving" @click="saveMember">
{{ isSaving ? '저장 ' : '저장' }}
</button>
</div>
</div>
</div>
<div class="admin-member-form__body grid gap-8 py-8 xl:grid-cols-3">
<aside class="admin-member-form__summary">
<div class="admin-member-form__identity flex items-center gap-4">
<div class="admin-member-form__avatar-control group relative h-20 w-20 shrink-0">
<button
class="admin-member-form__avatar-button relative h-20 w-20 overflow-hidden rounded-full bg-[#15171a] text-white"
type="button"
:aria-label="form.avatarUrl ? '썸네일 변경' : '썸네일 등록'"
@click="openAvatarFilePicker"
>
<img
v-if="form.avatarUrl"
class="admin-member-form__avatar h-full w-full object-cover"
:src="form.avatarUrl"
:alt="pageTitle"
>
<span v-else class="admin-member-form__avatar flex h-full w-full items-center justify-center text-2xl font-semibold">
{{ memberInitial }}
</span>
<span class="admin-member-form__avatar-caption absolute inset-x-0 bottom-0 flex min-h-8 items-end justify-center bg-gradient-to-t from-black/80 via-black/35 to-transparent px-2 pb-2 text-center text-[11px] font-semibold leading-tight text-white opacity-0 transition-opacity group-hover:opacity-100 group-focus-within:opacity-100">
{{ isUploadingAvatar ? '업로드 중' : form.avatarUrl ? '썸네일 변경' : '썸네일 등록' }}
</span>
</button>
<button
v-if="form.avatarUrl"
class="admin-member-form__avatar-remove absolute right-0 top-0 grid size-6 -translate-y-1 translate-x-1 place-items-center rounded-full bg-black/85 text-white opacity-0 shadow-sm transition hover:bg-[#d21a26] group-hover:opacity-100 group-focus-within:opacity-100"
type="button"
aria-label="썸네일 제거"
@click.stop="removeAvatar"
>
<svg class="h-3 w-3" viewBox="0 0 24 24" aria-hidden="true">
<path d="M12.707 12L23.854.854a.5.5 0 00-.707-.707L12 11.293.854.146a.5.5 0 00-.707.707L11.293 12 .146 23.146a.5.5 0 00.708.708L12 12.707l11.146 11.146a.5.5 0 10.708-.706L12.707 12z" fill="currentColor" />
</svg>
</button>
<input ref="avatarInputRef" class="sr-only" type="file" accept="image/*" @change="uploadAvatar">
</div>
<div class="min-w-0">
<h2 class="truncate text-lg font-semibold text-[#15171a]">{{ pageTitle }}</h2>
<p class="mt-1 truncate text-sm text-[#657080]">{{ form.email || '이메일 없음' }}</p>
<p class="mt-1 text-xs font-semibold uppercase tracking-[0.04em] text-[#9aa4b2]">{{ currentRoleLabel }}</p>
</div>
</div>
<div v-if="!isNewMember" class="admin-member-form__meta mt-10 space-y-3 text-sm text-[#4d5663]">
<p class="flex items-center gap-2">
<svg class="h-4 w-4 text-[#8a95a5]" fill="none" viewBox="0 0 24 26" aria-hidden="true">
<path d="M12 14.75a4 4 0 100-8 4 4 0 000 8z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
<path d="M21 10.75c0 7.9-6.932 12.331-8.629 13.3a.751.751 0 01-.743 0C9.931 23.08 3 18.648 3 10.75a9 9 0 1118 0z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
</svg>
{{ member?.lastSeenIp || '접속 IP 없음' }}
</p>
<p class="flex items-center gap-2">
<svg class="h-4 w-4 text-[#8a95a5]" fill="none" viewBox="0 0 26 24" aria-hidden="true">
<path d="M13 5.001c-4.03-.078-8.2 3.157-10.82 6.47-.276.35-.428.805-.428 1.277 0 .472.152.928.427 1.278C4.743 17.27 8.9 20.578 13 20.5c4.1.079 8.258-3.23 10.824-6.473.275-.35.428-.806.428-1.278s-.153-.927-.428-1.278C21.2 8.158 17.031 4.923 13 5.001z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
<path d="M16.75 12.751a3.75 3.75 0 11-7.5-.002 3.75 3.75 0 017.5.002z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
</svg>
{{ formatRelativeTime(member?.lastSeenAt) }}
</p>
</div>
<div v-if="!isNewMember" class="admin-member-form__side-section mt-12 border-t border-line pt-6">
<h3 class="text-xs font-semibold uppercase tracking-[0.04em] text-[#15171a]">가입 정보</h3>
<p class="mt-5 flex items-center gap-2 text-sm text-[#4d5663]">
<svg class="h-4 w-4 text-[#8a95a5]" fill="none" viewBox="0 0 24 24" aria-hidden="true">
<path d="M11.5 12c-2.824 0-2.83.024-4.5.53-3.5 1.058-5 3.176-5 6.386V21h10m7-5v6m-3-3h6m-10.5-7a5.5 5.5 0 100-11 5.5 5.5 0 000 11z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
</svg>
생성됨 <strong>{{ formatDate(member?.createdAt) }}</strong>
</p>
</div>
<div v-if="!isNewMember" class="admin-member-form__side-section mt-12 border-t border-line pt-6">
<h3 class="text-xs font-semibold uppercase tracking-[0.04em] text-[#15171a]">참여도</h3>
<p class="mt-5 text-sm leading-6 text-[#8a95a5]">
댓글 작성 {{ member?.commentCount || 0 }}
</p>
</div>
</aside>
<div class="admin-member-form__content space-y-8 xl:col-span-2">
<form class="admin-member-form__card rounded-xl border border-line bg-white p-5 md:p-6" @submit.prevent="saveMember">
<div class="grid gap-5 md:grid-cols-2">
<label class="admin-member-form__field block">
<span class="admin-member-form__label mb-2 block text-sm font-semibold text-[#15171a]">이름</span>
<input v-model="form.username" class="admin-member-form__input h-12 w-full rounded-md border border-transparent bg-[#eef1f4] px-4 text-sm text-[#15171a] outline-none focus:border-[#c5ccd5] focus:bg-white focus:ring-2 focus:ring-[#dce2e8]" type="text" maxlength="60" required>
</label>
<label class="admin-member-form__field block">
<span class="admin-member-form__label mb-2 block text-sm font-semibold text-[#15171a]">이메일</span>
<input v-model="form.email" class="admin-member-form__input h-12 w-full rounded-md border border-transparent bg-[#eef1f4] px-4 text-sm text-[#15171a] outline-none focus:border-[#c5ccd5] focus:bg-white focus:ring-2 focus:ring-[#dce2e8]" type="email" maxlength="254" required>
</label>
</div>
<label class="admin-member-form__field mt-5 block">
<span class="admin-member-form__label mb-2 block text-sm font-semibold text-[#15171a]">멤버 등급</span>
<span
v-if="shouldRenderRoleAsText"
class="admin-member-form__role-text flex h-12 w-full items-center rounded-md bg-[#eef1f4] px-4 text-sm font-semibold text-[#15171a]"
>
{{ currentRoleLabel }}
</span>
<span v-else class="admin-member-form__select-wrap relative block">
<select
v-model="form.roleCode"
class="admin-member-form__select h-12 w-full appearance-none rounded-md border border-transparent bg-[#eef1f4] px-4 pr-10 text-sm text-[#15171a] outline-none focus:border-[#c5ccd5] focus:bg-white focus:ring-2 focus:ring-[#dce2e8] disabled:opacity-60"
:disabled="!canEditRoleSelect"
>
<option v-for="option in availableRoleOptions" :key="option.value" :value="option.value">
{{ option.label }}
</option>
</select>
<svg class="pointer-events-none absolute right-4 top-1/2 size-4 -translate-y-1/2 text-[#394047]" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="m6 9 6 6 6-6" />
</svg>
</span>
<span class="admin-member-form__hint mt-2 block text-sm text-[#8a95a5]">
{{ roleHelpText }}
</span>
</label>
<label class="admin-member-form__field mt-5 block">
<span class="admin-member-form__label mb-2 block text-sm font-semibold text-[#15171a]">레이블</span>
<input v-model="form.labelsText" class="admin-member-form__input h-12 w-full rounded-md border border-transparent bg-[#eef1f4] px-4 text-sm text-[#15171a] outline-none focus:border-[#c5ccd5] focus:bg-white focus:ring-2 focus:ring-[#dce2e8]" type="text" placeholder="쉼표로 구분">
</label>
<label class="admin-member-form__field mt-5 block">
<span class="admin-member-form__label mb-2 block text-sm font-semibold text-[#15171a]">
노트 <span class="font-normal text-[#657080]">(멤버에게 보이지 않음)</span>
</span>
<textarea v-model="form.note" class="admin-member-form__textarea min-h-32 w-full resize-y rounded-md border border-transparent bg-[#eef1f4] px-4 py-3 text-sm text-[#15171a] outline-none focus:border-[#c5ccd5] focus:bg-white focus:ring-2 focus:ring-[#dce2e8]" maxlength="500" />
<span class="admin-member-form__count mt-2 block text-sm text-[#8a95a5]">
최대 500. 현재 {{ noteLength }}
</span>
</label>
<p v-if="saveMessage" class="admin-member-form__message mt-5 rounded-md border border-green-200 bg-green-50 px-4 py-3 text-sm text-green-700">{{ saveMessage }}</p>
<p v-if="saveError" class="admin-member-form__error mt-5 rounded-md border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">{{ saveError }}</p>
<p v-if="actionMessage" class="admin-member-form__message mt-5 rounded-md border border-green-200 bg-green-50 px-4 py-3 text-sm text-green-700">{{ actionMessage }}</p>
<p v-if="actionError && !passwordModalOpen && !deleteModalOpen" class="admin-member-form__error mt-5 rounded-md border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">{{ actionError }}</p>
</form>
<section v-if="!isNewMember" class="admin-member-form__activity">
<h2 class="admin-member-form__section-title mb-4 text-xs font-semibold uppercase tracking-[0.04em] text-[#15171a]">활동</h2>
<div class="admin-member-form__activity-card rounded-xl border border-line bg-white px-5 md:px-6">
<div class="admin-member-form__activity-row flex items-center justify-between gap-4 border-b border-line py-5 text-sm last:border-b-0">
<span class="flex items-center gap-3 text-[#3f4650]">
<svg class="h-5 w-5 text-[#6c747d]" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path d="M4 12h10.31m-3.076-3.076L14.31 12l-3.076 3.077" stroke="currentColor" stroke-width="1.714" stroke-linecap="round" stroke-linejoin="round" />
<path d="M4.998 16.308a7.69 7.69 0 003.733 3.182 7.238 7.238 0 004.8.189 7.608 7.608 0 003.949-2.88A8.283 8.283 0 0018.998 12c0-1.73-.533-3.414-1.518-4.798a7.607 7.607 0 00-3.949-2.88 7.237 7.237 0 00-4.8.188 7.69 7.69 0 00-3.733 3.182" stroke="currentColor" stroke-width="1.714" stroke-linecap="round" stroke-linejoin="round" />
</svg>
로그인
</span>
<span class="text-[#9aa4b2]">{{ formatRelativeTime(member?.lastSeenAt) }}</span>
</div>
<div class="admin-member-form__activity-row flex items-center justify-between gap-4 py-5 text-sm">
<span class="flex items-center gap-3 text-[#3f4650]">
<svg class="h-5 w-5 text-[#6c747d]" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path d="M11.246 12.144a4.242 4.242 0 100-8.484 4.242 4.242 0 000 8.484zM4 18.761a8.484 8.484 0 0110.5-3.42" stroke="currentColor" stroke-width="1.714" stroke-linecap="round" stroke-linejoin="round" />
<path d="M17.54 16.077V23m-3.463-3.46H21" stroke="#30CF43" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" />
</svg>
가입
</span>
<span class="text-[#9aa4b2]">{{ formatRelativeTime(member?.createdAt) }}</span>
</div>
</div>
</section>
</div>
</div>
<AdminUnsavedChangesModal
:open="isUnsavedModalOpen"
@stay="stayOnUnsavedPage"
@leave="leaveUnsavedPage"
/>
<Teleport to="body">
<div v-if="passwordModalOpen" class="admin-member-form__modal fixed inset-0 z-50 flex items-start justify-center bg-black/35 px-4 pt-10">
<section class="admin-member-form__modal-panel w-full max-w-[520px] rounded-xl bg-white text-[#15171a] shadow-[0_22px_70px_rgba(15,23,42,0.25)]">
<header class="flex items-center justify-between border-b border-line px-6 py-5">
<h2 class="text-xl font-semibold">비밀번호 변경</h2>
<button class="grid h-8 w-8 place-items-center rounded-md text-[#657080] hover:bg-[#f3f5f7]" type="button" aria-label="닫기" @click="closePasswordModal">
<svg class="h-4 w-4" viewBox="0 0 24 24" aria-hidden="true">
<path d="M12.707 12L23.854.854a.5.5 0 00-.707-.707L12 11.293.854.146a.5.5 0 00-.707.707L11.293 12 .146 23.146a.5.5 0 00.708.708L12 12.707l11.146 11.146a.5.5 0 10.708-.706L12.707 12z" fill="currentColor" />
</svg>
</button>
</header>
<div class="grid gap-4 px-6 py-5">
<p class="text-sm leading-6 text-[#657080]">
이메일 전송이 불가능한 상황을 대비해 관리자가 직접 비밀번호를 설정합니다.
</p>
<label class="grid gap-2 text-sm font-semibold">
비밀번호
<input v-model="passwordForm.password" class="h-11 rounded-md border border-transparent bg-[#eef1f4] px-4 text-sm outline-none focus:border-[#c5ccd5] focus:bg-white focus:ring-2 focus:ring-[#dce2e8]" type="password" autocomplete="new-password">
</label>
<label class="grid gap-2 text-sm font-semibold">
비밀번호 확인
<input v-model="passwordForm.passwordConfirm" class="h-11 rounded-md border border-transparent bg-[#eef1f4] px-4 text-sm outline-none focus:border-[#c5ccd5] focus:bg-white focus:ring-2 focus:ring-[#dce2e8]" type="password" autocomplete="new-password">
</label>
<p v-if="actionError" class="rounded-md border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">{{ actionError }}</p>
</div>
<footer class="flex justify-end gap-2 border-t border-line px-6 py-4">
<button class="h-10 rounded-md border border-line bg-white px-4 text-sm font-semibold text-[#3f4650]" type="button" @click="closePasswordModal">
취소
</button>
<button class="h-10 rounded-md bg-[#15171a] px-4 text-sm font-semibold text-white disabled:opacity-50" type="button" :disabled="isUpdatingPassword" @click="updateMemberPassword">
{{ isUpdatingPassword ? '변경 ' : '변경' }}
</button>
</footer>
</section>
</div>
</Teleport>
<Teleport to="body">
<div v-if="deleteModalOpen" class="admin-member-form__modal fixed inset-0 z-50 flex items-start justify-center bg-black/35 px-4 pt-10">
<section class="admin-member-form__modal-panel w-full max-w-[520px] rounded-xl bg-white text-[#15171a] shadow-[0_22px_70px_rgba(15,23,42,0.25)]">
<header class="flex items-center justify-between border-b border-line px-6 py-5">
<h2 class="text-xl font-semibold">멤버 삭제</h2>
<button class="grid h-8 w-8 place-items-center rounded-md text-[#657080] hover:bg-[#f3f5f7]" type="button" aria-label="닫기" @click="closeDeleteModal">
<svg class="h-4 w-4" viewBox="0 0 24 24" aria-hidden="true">
<path d="M12.707 12L23.854.854a.5.5 0 00-.707-.707L12 11.293.854.146a.5.5 0 00-.707.707L11.293 12 .146 23.146a.5.5 0 00.708.708L12 12.707l11.146 11.146a.5.5 0 10.708-.706L12.707 12z" fill="currentColor" />
</svg>
</button>
</header>
<div class="grid gap-4 px-6 py-5">
<p class="text-sm leading-6 text-[#657080]">
삭제하면 멤버 계정과 작성 댓글이 함께 삭제됩니다. 계속하려면 아래에 <strong class="text-[#15171a]">{{ form.email }}</strong> 입력해 주세요.
</p>
<input v-model="deleteForm.confirmText" class="h-11 rounded-md border border-transparent bg-[#eef1f4] px-4 text-sm outline-none focus:border-[#c5ccd5] focus:bg-white focus:ring-2 focus:ring-[#dce2e8]" type="text" autocomplete="off">
<p v-if="actionError" class="rounded-md border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">{{ actionError }}</p>
</div>
<footer class="flex justify-end gap-2 border-t border-line px-6 py-4">
<button class="h-10 rounded-md border border-line bg-white px-4 text-sm font-semibold text-[#3f4650]" type="button" @click="closeDeleteModal">
취소
</button>
<button class="h-10 rounded-md bg-[#d21a26] px-4 text-sm font-semibold text-white disabled:opacity-50" type="button" :disabled="isDeletingMember" @click="deleteMember">
{{ isDeletingMember ? '삭제 ' : '삭제' }}
</button>
</footer>
</section>
</div>
</Teleport>
</section>
</template>