멤버 상세 수정 모드 정리 v1.5.11

This commit is contained in:
2026-05-27 11:00:05 +09:00
parent 8ca63c0d00
commit 7f017a03a5
9 changed files with 131 additions and 50 deletions

View File

@@ -12,6 +12,8 @@ const props = defineProps({
const emit = defineEmits(['saved', 'deleted'])
const { toast, showToast } = useAdminToast()
const { data: adminSession } = await useFetch('/admin/api/auth/me', {
default: () => ({
userId: '',
@@ -20,18 +22,16 @@ const { data: adminSession } = await useFetch('/admin/api/auth/me', {
})
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 isEditingMember = ref(props.mode === 'new')
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 = [
@@ -93,15 +93,16 @@ const normalizedLabels = computed(() => [...new Set(
.filter(Boolean)
)])
const isFormEditable = computed(() => isNewMember.value || isEditingMember.value)
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 shouldRenderRoleAsText = computed(() => !isFormEditable.value || isNewMember.value || !isCurrentAdminPrivileged.value)
const canEditRoleSelect = computed(() => {
if (shouldRenderRoleAsText.value || isSaving.value) {
if (!isFormEditable.value || shouldRenderRoleAsText.value || isSaving.value) {
return false
}
@@ -127,6 +128,10 @@ const availableRoleOptions = computed(() => {
return roleOptions
})
const roleHelpText = computed(() => {
if (!isFormEditable.value) {
return '수정하기를 누르면 변경 가능한 항목을 편집할 수 있습니다.'
}
if (shouldRenderRoleAsText.value) {
return '멤버와 VIP는 관리자 권한이 없어 등급을 변경할 수 없습니다.'
}
@@ -229,18 +234,32 @@ const getMemberPayload = () => ({
})
const hasUnsavedMemberChanges = computed(() => serializeMemberPayload() !== savedMemberSnapshot.value)
const shouldBlockUnsavedMemberChanges = computed(() => isFormEditable.value && hasUnsavedMemberChanges.value)
const canSubmitMemberForm = computed(() => isFormEditable.value && hasUnsavedMemberChanges.value && !isSaving.value)
const {
isUnsavedModalOpen,
stayOnUnsavedPage,
leaveUnsavedPage
} = useAdminUnsavedChangesGuard(hasUnsavedMemberChanges)
} = useAdminUnsavedChangesGuard(shouldBlockUnsavedMemberChanges)
/**
* 회원 상세를 수정 모드로 전환한다.
* @returns {void}
*/
const enterEditMode = () => {
isEditingMember.value = true
}
/**
* 썸네일 파일 선택창을 연다.
* @returns {void}
*/
const openAvatarFilePicker = () => {
if (!isFormEditable.value) {
return
}
avatarInputRef.value?.click()
}
@@ -257,9 +276,11 @@ const uploadAvatar = async (event) => {
return
}
if (!isFormEditable.value) {
return
}
isUploadingAvatar.value = true
saveError.value = ''
saveMessage.value = ''
try {
const formData = new FormData()
@@ -278,10 +299,10 @@ const uploadAvatar = async (event) => {
if (!isNewMember.value) {
emit('saved', result)
savedMemberSnapshot.value = serializeMemberPayload()
saveMessage.value = '썸네일이 변경되었습니다.'
showToast('success', '썸네일이 변경되었습니다.')
}
} catch (error) {
saveError.value = error?.data?.message || '썸네일 업로드에 실패했습니다.'
showToast('error', error?.data?.message || '썸네일 업로드에 실패했습니다.')
} finally {
isUploadingAvatar.value = false
if (target) {
@@ -295,6 +316,10 @@ const uploadAvatar = async (event) => {
* @returns {void}
*/
const removeAvatar = () => {
if (!isFormEditable.value) {
return
}
form.avatarUrl = ''
}
@@ -321,7 +346,6 @@ const closeActionMenu = () => {
const openPasswordModal = () => {
passwordForm.password = ''
passwordForm.passwordConfirm = ''
actionMessage.value = ''
actionError.value = ''
passwordModalOpen.value = true
closeActionMenu()
@@ -333,7 +357,6 @@ const openPasswordModal = () => {
*/
const openDeleteModal = () => {
deleteForm.confirmText = ''
actionMessage.value = ''
actionError.value = ''
deleteModalOpen.value = true
closeActionMenu()
@@ -368,7 +391,6 @@ const closeDeleteModal = () => {
* @returns {Promise<void>}
*/
const updateMemberPassword = async () => {
actionMessage.value = ''
actionError.value = ''
if (!passwordForm.password || passwordForm.password.length < 8 || passwordForm.password.length > 32) {
@@ -392,9 +414,11 @@ const updateMemberPassword = async () => {
passwordModalOpen.value = false
passwordForm.password = ''
passwordForm.passwordConfirm = ''
actionMessage.value = '비밀번호가 변경되었습니다.'
showToast('success', '비밀번호가 변경되었습니다.')
} catch (error) {
actionError.value = error?.data?.message || '비밀번호 변경에 실패했습니다.'
const message = error?.data?.message || '비밀번호 변경에 실패했습니다.'
actionError.value = message
showToast('error', message)
} finally {
isUpdatingPassword.value = false
}
@@ -405,7 +429,6 @@ const updateMemberPassword = async () => {
* @returns {Promise<void>}
*/
const deleteMember = async () => {
actionMessage.value = ''
actionError.value = ''
if (deleteForm.confirmText !== form.email) {
@@ -420,7 +443,9 @@ const deleteMember = async () => {
})
emit('deleted')
} catch (error) {
actionError.value = error?.data?.message || '회원 삭제에 실패했습니다.'
const message = error?.data?.message || '회원 삭제에 실패했습니다.'
actionError.value = message
showToast('error', message)
} finally {
isDeletingMember.value = false
}
@@ -435,8 +460,15 @@ const saveMember = async () => {
return
}
saveMessage.value = ''
saveError.value = ''
if (!isFormEditable.value) {
enterEditMode()
return
}
if (!hasUnsavedMemberChanges.value) {
return
}
isSaving.value = true
try {
@@ -462,9 +494,10 @@ const saveMember = async () => {
savedMemberSnapshot.value = serializeMemberPayload()
emit('saved', saved)
saveMessage.value = '저장되었습니다.'
isEditingMember.value = isNewMember.value
showToast('success', '저장되었습니다.')
} catch (error) {
saveError.value = error?.data?.message || '저장에 실패했습니다.'
showToast('error', error?.data?.message || '저장에 실패했습니다.')
} finally {
isSaving.value = false
}
@@ -516,8 +549,13 @@ watch(() => props.member, () => {
<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
class="admin-member-form__save h-11 rounded-md bg-[#15171a] px-5 text-sm font-semibold text-white transition hover:bg-[#2b2f35] disabled:cursor-not-allowed disabled:bg-[#c7cdd4] disabled:text-white"
type="button"
:disabled="isFormEditable && !canSubmitMemberForm"
@click="saveMember"
>
{{ !isFormEditable ? '수정하기' : isSaving ? '저장 중' : '저장' }}
</button>
</div>
</div>
@@ -531,6 +569,8 @@ watch(() => props.member, () => {
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 ? '썸네일 변경' : '썸네일 등록'"
:disabled="!isFormEditable"
:class="{ 'cursor-default': !isFormEditable }"
@click="openAvatarFilePicker"
>
<img
@@ -543,11 +583,11 @@ watch(() => props.member, () => {
{{ 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 ? '썸네일 변경' : '썸네일 등록' }}
{{ !isFormEditable ? '현재 썸네일' : isUploadingAvatar ? '업로드 중' : form.avatarUrl ? '썸네일 변경' : '썸네일 등록' }}
</span>
</button>
<button
v-if="form.avatarUrl"
v-if="form.avatarUrl && isFormEditable"
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="썸네일 제거"
@@ -562,7 +602,6 @@ watch(() => props.member, () => {
<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>
@@ -606,11 +645,17 @@ watch(() => props.member, () => {
<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>
<span v-if="!isFormEditable" class="admin-member-form__readonly flex min-h-12 items-center text-sm text-[#15171a]">
{{ form.username || '-' }}
</span>
<input v-else v-model="form.username" class="admin-member-form__input h-12 w-full rounded-md border border-[#d7dce0] bg-white px-4 text-sm text-[#15171a] outline-none focus:border-[#8e9cac] 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>
<span v-if="!isFormEditable" class="admin-member-form__readonly flex min-h-12 items-center text-sm text-[#15171a]">
{{ form.email || '-' }}
</span>
<input v-else v-model="form.email" class="admin-member-form__input h-12 w-full rounded-md border border-[#d7dce0] bg-white px-4 text-sm text-[#15171a] outline-none focus:border-[#8e9cac] focus:ring-2 focus:ring-[#dce2e8]" type="email" maxlength="254" required>
</label>
</div>
@@ -618,14 +663,15 @@ watch(() => props.member, () => {
<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]"
class="admin-member-form__role-text flex min-h-12 w-full items-center text-sm font-semibold text-[#15171a]"
:class="{ 'rounded-md border border-[#d7dce0] bg-white px-4': isFormEditable }"
>
{{ 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"
class="admin-member-form__select h-12 w-full appearance-none rounded-md border border-[#d7dce0] bg-white px-4 pr-10 text-sm text-[#15171a] outline-none focus:border-[#8e9cac] focus:ring-2 focus:ring-[#dce2e8] disabled:opacity-60"
:disabled="!canEditRoleSelect"
>
<option v-for="option in availableRoleOptions" :key="option.value" :value="option.value">
@@ -643,23 +689,24 @@ watch(() => props.member, () => {
<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="쉼표로 구분">
<span v-if="!isFormEditable" class="admin-member-form__readonly flex min-h-12 items-center text-sm text-[#15171a]">
{{ form.labelsText || '-' }}
</span>
<input v-else v-model="form.labelsText" class="admin-member-form__input h-12 w-full rounded-md border border-[#d7dce0] bg-white px-4 text-sm text-[#15171a] outline-none focus:border-[#8e9cac] 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" />
<p v-if="!isFormEditable" class="admin-member-form__readonly min-h-24 whitespace-pre-wrap text-sm leading-6 text-[#15171a]">
{{ form.note || '-' }}
</p>
<textarea v-else v-model="form.note" class="admin-member-form__textarea min-h-32 w-full resize-y rounded-md border border-[#d7dce0] bg-white px-4 py-3 text-sm text-[#15171a] outline-none focus:border-[#8e9cac] 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">
@@ -696,6 +743,21 @@ watch(() => props.member, () => {
@leave="leaveUnsavedPage"
/>
<Teleport to="body">
<div
v-if="toast"
class="admin-member-form__toast fixed right-5 top-5 z-[100] max-w-[min(24rem,calc(100vw-2.5rem))] rounded border px-4 py-3 text-sm font-semibold shadow-lg"
:class="{
'border-green-200 bg-green-50 text-green-800': toast.type === 'success',
'border-red-200 bg-red-50 text-red-800': toast.type === 'error',
'border-[#e3e6e8] bg-white text-[#15171a]': toast.type === 'info'
}"
role="status"
>
{{ toast.message }}
</div>
</Teleport>
<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)]">