멤버 필터와 썸네일 편집 개선

This commit is contained in:
2026-05-13 11:43:38 +09:00
parent 79d0a30475
commit 6481f958f5
9 changed files with 483 additions and 39 deletions

View File

@@ -17,6 +17,8 @@ const saveMessage = ref('')
const saveError = ref('')
const isSaving = ref(false)
const savedMemberSnapshot = ref('')
const avatarInputRef = ref(null)
const isUploadingAvatar = ref(false)
const form = reactive({
username: '',
@@ -146,6 +148,57 @@ const {
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('files', file)
const result = await $fetch('/admin/api/uploads', {
method: 'POST',
body: formData
})
form.avatarUrl = result.files?.[0]?.url || ''
} catch (error) {
saveError.value = error?.data?.message || '썸네일 업로드에 실패했습니다.'
} finally {
isUploadingAvatar.value = false
if (target) {
target.value = ''
}
}
}
/**
* 회원 썸네일 연결을 제거한다.
* @returns {void}
*/
const removeAvatar = () => {
form.avatarUrl = ''
}
/**
* 회원 기본 정보를 저장한다.
* @returns {Promise<void>}
@@ -221,15 +274,39 @@ watch(() => props.member, () => {
<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">
<img
v-if="form.avatarUrl"
class="admin-member-form__avatar h-20 w-20 rounded-full object-cover"
:src="form.avatarUrl"
:alt="pageTitle"
>
<span v-else class="admin-member-form__avatar flex h-20 w-20 items-center justify-center rounded-full bg-[#15171a] text-2xl font-semibold text-white">
{{ memberInitial }}
</span>
<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>
@@ -284,11 +361,6 @@ watch(() => props.member, () => {
</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]">썸네일 URL</span>
<input v-model="form.avatarUrl" 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="500" placeholder="/uploads/members/avatars/...">
</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="쉼표로 구분">

View File

@@ -13,7 +13,7 @@ defineEmits(['stay', 'leave'])
<Teleport to="body">
<div
v-if="open"
class="admin-unsaved-modal fixed inset-0 z-[100] flex items-center justify-center bg-black/40 px-5 py-8"
class="admin-unsaved-modal fixed inset-0 z-[100] flex items-start justify-center bg-black/40 px-5 pb-8 pt-10"
role="dialog"
aria-modal="true"
aria-labelledby="admin-unsaved-modal-title"

View File

@@ -1,5 +1,11 @@
# 의사결정 이력
## 2026-05-13 v0.0.113
### 멤버 필터와 썸네일 편집 방식 정리
멤버 목록은 검색만으로는 “Gmail 사용자 제외”, “비활성 사용자”, “특정 날짜 이후 접속 없음”처럼 운영자가 자주 쓰는 조건을 표현하기 어렵다. 서버 API를 먼저 확장하지 않고 현재 화면의 회원 목록 데이터를 기준으로 클라이언트 조건 필터를 제공해 UI 흐름을 빠르게 검증한다. 멤버 상세의 썸네일은 URL 문자열보다 이미지 자체를 클릭해 등록·변경·제거하는 방식이 더 자연스러우므로, URL 입력 필드는 숨기고 요약 영역의 원형 썸네일 액션으로 책임을 옮긴다.
## 2026-05-13 v0.0.112
### 관리자 편집 화면 이탈 확인 공통화

View File

@@ -60,8 +60,8 @@
| components/admin/AdminBlockEditor.vue | 관리자 글 블록형 에디터, 이미지/갤러리/콜아웃/토글/임베드 블록, 한글 조합 입력 처리, Shift+Enter 줄바꿈, 코드 블록 단축 변환, AFFiNE 참고 세로 막대형 블록 핸들 선택/삭제/드래그 이동과 삽입선 표시, 하단 빈 입력 블록 유지, 본문 placeholder 표시 |
| components/admin/AdminNavPrimaryBranch.vue | 관리자 상단 네비 트리(테이블·태그와 동일한 행 드래그 하이라이트, 하위·삭제) |
| components/admin/AdminTagForm.vue | 관리자 태그 생성/수정 폼(이름/슬러그/설명/색상만 편집) |
| components/admin/AdminMemberForm.vue | 관리자 멤버 추가/수정 폼(Ghost형 3분할, 요약 1fr·입력 2fr, 기본 정보·레이블·관리자 노트·활동 요약, 미저장 변경사항 이탈 확인) |
| components/admin/AdminUnsavedChangesModal.vue | 관리자 편집 화면 공통 미저장 변경사항 이탈 확인 모달 |
| components/admin/AdminMemberForm.vue | 관리자 멤버 추가/수정 폼(Ghost형 3분할, 요약 1fr·입력 2fr, 원형 썸네일 hover 등록·변경·삭제, 기본 정보·레이블·관리자 노트·활동 요약, 미저장 변경사항 이탈 확인) |
| components/admin/AdminUnsavedChangesModal.vue | 관리자 편집 화면 공통 미저장 변경사항 이탈 확인 모달(상단 40px 위치) |
## 관리자 컴포저블
@@ -110,7 +110,7 @@
| pages/admin/tags/new.vue | 태그 생성 |
| pages/admin/tags/[id].vue | 태그 수정 |
| pages/admin/settings/index.vue | 사이트 설정 + 관리자 프로필(썸네일/이름) + 관리자 비밀번호 변경 |
| pages/admin/members/index.vue | 관리자 멤버 목록(Ghost형 테이블, 검색, 멤버 추가 버튼, 닉네임+이메일, 가입일+최근 활동, IP, 댓글 수, 활동 상태) |
| pages/admin/members/index.vue | 관리자 멤버 목록(Ghost형 테이블, 검색, 조건 필터, 멤버 추가 버튼, 닉네임+이메일, 가입일+최근 활동, IP, 댓글 수, 활동 상태 텍스트) |
| pages/admin/members/new.vue | 관리자 멤버 추가(썸네일 URL, 이름, 이메일, 레이블, 관리자 노트) |
| pages/admin/members/[id].vue | 관리자 멤버 상세/수정(회원 요약, 가입 정보, 활동 요약, 기본 정보 저장) |

View File

@@ -554,9 +554,9 @@ components/content/
- 관리자 로그인 성공 시 관리자/회원 세션 쿠키를 함께 설정해 관리자 화면에서도 프로필(썸네일, 이름) 수정 API를 공통으로 사용한다.
- 관리자 설정 화면에서 관리자 프로필(이름, 썸네일)과 관리자 비밀번호를 수정할 수 있다.
- 관리자 멤버 목록은 Ghost형 테이블 기준으로 닉네임 아래 이메일, 가입일 아래 최근 활동을 함께 표시한다.
- 관리자 멤버 목록은 멤버 검색 멤버 추가 버튼을 제공한다.
- 관리자 멤버 목록은 멤버 검색, 멤버 추가 버튼, 조건 필터를 제공한다. 필터는 이름·이메일·레이블 텍스트 조건, 활성/비활성 상태 조건, 최근 접속·가입일 날짜 조건을 지원하며 여러 조건은 AND로 적용한다.
- 관리자 멤버 목록은 뉴스레터 지표 대신 댓글 작성 개수를 표시한다.
- 관리자 멤버 상세/추가 화면은 Ghost형 편집 화면을 기준으로 썸네일, 이름, 이메일, 레이블, 관리자 노트를 편집한다. 본문은 3분할 그리드로 요약 영역 1fr, 입력 영역 2fr 비율을 사용한다. 기존 회원 상세는 가입 정보와 활동 요약을 표시하고, 신규 회원 화면은 활동 영역을 표시하지 않는다.
- 관리자 멤버 상세/추가 화면은 Ghost형 편집 화면을 기준으로 썸네일, 이름, 이메일, 레이블, 관리자 노트를 편집한다. 본문은 3분할 그리드로 요약 영역 1fr, 입력 영역 2fr 비율을 사용한다. 썸네일은 URL 입력 대신 요약 영역의 원형 썸네일 hover 액션으로 등록·변경·제거한다. 기존 회원 상세는 가입 정보와 활동 요약을 표시하고, 신규 회원 화면은 활동 영역을 표시하지 않는다.
- 관리자 게시글 작성/수정과 멤버 추가/수정 화면은 저장되지 않은 변경사항이 있을 때 내부 라우트 이동을 공통 확인 모달로 막는다. 브라우저 새로고침·탭 닫기는 브라우저 기본 `beforeunload` 확인을 사용한다. 게시글 화면에서 이탈을 승인하면 해당 로컬 자동 저장본은 삭제한다.
- `users.member_labels`는 관리자용 문자열 배열이며, 이후 사용자별 칭호/분류 용도로 확장한다. `users.member_note`는 관리자에게만 보이는 500자 이하 메모다.
- 관리자 멤버 권한은 `소유자(owner)`, `관리자(admin)`, `멤버(member)` 3단계를 유지하되, 목록 화면에서는 변경 UI를 노출하지 않고 상세/후속 화면에서 변경한다.

View File

@@ -1,5 +1,14 @@
# 업데이트 이력
## v0.0.113
- 관리자 미저장 변경사항 모달을 화면 상단 40px 여백 위치로 조정.
- 관리자 멤버 상세 폼에서 썸네일 URL 입력 필드 제거.
- 관리자 멤버 상세 요약 썸네일에 hover 등록·변경·삭제 UI 추가.
- 관리자 멤버 목록 상태 표시를 배지에서 일반 텍스트로 변경.
- 관리자 멤버 목록에 이름·이메일·레이블·상태·최근 접속·가입일 조건 필터 추가.
- 패키지 버전 `0.0.113`으로 갱신.
## v0.0.112
- 관리자 멤버 폼 본문을 3분할 그리드로 변경하고 요약 1fr, 입력 영역 2fr 비율로 조정.

4
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "sori.studio",
"version": "0.0.112",
"version": "0.0.113",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "sori.studio",
"version": "0.0.112",
"version": "0.0.113",
"hasInstallScript": true,
"dependencies": {
"@nuxtjs/tailwindcss": "^6.14.0",

View File

@@ -1,6 +1,6 @@
{
"name": "sori.studio",
"version": "0.0.112",
"version": "0.0.113",
"private": true,
"type": "module",
"imports": {

View File

@@ -4,24 +4,275 @@ definePageMeta({
})
const memberSearchQuery = ref('')
const isFilterOpen = ref(false)
const filterDrafts = ref([
{
id: 1,
field: 'email',
operator: 'contains',
value: ''
}
])
const activeFilters = ref([])
let nextFilterId = 2
const { data: members } = await useFetch('/admin/api/members', {
default: () => []
})
const filteredMembers = computed(() => {
const query = memberSearchQuery.value.trim().toLowerCase()
const dateFilterFields = ['lastSeenAt', 'createdAt']
if (!query) {
return members.value
const filterFieldOptions = [
{ value: 'name', label: '이름' },
{ value: 'email', label: '이메일' },
{ value: 'label', label: '레이블' },
{ value: 'status', label: '상태' },
{ value: 'lastSeenAt', label: '최근 접속' },
{ value: 'createdAt', label: '가입일' }
]
const operatorOptionsByFieldType = {
text: [
{ value: 'contains', label: '포함' },
{ value: 'notContains', label: '포함하지 않음' },
{ value: 'is', label: '일치' },
{ value: 'startsWith', label: '시작' },
{ value: 'endsWith', label: '끝남' }
],
status: [
{ value: 'is', label: '일치' },
{ value: 'isNot', label: '일치하지 않음' }
],
date: [
{ value: 'before', label: '이전' },
{ value: 'after', label: '이후' },
{ value: 'empty', label: '없음' },
{ value: 'notEmpty', label: '있음' }
]
}
const statusFilterOptions = [
{ value: '활성', label: '활성' },
{ value: '비활성', label: '비활성' }
]
/**
* 필터 필드 타입을 반환한다.
* @param {string} field - 필터 필드
* @returns {'text'|'status'|'date'} 필드 타입
*/
const getFilterFieldType = (field) => {
if (field === 'status') {
return 'status'
}
return members.value.filter((member) => [
member.username,
member.email,
member.lastSeenIp,
member.activityStatus
].some((value) => String(value || '').toLowerCase().includes(query)))
if (dateFilterFields.includes(field)) {
return 'date'
}
return 'text'
}
/**
* 필터 필드에 맞는 연산자 목록을 반환한다.
* @param {string} field - 필터 필드
* @returns {Array<{ value: string, label: string }>} 연산자 목록
*/
const getFilterOperatorOptions = (field) => operatorOptionsByFieldType[getFilterFieldType(field)]
/**
* 필터 대상 회원 값을 반환한다.
* @param {Object} member - 회원 정보
* @param {string} field - 필터 필드
* @returns {string} 필터 대상 값
*/
const getFilterMemberValue = (member, field) => {
if (field === 'name') {
return member.username || ''
}
if (field === 'label') {
return Array.isArray(member.labels) ? member.labels.join(', ') : ''
}
if (field === 'status') {
return member.activityStatus || '비활성'
}
return member[field] || ''
}
/**
* 텍스트 필터 조건을 검사한다.
* @param {string} source - 원본 값
* @param {string} operator - 연산자
* @param {string} target - 비교 값
* @returns {boolean} 조건 충족 여부
*/
const matchesTextFilter = (source, operator, target) => {
const sourceText = source.toLowerCase()
const targetText = target.toLowerCase()
if (!targetText) {
return true
}
if (operator === 'is') {
return sourceText === targetText
}
if (operator === 'notContains') {
return !sourceText.includes(targetText)
}
if (operator === 'startsWith') {
return sourceText.startsWith(targetText)
}
if (operator === 'endsWith') {
return sourceText.endsWith(targetText)
}
return sourceText.includes(targetText)
}
/**
* 날짜 필터 조건을 검사한다.
* @param {string | null} value - ISO 날짜 문자열
* @param {string} operator - 연산자
* @param {string} target - yyyy-mm-dd 비교 값
* @returns {boolean} 조건 충족 여부
*/
const matchesDateFilter = (value, operator, target) => {
if (operator === 'empty') {
return !value
}
if (operator === 'notEmpty') {
return Boolean(value)
}
if (!value || !target) {
return true
}
const sourceTime = new Date(value).getTime()
const targetTime = new Date(`${target}T00:00:00`).getTime()
if (Number.isNaN(sourceTime) || Number.isNaN(targetTime)) {
return true
}
return operator === 'before'
? sourceTime < targetTime
: sourceTime >= targetTime
}
/**
* 회원이 단일 필터 조건을 만족하는지 확인한다.
* @param {Object} member - 회원 정보
* @param {Object} filter - 필터 조건
* @returns {boolean} 조건 충족 여부
*/
const matchesMemberFilter = (member, filter) => {
const fieldType = getFilterFieldType(filter.field)
const source = String(getFilterMemberValue(member, filter.field) || '')
if (fieldType === 'date') {
return matchesDateFilter(source, filter.operator, filter.value)
}
if (fieldType === 'status') {
return filter.operator === 'isNot'
? source !== filter.value
: source === filter.value
}
return matchesTextFilter(source, filter.operator, filter.value)
}
/**
* 필터 조건 필드를 변경한다.
* @param {Object} filter - 필터 조건
* @returns {void}
*/
const changeFilterField = (filter) => {
const fieldType = getFilterFieldType(filter.field)
filter.operator = getFilterOperatorOptions(filter.field)[0].value
filter.value = fieldType === 'status' ? '활성' : ''
}
/**
* 새 필터 조건을 추가한다.
* @returns {void}
*/
const addFilter = () => {
filterDrafts.value.push({
id: nextFilterId,
field: 'email',
operator: 'contains',
value: ''
})
nextFilterId += 1
}
/**
* 필터 조건을 삭제한다.
* @param {number} id - 필터 ID
* @returns {void}
*/
const removeFilter = (id) => {
filterDrafts.value = filterDrafts.value.filter((filter) => filter.id !== id)
if (!filterDrafts.value.length) {
addFilter()
}
}
/**
* 필터 조건을 적용한다.
* @returns {void}
*/
const applyFilters = () => {
activeFilters.value = filterDrafts.value
.filter((filter) => ['empty', 'notEmpty'].includes(filter.operator) || String(filter.value || '').trim())
.map((filter) => ({ ...filter }))
isFilterOpen.value = false
}
/**
* 모든 필터 조건을 초기화한다.
* @returns {void}
*/
const resetFilters = () => {
filterDrafts.value = [
{
id: nextFilterId,
field: 'email',
operator: 'contains',
value: ''
}
]
nextFilterId += 1
activeFilters.value = []
}
const filteredMembers = computed(() => {
const query = memberSearchQuery.value.trim().toLowerCase()
const searchedMembers = !query
? members.value
: members.value.filter((member) => [
member.username,
member.email,
member.lastSeenIp,
member.activityStatus
].some((value) => String(value || '').toLowerCase().includes(query)))
if (!activeFilters.value.length) {
return searchedMembers
}
return searchedMembers.filter((member) => activeFilters.value.every((filter) => matchesMemberFilter(member, filter)))
})
const memberCountLabel = computed(() => {
@@ -29,6 +280,8 @@ const memberCountLabel = computed(() => {
return `${count}`
})
const activeFilterCount = computed(() => activeFilters.value.length)
/**
* 회원 상세 화면으로 이동한다.
* @param {Object} member - 회원 정보
@@ -136,6 +389,20 @@ const formatRelativeTime = (value) => {
aria-label="멤버 검색"
>
</label>
<button
class="admin-members__filter inline-flex h-11 items-center justify-center gap-2 rounded-md border border-line bg-white px-4 text-sm font-semibold text-[#394047] transition hover:bg-[#eff1f2] hover:text-black"
type="button"
:aria-expanded="isFilterOpen"
@click="isFilterOpen = !isFilterOpen"
>
<svg class="h-4 w-4" viewBox="0 0 18 18" fill="none" aria-hidden="true">
<path d="M6.5 13.502H12M4 9.004L14 9M1.688 4.502h14.624" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" />
</svg>
<span>필터</span>
<span v-if="activeFilterCount" class="admin-members__filter-count rounded-full bg-[#15171a] px-1.5 py-0.5 text-[11px] leading-none text-white">
{{ activeFilterCount }}
</span>
</button>
<NuxtLink
class="admin-members__new inline-flex h-11 items-center rounded-md bg-[#15171a] px-4 text-sm font-semibold text-white transition hover:bg-[#2b2f35] focus-visible:outline focus-visible:ring-2 focus-visible:ring-[#8e9cac] focus-visible:ring-offset-2"
to="/admin/members/new"
@@ -145,6 +412,101 @@ const formatRelativeTime = (value) => {
</div>
</div>
<section v-if="isFilterOpen" class="admin-members__filter-panel mt-2 rounded-lg border border-line bg-white p-6 shadow-xl">
<h2 class="admin-members__filter-title text-2xl font-semibold tracking-[-0.01em] text-[#15171a]">
필터 목록
</h2>
<div class="admin-members__filter-body mt-6 rounded-md bg-[#eef1f4] p-5">
<div class="admin-members__filter-rules grid gap-3">
<div
v-for="filter in filterDrafts"
:key="filter.id"
class="admin-members__filter-row grid gap-2 lg:grid-cols-[minmax(0,1fr)_220px_minmax(0,1fr)_36px]"
>
<label class="admin-members__filter-field">
<span class="sr-only">필터 항목</span>
<select
v-model="filter.field"
class="admin-members__filter-select h-11 w-full rounded-md border border-[#d7dce0] bg-white px-3 text-sm text-[#15171a] outline-none focus:border-[#8e9cac] focus:ring-2 focus:ring-[#dce2e8]"
@change="changeFilterField(filter)"
>
<option v-for="option in filterFieldOptions" :key="option.value" :value="option.value">
{{ option.label }}
</option>
</select>
</label>
<label class="admin-members__filter-operator">
<span class="sr-only">필터 조건</span>
<select
v-model="filter.operator"
class="admin-members__filter-select h-11 w-full rounded-md border border-[#d7dce0] bg-white px-3 text-sm text-[#15171a] outline-none focus:border-[#8e9cac] focus:ring-2 focus:ring-[#dce2e8]"
>
<option v-for="option in getFilterOperatorOptions(filter.field)" :key="option.value" :value="option.value">
{{ option.label }}
</option>
</select>
</label>
<label class="admin-members__filter-value">
<span class="sr-only">필터 </span>
<select
v-if="getFilterFieldType(filter.field) === 'status'"
v-model="filter.value"
class="admin-members__filter-select h-11 w-full rounded-md border border-[#d7dce0] bg-white px-3 text-sm text-[#15171a] outline-none focus:border-[#8e9cac] focus:ring-2 focus:ring-[#dce2e8]"
>
<option v-for="option in statusFilterOptions" :key="option.value" :value="option.value">
{{ option.label }}
</option>
</select>
<input
v-else-if="getFilterFieldType(filter.field) === 'date' && !['empty', 'notEmpty'].includes(filter.operator)"
v-model="filter.value"
class="admin-members__filter-input h-11 w-full rounded-md border border-[#d7dce0] bg-white px-3 text-sm text-[#15171a] outline-none focus:border-[#8e9cac] focus:ring-2 focus:ring-[#dce2e8]"
type="date"
>
<input
v-else-if="getFilterFieldType(filter.field) !== 'date'"
v-model="filter.value"
class="admin-members__filter-input h-11 w-full rounded-md border border-[#d7dce0] bg-white px-3 text-sm text-[#15171a] outline-none focus:border-[#8e9cac] focus:ring-2 focus:ring-[#dce2e8]"
type="text"
placeholder="값 입력"
>
<span v-else class="admin-members__filter-empty-value flex h-11 items-center rounded-md border border-[#d7dce0] bg-white px-3 text-sm text-[#8a95a5]">
필요 없음
</span>
</label>
<button
class="admin-members__filter-delete grid h-11 w-9 place-items-center rounded-md text-[#657080] transition hover:bg-white hover:text-[#15171a]"
type="button"
aria-label="필터 삭제"
@click="removeFilter(filter.id)"
>
<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>
</div>
</div>
<button
class="admin-members__filter-add mt-5 inline-flex items-center gap-2 text-sm font-semibold text-[#17a62b]"
type="button"
@click="addFilter"
>
<svg class="h-3.5 w-3.5" viewBox="0 0 16 16" fill="none" aria-hidden="true">
<path d="M8 1v14M1 8h14" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
</svg>
<span>필터 추가</span>
</button>
</div>
<div class="admin-members__filter-footer mt-5 flex items-center justify-between">
<button class="admin-members__filter-reset h-10 rounded-md border border-line bg-white px-4 text-sm font-semibold text-[#394047] transition hover:bg-[#eff1f2]" type="button" @click="resetFilters">
모두 초기화
</button>
<button class="admin-members__filter-apply h-10 rounded-md bg-[#15171a] px-4 text-sm font-semibold text-white transition hover:bg-black" type="button" @click="applyFilters">
필터 적용
</button>
</div>
</section>
<div class="admin-members__table mt-7 overflow-x-auto">
<table class="admin-members__table-inner min-w-[920px] w-full border-collapse text-left text-sm">
<thead class="admin-members__table-head border-b border-line text-xs uppercase tracking-[0.02em] text-[#15171a]">
@@ -187,12 +549,7 @@ const formatRelativeTime = (value) => {
</div>
</td>
<td class="admin-members__cell px-5 py-5 text-[#2f343b]">
<span
class="admin-members__status inline-flex rounded-full border px-2.5 py-1 text-xs font-semibold"
:class="member.activityStatus === '활성'
? 'border-green-200 bg-green-50 text-green-700'
: 'border-line bg-white text-[#657080]'"
>
<span class="admin-members__status text-sm text-[#394047]">
{{ member.activityStatus }}
</span>
</td>